Skip to main content

git build-commit

Run a build, commit its output onto a separate branch, and record which source commit produced it — as a real parent link in the commit graph.

$ git build-commit
✓ build → 13f5fc201b (2 files from 5527140254)

$ git log --graph --oneline build
*   13f5fc2 Build tag v1.0 (5527140)
|\
| * 5527140 Fix the axis labels on figure 3     ← source branch
* | 8760998 Build branch main (81afab4)
|\|
| * 81afab4 Add chapter 4
* | b1a6193 Build branch main (ca1d5a8)
|/
* ca1d5a8 Initial commit

The build branch keeps its own history, and every build commit says exactly what it was built from. No orphan-branch force-push, no build artifacts on your source branch.

Why not just push a folder to a branch?

Because gh-pages, ghp-import, buildbranch and friends give you a build branch whose history is a flat list of "Update site" commits with no link back. When a published page is wrong, you can't answer "which commit built this?" without guessing from timestamps.

Here, the answer is git log -1 build^2.

Install

uv tool install git+https://github.com/psomhorst/git-build-commit
# or: pipx install git+https://github.com/psomhorst/git-build-commit

That puts git-build-commit on your PATH, which is all git needs to make git build-commit work as a subcommand.

Use

git build-commit --build "make site" --dir output/site --target build
git build-commit push

--push does both in one step, which is what you want most of the time:

git build-commit --push

The separate push exists for publishing a build that already happened — after a failed push, say. It never builds, so if the target branch is behind the source branch it says so afterwards and points you at --push.

Or write the settings down once and forget them:

git build-commit init --build "quarto render --to html" --dir output/site
git add .build-commit.toml && git commit -m "Configure build-commit"

git build-commit          # every build from here on
git build-commit push

.build-commit.toml

Lives in the repository root and is meant to be committed, so the build is reproducible by anyone who clones the repo, and by CI.

build = "quarto render --to html"    # or a list, run in order
dir = "output/site"                  # becomes the entire tree of the commit
target = "build"                     # branch the output lands on
remote = "origin"
# source = "main"                    # omit to build whatever branch you are on
# message = "Build {short_sha}"      # omit for the default described below
# link = true                        # false: no parent link to the source commit

Settings can equally live in pyproject.toml under [tool.git-build-commit]. Flags always win over the file.

Profiles

One repo holding several documents, or a document with different types of output (e.g., a website and a document), requires multiple build branches. Each profile inherits the top-level settings and overrides what it needs:

remote = "origin"

[profile.report-a]
build  = "quarto render --profile report-a"
dir    = "docs/report-a/_output"
target = "build/report-a"

[profile.report-b]
build  = "quarto render --profile report-b"
dir    = "docs/report-b/_output"
target = "build/report-b"
git build-commit --profile report-a      # one
git build-commit --all --push            # all of them

Each document gets its own build branch and its own publication history, while the config lives on the source branch alone — never merged, so never conflicting. Profile names lead the commit subject (report-a: Build branch main (a1b2c3d)) because a graph viewer shows branch labels only at the tips; every older commit has to say what it is.

Two profiles may not share a target branch, and the config is rejected if they do. Note that git cannot have both a build branch and a build/… branch — pick one shape.

inputs: skip builds nothing changed for

Declaring what a profile depends on does two useful things:

[profile.report-a]
build  = "quarto render --profile report-a"
dir    = "docs/report-a/_output"
target = "build/report-a"
inputs = ["docs/report-a", "_quarto.yml", "assets"]

--all gets cheap. Without inputs, an unchanged document is still rendered in full before the identical output is discovered and discarded. With it, the build is skipped on a single git diff — nothing runs at all. So git build-commit --all --push becomes a command you can run on every commit.

The flag becomes optional. Run from inside a declared input path and that profile is selected for you:

$ cd docs/report-a && git build-commit
Profile report-a (from working directory)

Only when exactly one profile matches, and it always announces the choice — a command that silently means different things in different directories would be worse than typing -p. From anywhere undeclared or ambiguous it still refuses and lists the profiles. An explicit --profile always wins, and --force overrides the skip.

Commit messages

By default the message names the source commit by the most specific thing it is — a tag if it has one, otherwise the branch:

Build tag v1.2 (35501af)                          # tagged
Build tags v1.10, v1.9, rc (d1c4a1a)              # several tags, highest version first
Build branch main (8f74ae2)                       # untagged
Build commit 4d15d86                              # neither (e.g. --source <sha>)

Set message (or pass -m) to override it with a template:

Placeholder
{sha} {short_sha} the source commit
{tag} {tags} highest-version tag on it; all of them, comma-separated. Empty when untagged
{source} {target} branch names
{profile} the profile being built. Empty when none is in use
{subject} subject line of the source commit
{date} {time} {datetime} when the build ran

Options

Flag Meaning
-b, --build Shell command to run. Repeat for several, run in order.
-d, --dir Output directory, relative to the worktree root.
-s, --source Branch or commit to build. Default: the current branch.
-t, --target Branch to commit onto. Default: build.
-m, --message Commit message template. Default: names the tag, else the branch.
-n, --dry-run Print the plan. Runs nothing, changes nothing.
-f, --force Build a commit that has already been built.
--allow-empty Commit even when the output is identical to the last build.
--no-link Omit the source-commit parent.
--push Push after a successful build.
--keep-temp Leave the temporary worktree behind for inspection.
-p, --profile Build one named profile. Inferred from the directory when inputs allow.
--all Build every profile. Skips those whose inputs are unchanged.

