Splitting a large commit into reviewable pieces Jump to heading

A commit that renames an interface, adds a feature, fixes an unrelated bug and reformats two files is not reviewable β€” not because reviewers lack diligence, but because there is no order in which the diff makes sense. Splitting it into a sequence where each commit does one thing turns an unreadable change into four short ones, and takes about ten minutes. This recipe uses the machinery covered in Interactive Rebase Workflows.

When to use this approach Jump to heading

  • A commit’s diff spans concerns a reviewer would want to evaluate separately.
  • A refactor and a behaviour change landed together, so neither can be reverted alone.
  • You want git bisect to be able to land on a small commit rather than a large one.
  • The branch has not been shared yet, or you are the only person with it checked out.
  • If the commit is already merged to a shared branch, do not split it β€” the history is public. Improve the next one instead.

Step 1 β€” Plan the slices before touching Git Jump to heading

# What is actually in this commit?
git show --stat HEAD
git show HEAD --name-only | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn

What changed: nothing β€” but the directory histogram usually reveals the boundaries. Files under api/ moving together with files under db/migrations/ is two commits, not one.

One commit, four coherent slicesA single commit containing a rename, a schema change, a feature and a formatting pass becomes four commits in dependency order: formatting first, then the schema, then the rename, then the feature. Each builds on its own and can be reviewed or reverted independently.one commit42 files, 1 800 linesrename + schema +feature + formattingunreviewable1 Β· style: reformat the settlement moduleno behaviour2 Β· feat: add settlement_batch tableadditive3 Β· refactor: rename Ledger to Journalmechanical4 Β· feat: export settlements as NDJSONthe actual change

Order matters. Put the mechanical changes first β€” formatting, renames, additive schema β€” so the final commit contains only the substance a reviewer needs to think hard about.

Step 2 β€” Stop at the commit with an interactive rebase Jump to heading

# Start a rebase covering the commit you want to split
git rebase -i HEAD~3

# In the editor, change 'pick' to 'edit' on the target commit:
#   edit  9f8e7d6  feat: settlement export
#   pick  a1b2c3d  docs: update readme
# Git stops with the commit applied. Undo it, keeping the changes unstaged.
git reset HEAD^
git status --short         # every file from the commit, now unstaged

What changed: the commit’s content is back in the working tree and nothing is staged, so you can rebuild it in pieces. Nothing has been lost β€” git rebase --abort returns you to where you started at any point.

SAFETY WARNING β€” git reset HEAD^ during a rebase discards the commit object from the branch, and the content survives only in your working tree. Do not run git checkout . or git stash carelessly at this point: the reflog can recover the commit, but only if you know its SHA. Note it first with git rev-parse HEAD before resetting, and keep the terminal open until the split is finished.

Step 3 β€” Stage each slice with add --patch Jump to heading

# Slice 1: the formatting-only changes
git add --patch src/settlement/format.ts
# y = stage this hunk, n = skip, s = split it smaller, e = edit by hand, ? = help

git commit -m "style: reformat the settlement module

No functional change; produced by the project formatter."
# Confirm the slice is coherent before moving on
git show --stat HEAD
git diff --cached --stat        # empty: everything intended is committed
npm test --silent               # this commit must build and pass on its own

Repeat for each slice. The four interactive commands do all the work:

The four add --patch commands that mattery stages the hunk shown, n leaves it in the working tree for a later commit, s splits a hunk into smaller ones when it contains more than one change, and e opens the hunk for hand editing when two changes share adjacent lines.ystage this hunkit belongs in the commit you are buildingthe rest stays in the working treenskip this hunkit belongs to a later slicenothing is lost β€” it is still uncommittedssplit into smaller hunkswhen one hunk holds two changesworks while a blank line separates themeedit the hunk by handfor changes on adjacent linesdelete the lines this slice should not take
# When every slice is committed, resume the rebase
git status --short          # must be empty: nothing left uncommitted
git rebase --continue

Step 4 β€” Verify the final tree is byte-identical Jump to heading

A split must not change what the branch produces. This is the check that proves it.

# The SHA you noted before resetting, in Step 2
original=9f8e7d6

# Compare the resulting tree against the original commit's tree
git diff "$original" HEAD --stat
# Expect: no output β€” the content is identical, only the commit boundaries moved
# And confirm each intermediate commit builds
git rebase -i --exec 'npm test --silent' HEAD~5
# Expect: the rebase completes; any failing commit stops it with the SHA
Same destination, better pathThe original single commit and the split sequence both produce an identical final tree. The difference is that every intermediate point in the split sequence is a valid, buildable state, which the rebase exec flag verifies commit by commit.beforebasebigone enormous jump; nothing in betweenbisect can only tell you "somewhere in here"afterbase1234every point is buildable β€”rebase --exec 'npm test'proves it commit by commitidentical final tree β€” verified with git diff

When a slice will not build on its own Jump to heading

Occasionally a boundary that looked clean produces a commit that does not compile β€” the rename slice changes a call site whose definition arrives in the next slice, or a schema migration references a column added later. Two responses are correct and one is not.

Move the boundary. If a definition and its first caller genuinely cannot be separated, they are one change and belong in one commit; forcing them apart produces a commit that exists only to satisfy a rule. Reorder instead. Dependency order is usually the fix: the additive change goes first, the change that consumes it second, and a broken intermediate state disappears. What is not correct is shipping a knowingly broken commit with a note in the message, because that is precisely the commit git bisect will land on during an incident, at which point the note is no help at all.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Must every intermediate commit build? Jump to heading

It should, and it is what makes the split worth doing. A sequence where each commit builds and passes tests can be bisected, reverted individually and reviewed independently. If a slice genuinely cannot stand alone, that is a signal the boundary is in the wrong place β€” usually the interface change and its first caller belong in the same commit.

What if the changes are interleaved in one file? Jump to heading

git add --patch splits by hunk, and the s command splits a hunk further. When two changes share adjacent lines, use e to edit the staged hunk by hand β€” you keep the lines belonging to this slice and drop the rest, and the remainder stays in the working tree for the next commit.

Is this safe on a pushed branch? Jump to heading

It rewrites commits, so it is safe only on a branch nobody else has checked out, and it requires a force push afterwards. On a branch under review where colleagues have pulled, splitting orphans their copies. Do it before requesting review, or coordinate explicitly if the branch is already shared.