Skip to content
GhostbranchdocsGitHub

History modes

Choose how a feature's commits appear on your public repo, and whose name they carry.

Your private branch can be as messy as you like. When you publish, you pick what the public history looks like: every commit as you made it, one commit, or commits you regroup and reword. The examples publish a feature add-search with two commits.

Mode What goes public Good for
preserve Every feature commit, as written Tidy histories you want to keep
squash One commit, with a message you write Work in progress with noisy commits
curated The groups, order and messages you choose Large features with a story to tell

Pass --mode to promote. Without it, promote uses promote.default_mode from .ghostbranch.yml, which is preserve unless you change it.

preserve publishes each feature commit as it is, with its message and its date. Two things are taken out: the overlay’s paths and any internal sections. A commit left empty after that is dropped, and the plan says so (2 public commit(s), 1 dropped as empty).

If every commit is left empty, there’s nothing to publish, in any mode. The gate then stops with NOTHING_TO_PUBLISH (reason all_stripped) before it pushes anything or starts CI. So does a feature with no commits of its own yet (reason no_commits). See A feature with nothing to publish.

Commit messages are published exactly as written, trailers such as Co-Authored-By: included. Ghostbranch doesn’t strip or rewrite them. If a coding agent adds its own trailer and you’d rather it didn’t go public, tidy the messages on your branch first, or use squash or curated.

squash publishes the whole feature as one commit. Write its message with -m:

Terminal
ghostbranch promote add-search --mode squash -m "Add search to the docs" --plan
Plan 20261012T163925Z-5eccc5 saved to /home/you/.local/state/ghostbranch/workspaces/default/plans/20261012T163925Z-5eccc5.json.
squash: 1 public commit(s)
Publish it with: ghostbranch apply 20261012T163925Z-5eccc5
Next:
ghostbranch apply 20261012T163925Z-5eccc5 --yes
a person allowed agents to publish here; publish the plan

Without -m, promote stops and asks for one: error: a commit message is required: pass -m/--message [MISSING_INPUT]. The pull request’s title is the message’s first line, unless you pass --title.

curated lets you regroup, reorder and reword the commits. Write a JSON file that names each feature commit exactly once, in the order to publish, with a message for each group. The commits come from the gate’s result (ghostbranch gate add-search --json, under data.commits, each with its source SHA, subject, author and files):

groups.json
{"commits": [
{"from": ["893f3408d9cad3ce18bbd77685e8d2f5a6b0f6c5", "09278daf2c364e1770327ce8df39e1e8d47f3e73"],
"message": "Add search to the docs"}
]}

This one folds both commits into one. Then:

Terminal
ghostbranch promote add-search --mode curated --curate groups.json --plan
Plan 20261012T163948Z-831f34 saved to /home/you/.local/state/ghostbranch/workspaces/default/plans/20261012T163948Z-831f34.json.
curated: 1 public commit(s)
Publish it with: ghostbranch apply 20261012T163948Z-831f34
Next:
ghostbranch apply 20261012T163948Z-831f34 --yes
a person allowed agents to publish here; publish the plan

With more than one group, add --title for the pull request. A file that leaves a commit out, or names one twice, is refused before anything is built:

Terminal
ghostbranch promote add-search --mode curated --curate groups.json --plan
error: the curate groups must name each of the feature's 2 commits exactly once [CURATE_INVALID]
Next:
ghostbranch promote add-search --mode curated --plan --repo you/project --curate <curate>
fix the --curate file (error.details lists the problem SHAs; the gate result lists the feature's commits) and run this again

With --json, error.details lists the SHAs that are missing, repeated, unknown or ambiguous. Reordering commits that change the same lines can fail with CURATE_CONFLICT or CURATE_TREE_MISMATCH: keep those in order, or reorder them on your branch first. Whatever the groups, the last public commit always ends on exactly the files the gate tested.

The mode decides what the leak scan reads. A preserve publish checks every commit, so a secret added and later deleted still blocks it. squash and curated check what they publish, so a feature whose older commit held a secret can be published as one commit, or with that commit grouped with the one that removed it, and the secret never ships.

promote.author_email in .ghostbranch.yml decides whose name each public commit carries:

  • noreply (the default) publishes every commit as the publishing account’s GitHub noreply identity, such as you <12345+you@users.noreply.github.com>. Your local email never goes public.
  • keep publishes each commit’s own author, as recorded on your machine.
  • map looks each author’s email up in promote.author_map, and publishes the identity it names:
.ghostbranch.yml
promote:
author_email: map
author_map:
"you@laptop.local": "Your Name <you@example.com>"

Under map, an author who isn’t in the map stops the publish with AUTHOR_UNMAPPED, and error.details.emails lists them. A person adds them to the map, or switches back to noreply.

Commits someone made on the pull request itself, which feature pull brought into your feature, always keep their own authors. A squash publish credits them with a Co-authored-by: trailer. See Updating an open PR.

GitHub’s merge button has three choices, and only two of them keep what preserve published:

  • Create a merge commit keeps every commit as published, and adds one merge commit on main.
  • Rebase and merge keeps every commit, but copies each one onto main.
  • Squash and merge replaces them all with one new commit on main. The commits, messages and authors you chose to publish one by one are gone from main’s history: it is much as if you had published with squash, but with a message GitHub wrote.

So merge a preserve pull request with a merge commit or a rebase. If you always want one commit per pull request, publish with squash instead, which lets you write that commit’s message yourself and is checked by the gate as one commit.

On a stack, it does the same to each pull request: one commit per pull request.

CURATE_INVALIDexit 2The --curate file is malformed, or its groups don't name every feature commit exactly once.
Causes
  • not JSON of the form {"commits": [{"from": [<sha>...], "message": "..."}]}
  • a group with no commits or an empty message
  • a SHA that is missing, repeated, ambiguous, or not one of the feature's commits
What to do

Fix the file using error.details (missing, repeated, unknown, ambiguous). The feature's commits are listed in the gate result's commits.

CURATE_CONFLICTexit 4Replaying the curated groups in their order hit a conflict.
What to do

Change the group order in the --curate file so commits that touch the same lines keep their original order, or reorder the commits on your feature branch yourself. Ghostbranch never resolves conflicts.

CURATE_TREE_MISMATCHexit 4The curated replay ends on a different tree from the one the gate's CI tested.
What to do

Keep commits that touch the same lines in their original order, or reorder on your feature branch, gate again, and use preserve.

AUTHOR_UNMAPPEDexit 1promote.author_email is map, and some commit authors are not in promote.author_map. Commits feature pull brought in from the public PR keep their authors and are never unmapped.
What to do

A person adds the emails in error.details.emails to promote.author_map in .ghostbranch.yml on the overlay branch, or switches author_email to noreply.

This needs a person.