Your build command runs with GBC_SOURCE_SHA, GBC_SOURCE_REF, GBC_TARGET_REF, GBC_OUTPUT_DIR and GBC_WORKTREE set, plus GIT_BUILD_COMMIT=1 so a build can tell that it is being published.

How it works

  1. git worktree add --detach <tmp> <source> — a clean checkout of the source commit, so the build never sees your uncommitted edits and never touches your working tree.
  2. Run the build command(s) there.
  3. Stage the output directory into a scratch index (GIT_INDEX_FILE), with GIT_WORK_TREE pointed at the output directory and git add --force, so gitignored build artifacts get committed and your real index stays untouched.
  4. git write-tree, then git commit-tree <tree> -p <target-tip> -p <source>. Two parents: the previous build (keeps target history) and the source commit (records provenance). It is the shape of a merge, but nothing is merged — the commit's tree is exactly the build output.
  5. git update-ref refs/heads/<target> <new> <old>, asserting the old value so a concurrent build can't be clobbered.

git log --first-parent build gives you the list of builds — though it runs on into the source history at the end, because the very first build commit has the source commit as its only parent. build^2 is always the commit that produced the current build. Set link = false if you want a build branch that is fully isolated in the graph, with provenance kept only in the trailer. The source commit is also recorded as a Source-Commit: trailer, which survives --no-link.

What the link keeps alive

A parent link is a reachability edge, and that has two consequences worth knowing before you rely on it.

Pushing the target branch also pushes the source commits it links to. The source history travels with the build branch, because it hangs off the second parents. Usually irrelevant — both branches live on the same remote anyway. It matters if you push the build branch to a different, more public remote than the source: the whole source history goes with it. Use --no-link there, and provenance falls back to the Source-Commit: trailer.

A rewritten source commit survives through the build branch. If you amend, rebase or squash a commit that was already built, the original stays reachable via the target branch: it will never be garbage collected, and it gets pushed along with the target. For an ordinary amend that is arguably correct — that build really was made from that commit, and rewriting history does not change what happened. It is the opposite of harmless when the rewrite was meant to remove a secret.

So the tool tells you when it happens, after the build output and the result line — where you are actually looking, rather than scrolled away above a few thousand lines of build log:

✓ build → 77280974b9 (1 file from a2051cd280)

╭─ ⚠  History was rewritten ──────────────────────────────────────────────────╮
│                                                                             │
│  'build' was built from de11d53, which is no longer on any branch — it was  │
│  amended, rebased or squashed after that build.                             │
│                                                                             │
│  That commit stays reachable through 'build', so it survives git gc and     │
│  git build-commit push will push it to the remote. Harmless for an          │
│  ordinary amend. If the rewrite removed something sensitive, 'build' needs  │
│  the same treatment as the source branch.                                   │
│                                                                             │
╰─────────────────────────────────────────────────────────────────────────────╯

It is an alert at the moment of the rewrite, not a continuous audit: it fires while the target tip still points at the abandoned commit, and stops once the next successful build re-links to live history. Older builds deeper in the branch are not re-checked. If you are purging a secret, the build branch is a second place that needs the same treatment as the source branch — git filter-repo and friends must be pointed at both.

What it refuses to do

  • Build the same commit twice. Exits 3 if the source commit is already a parent of the target tip. --force overrides.
  • Commit an unchanged build. Exits 4 if the output tree is identical to the last build. --allow-empty overrides.
  • Commit nothing. A missing or empty output directory is an error, rather than an empty commit that wipes the branch.
  • Strand a checked-out target branch. If build is checked out in another worktree, that checkout is fast-forwarded to the new commit afterwards — but only when it is clean. If it has uncommitted changes, nothing is moved. (This is the case where plain git branch -f fails outright.)
  • Push on its own. Pushing is always git build-commit push, or an explicit --push.
  • Publish a stale build silently. push does not build — it is a git verb and it means one thing. But if the target branch is behind the source branch, it says so once the push is done, rather than letting you deploy a build from three commits ago without a word.

Exit codes: 0 success, 1 error, 3 already built, 4 output unchanged. Anything non-zero means no new commit was created, which makes chaining safe:

publish:
	git build-commit && git build-commit push

Development

Setup, layout and release steps are in DEVELOPMENT.md.

License

MIT

Authorship

The implementation, tests and documentation in this repository were written by Claude (Anthropic). It is covered by 61 tests against real git repositories, but it is machine-written code that moves branch refs — worth reading before you point it at a branch you care about.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

git_build_commit-0.2.0.tar.gz (30.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

git_build_commit-0.2.0-py3-none-any.whl (25.4 kB view details)

Uploaded Python 3

File details

Details for the file git_build_commit-0.2.0.tar.gz.

File metadata

  • Download URL: git_build_commit-0.2.0.tar.gz
  • Upload date:
  • Size: 30.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for git_build_commit-0.2.0.tar.gz
Algorithm Hash digest
SHA256 39d609cbd83e6cf85f00899cc2b98eb252319560a9fd74bfea353f97502058dd
MD5 cb4b171f039125fed6627dae723973e2
BLAKE2b-256 8cd1e45696d8c28649dba76d1fe64fbba4aa0cf6d5dbbc4df6245686a386a925

See more details on using hashes here.

File details

Details for the file git_build_commit-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: git_build_commit-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 25.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for git_build_commit-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 996ee027e15f24275a5ff3af43af4879f04c4810588ff75f0bb0f8e81bb6c7b0
MD5 8de85b7e7704e253774f1fe5040b541a
BLAKE2b-256 715e4833059976b5a025fcc51ebfea250214d9a7ba52672ba5ae65d4a47b38b2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page