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
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.- Run the build command(s) there.
- Stage the output directory into a scratch index (
GIT_INDEX_FILE), withGIT_WORK_TREEpointed at the output directory andgit add --force, so gitignored build artifacts get committed and your real index stays untouched. git write-tree, thengit 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.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
3if the source commit is already a parent of the target tip.--forceoverrides. - Commit an unchanged build. Exits
4if the output tree is identical to the last build.--allow-emptyoverrides. - 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
buildis 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 plaingit branch -ffails outright.) - Push on its own. Pushing is always
git build-commit push, or an explicit--push. - Publish a stale build silently.
pushdoes 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
39d609cbd83e6cf85f00899cc2b98eb252319560a9fd74bfea353f97502058dd
|
|
| MD5 |
cb4b171f039125fed6627dae723973e2
|
|
| BLAKE2b-256 |
8cd1e45696d8c28649dba76d1fe64fbba4aa0cf6d5dbbc4df6245686a386a925
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
996ee027e15f24275a5ff3af43af4879f04c4810588ff75f0bb0f8e81bb6c7b0
|
|
| MD5 |
8de85b7e7704e253774f1fe5040b541a
|
|
| BLAKE2b-256 |
715e4833059976b5a025fcc51ebfea250214d9a7ba52672ba5ae65d4a47b38b2
|