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.

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

  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 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 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.1.3.tar.gz (24.7 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.1.3-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: git_build_commit-0.1.3.tar.gz
  • Upload date:
  • Size: 24.7 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.1.3.tar.gz
Algorithm Hash digest
SHA256 075f17971568591d3dd9632e70d2a29144a506f159999f9af746be72b0de11c6
MD5 793e59a0b8e18ba1cc0684d2069a7c06
BLAKE2b-256 6fbfe392ecba29f7dc4cf60ddf739dc9cdb3f98c657f068a131106590adfa725

See more details on using hashes here.

File details

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

File metadata

  • Download URL: git_build_commit-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 21.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

Hashes for git_build_commit-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 3cac1074354a8d48485fe091977abbdc1f11860635b9e2d4b43399f671bf2221
MD5 1ce0634c3695a2f66ffaa923bb7a53c7
BLAKE2b-256 6e862c12d8a96c8e8e88096815721ef9b99dc29452910d227c5b6f2c4d0dd65b

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