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
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.
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 |
{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. |
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 clean list of builds; build^2 is
always the commit that produced the current build. 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.
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.1.2.tar.gz.
File metadata
- Download URL: git_build_commit-0.1.2.tar.gz
- Upload date:
- Size: 23.4 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 |
8cd55f5f92ef4ce8f197bad51988648fca5df502666cb943db18078e5ea129a8
|
|
| MD5 |
7230a45655313545757d1780b3a203d4
|
|
| BLAKE2b-256 |
b5b53c7e5102ce96ad49acd5a00213089c22fcd9ac8a126624757fb8468858e1
|
File details
Details for the file git_build_commit-0.1.2-py3-none-any.whl.
File metadata
- Download URL: git_build_commit-0.1.2-py3-none-any.whl
- Upload date:
- Size: 20.5 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 |
3beab002c83e17ebc7741a107ce2f1b11dea9250386b160417eb5ae12590c09d
|
|
| MD5 |
6b2b63509807abb1443492371da5e3c9
|
|
| BLAKE2b-256 |
14d8f540cae0c9d52e7f5e81070b2ac19d43a58e2ff59d078444516b678e6005
|