On this page

Rewriting unpushed history

How the commits a checkout has not pushed yet are put in the shape a push review accepts, before a handoff.

A push review refuses a commit that only reformats another, a fixup, a commit that takes back what an earlier one swept in, and a message that says something its patch does not do. Each is repaired before the push by folding, dropping, rewording or splitting the unpushed commits. stado release history does that from a plan, without touching the checkout's working tree or index, so other work in the same checkout goes on while it runs:

stado release history --root /path/to/checkout --plan plan.json --notes-ref refs/notes/attestation --json

The plan

The plan names what becomes of every commit between the branch's upstream and HEAD, each exactly once, in the order the new history keeps them. A merge that brought the upstream into the line is not named: every parent it joins other than the line's own is already held by the upstream, so it adds nothing, and the line's single-parent commits are replayed onto the upstream without it (the tree check below refuses the move if the merge carried a change of its own):

{"commits": [
  {"action": "pick", "commit": "1a2b3c4"},
  {"action": "fold", "commit": "5d6e7f8", "into": "1a2b3c4"},
  {"action": "reword", "commit": "9a8b7c6", "message": "messages/named.txt"},
  {"action": "split", "commit": "d5e4f3a", "parts": [
    {"paths": ["src/left.rs"], "message": "messages/left.txt"},
    {"paths": ["src/right.rs"], "message": "messages/right.txt"}
  ]},
  {"action": "drop", "commit": "b2c1d0e"},
  {"action": "drop", "commit": "f0e9d8c"}
]}
Action What the new history holds
pick The commit as it is, its message included.
reword The commit's change with the message the file holds.
fold Nothing of its own: its change joins the commit into names, which keeps its message.
drop Nothing: its change is left out.
split One commit per part, each with the commit's change under the paths it names and the message its file holds. The parts together name every path the commit changes, each once.

A commit may be named by any id git resolves to it.

What it guarantees

The new commits are made in a private index inside the repository's git directory, with each commit's author and author date kept. The branch moves only when:

  • the plan names every unpushed commit exactly once and nothing else;
  • every change applies where the plan puts it;
  • the new history ends on exactly the tree HEAD has, so a drop is only possible together with the commit that takes the change back;
  • the branch still points where it did when the rewrite began (the move is a compare-and-swap, so a commit made meanwhile refuses it rather than being lost).

Because the tree does not change, the working tree and the checkout's index stay valid as they are. The upstream is never touched.

With --notes-ref, a commit the new history carries unaltered — a pick that receives no fold — keeps the note its old commit had under that ref, such as an attestation. Every other new commit is listed under unattested in the answer and needs its own note before the push. Without --notes-ref no note is carried.

Refusals

Refusal Meaning
the plan … must name every unpushed commit once and nothing else: it leaves out […] and names […] The plan and the branch disagree on what is unpushed.
the plan … names a commit more than once One commit has two entries.
… folds into …, which the plan does not keep as a commit A fold's target is itself folded or dropped.
the parts of … must name every path it changes / … is split with … in two parts A split leaves out or repeats a path.
the change of … does not apply where the plan puts it: … A fold moved a change before a commit it depends on.
the plan does not keep HEAD's tree, so … is not moved; it differs in: … A drop is not taken back elsewhere, with the files that differ.
… holds the merge … of …, which … does not hold A merge joins a second line of unpushed work, which a plan of one line cannot name.
… holds no commit … does not have Nothing is unpushed.
`git update-ref …` … exited A commit landed on the branch while the rewrite ran; nothing moved.

In Stado Desktop

The release operations offer Rewrite a checkout's unpushed commits by a plan, with the checkout, the plan file and the notes ref; it runs the same command and shows its answer or its refusal word for word.

Real test

cargo test --test release-history makes a bare upstream and a clone under .build/release-history/, gives the clone a feature, a commit that only reformats it, a misnamed commit, a commit mixing two files and a commit with a note, and runs the real binary three times: a plan that leaves out a commit and a plan whose drop changes the tree are refused with the branch unmoved, and a plan that folds, rewords, splits and picks moves the branch to the planned commits with the old tree, a clean working tree, the note carried only to the unaltered commit and the upstream untouched. A second case gives the clone a feature and its format commit, merges a commit the upstream gained meanwhile, adds one more commit, and folds the format commit into the feature: the new line sits on the upstream with no merge and the old tree; a merge of a side line the upstream does not hold is then refused by name with the branch unmoved. Every command, its status and both streams are kept in that run's report.json.

Source: this website