Software Engineering · Version Control Practice
Squash, Fixup, and How a Commit Series Is Composed
The difference between squashing and fixing up, how autosquash matches commits to targets, and what information survives when intermediate commits are collapsed.
A branch that took a week to write rarely contains a week’s worth of independent changes. It contains the change, plus the corrections made while testing, plus the corrections made in response to review, plus a commit that fixes a typo in the message of the first one. The series is a log of the work, and the work’s final state is what actually matters to everyone downstream. Collapsing the series is the operation that converts one into the other.
Two ways to combine
git rebase -i presents the commits oldest first with a command per line. squash and fixup both combine a commit into its predecessor by replaying its patch and folding the resulting tree into the previous commit. The difference is entirely in the message.
Squash concatenates both messages, separated by a line of # This is a combination of N commits, and stops the rebase so the combined message can be edited. That stop is the point: the author of the series is being asked to write a single description that covers both changes. Fixup discards the later message and keeps the earlier one, then continues without stopping.
The rule that follows is about which message is the right one. A commit whose only purpose was to correct an earlier commit has no message worth keeping, so fixup is correct. A commit that adds a genuinely distinct piece of the same logical change has a message worth merging with the earlier one, so squash is correct. Using fixup where squash was meant produces a commit whose message describes only part of its diff, which is a small lie that survives forever in the log.
pick 4a1c9e2 Add retry budget to the client
fixup 9d3f0b1 Fix off-by-one in the retry counter
fixup 7e2b4c8 Remove debug logging
squash 1c8a5d3 Document the retry semantics
The autosquash convention
The pairing of a correction to its target is done by message convention. A commit whose subject begins with fixup! followed by the subject of an earlier commit is, when --autosquash is enabled, automatically reordered to sit directly after that target and given the fixup command. squash! does the same with the squash command.
git commit --fixup=<target> constructs the subject from the target’s subject without requiring it to be typed, and git commit --squash=<target> does the same for a squash. The matching is textual, on the subject line, and it takes the first commit in the list whose subject matches. Two commits with the same subject — common when a series was split and then partly recombined — make the match ambiguous, and the wrong target may be chosen.
The convention only works if the correction commit is made against the state that includes the target. If the target has already been rewritten, the fixup! commit’s patch applies to a different base and the autosquashed replay conflicts. This is why the convention is usually paired with committing corrections onto the branch tip and squashing only at the end of the review cycle: the target has not moved in the meantime.
--fixup=amend:<target> is a later form that also replaces the target’s message with the correction’s message, rather than discarding it. It is useful for a correction that consists only of a message change, where a plain fixup would produce an empty patch and be dropped.
What collapsing loses
The combined commit’s author line is the first commit’s author, and its committer is whoever ran the rebase. Timestamps of the intermediate commits are gone. Review comments that were attached to a specific intermediate commit lose their anchor, because the commit id they referenced no longer exists.
More significantly, the diff boundaries disappear. If the series contained a mechanical change followed by a semantic one, the combined commit contains both and a later reader cannot separate them. A bisect that lands on the combined commit has to be understood as a whole. A revert of the combined commit undoes both.
The usual mitigation is to squash only within logical units and keep those units separate. A series of four commits, each a coherent change, survives review better than one commit of four hundred lines. The composition decision is therefore not “how few commits can I get away with” but “what is the smallest set of commits each of which can be understood, tested, and reverted alone”. The number that satisfies that is usually between one and ten for a feature branch, and it is determined by the structure of the change rather than by a target count.
There is an edge case that catches people: a fixup whose patch is entirely already present in the target produces an empty commit, which Git drops by default during replay. The result is correct — the change is already there — but the commit count drops by more than expected, and any fixup! commit that was itself the only record of a decision leaves no trace.
Partial staging and the same problem at file granularity
The same collapse question appears one level down, in how a single commit is assembled. git add -p presents hunks and allows them to be staged selectively, with s to split a hunk further and e to edit it manually. The point is to make each commit contain one change even when the working tree contains several unrelated ones.
The hunk splitter is line-based and has no syntax knowledge. A hunk that adds a parameter to a function signature and updates three call sites will split into the signature line and the call sites only if there are enough unchanged lines between them; otherwise it is one hunk and the manual edit mode is required. Editing a hunk means rewriting the patch text by hand, changing + and - prefixes and adjusting the counts in the @@ header. A wrong count is rejected rather than silently misapplied, which makes the operation safe but slow.
The reason to bother is that commit boundaries are the unit of every later operation. A revert undoes a commit. A cherry-pick takes a commit. A bisect isolates a commit. A commit containing two unrelated changes breaks all four. The cost of splitting is paid once, at authoring time; the cost of not splitting is paid by whoever next needs to undo one of the two changes.
Fixup commits as a review protocol
A workflow that uses the convention heavily looks like this: the branch is pushed with one commit per logical change; review produces corrections; each correction is committed with --fixup against the commit it corrects and pushed as an additional commit. The reviewer can then see the corrections as a delta against the previous review, which is far easier than re-reading a force-pushed branch.
At the end of the review, git rebase -i --autosquash collapses the corrections into their targets, producing a clean series. The force-push that follows is expected and the review tooling is configured for it.
The trade is that the pushed branch is not the branch that lands, so any automated check that ran on the intermediate state ran on a state that no longer exists. This is acceptable when the checks are about the final tree and not about commit boundaries, and unacceptable when the repository enforces one-commit-per-change in CI.
An alternative that avoids the force-push is to merge the fixups as ordinary commits and accept the noise, or to squash the whole branch into a single commit at integration. The latter moves the composition decision to the integrating party, which is a reasonable division when the branch is short and the main line’s history is the only one anyone reads.
Message quality after collapsing
The combined message is the one artifact that cannot be recovered later, and it is usually written in a hurry at the end of a rebase. Three properties make it useful: a subject that states the change in the imperative, a body that explains why the change was needed rather than what it does, and a reference to the constraint that forced the design. The diff already says what changed; the message is the only place the reasoning lives.
Conventional prefixes on the subject — a scope, a type, an issue reference — are a formatting choice, and their value depends entirely on whether tooling parses them. Where it does not, they add a fixed cost to every subject line without a corresponding benefit.
A message that describes the intermediate steps rather than the final state is the common failure after a squash. If the series added a feature, then removed half of it, the combined message should describe what remains. Concatenating the intermediate messages and editing lightly produces a description of a change that no longer exists in that form.
The mechanical operations here are all replay, and they inherit replay’s properties: new ids, folded conflict resolutions, and a reflog trail for recovery. Those properties are set out in rebase as patch replay. The complementary operation — taking a single commit from one line to another without moving the line — has its own identity problem, treated in cherry-pick, patch identity, and duplicate commits.
References
- Git documentation — git-rebase — Git project
- Git documentation — git-commit — Git project
- Pro Git, 2nd edition — Rewriting History — Chacon & Straub