Metadata-Version: 2.4
Name: git-build-commit
Version: 0.1.3
Summary: Run a build, commit its output to a separate branch, and link that commit back to the source commit that produced it.
Project-URL: Homepage, https://github.com/psomhorst/git-build-commit
Project-URL: Source, https://github.com/psomhorst/git-build-commit
Project-URL: Issues, https://github.com/psomhorst/git-build-commit/issues
Author-email: Peter Somhorst <peter@somhorst.xyz>
License-Expression: MIT
License-File: LICENSE
Keywords: build,gh-pages,git,provenance,publish,worktree
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Version Control :: Git
Requires-Python: >=3.11
Requires-Dist: rich>=13
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# 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

```sh
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

```sh
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:

```sh
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:

```sh
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.

```toml
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:

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

## Development

Setup, layout and release steps are in [DEVELOPMENT.md](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.
