In the previous lesson we used git rebase for one thing only: changing a branch's base. But the mechanism underneath — breaking a branch down into a list of commits and applying them again one by one — allows a great deal more if somebody lets you edit that list before it runs.

That is git rebase -i. Git opens a text file containing the commits it is about to reapply, one per line, and waits for you to decide what to do with each: leave it as it is, change its message, join it with the one before, split it in two, move it somewhere else or delete it. When you save and close, Git carries out your instructions to the letter.

It is the tool that turns a real branch — with its second thoughts, its backtracking and its wips — into a sequence of commits somebody else can read and understand. Bruno needs it right now: his feature/csv-export branch has six commits, three of them called fix, wip 2 and wip 3, and he wants to publish it for review.

And with it comes back, stronger than ever, the golden rule: interactive rebase rewrites commits, so it applies only to work you have not yet published (or to a published branch that only you use and about which you can warn people).

Contents

  1. What the to-do list is
  2. The available commands
  3. The starting point: Bruno's branch
  4. A full session: from six commits to two
  5. squash versus fixup
  6. Reordering and removing commits
  7. Splitting a commit in two with edit
  8. Changing just a message with reword
  9. --autosquash: commit --fixup and commit --squash
  10. --exec: validating every commit
  11. break, label, reset and merge
  12. What to do if you get lost halfway

  1. What the to-do list is

When you launch an interactive rebase, Git prepares the list of commits it is going to reapply and opens it in your editor:

git rebase -i main
pick b1c4f80 Start the export
pick 3d6e9a1 Add export button
pick c8f2b47 more csv stuff
pick 2a7f4c1 fix
pick 9e3b8d6 wip 2
pick 5c1a9f2 wip 3

# Rebase e91d4a8..5c1a9f2 onto e91d4a8 (6 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later)
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.

Three details to take in before touching anything:

  • The order is chronological ascending: the oldest commit at the top. That is the opposite of git log, which shows the most recent first. This inversion is beginners' number-one source of mistakes.
  • The lines run from top to bottom. Reordering lines reorders commits.
  • Deleting a line is the same as drop: that commit disappears. Git warns you in capitals for a reason.

And a point about the argument: git rebase -i main means "reapply onto main everything that is on my branch and not on main", just as in the previous lesson. If you only want to touch the last N commits of the branch you are on, without changing the base, you use:

git rebase -i HEAD~4     # the last 4 commits
git rebase -i 8d4e6b2    # everything AFTER that commit

Careful with that second one: git rebase -i <commit> does not include <commit> in the list, because <commit> is the base. To touch the last four commits, the base is the fifth counting back from the end: HEAD~4.

If your default editor is not the one you want, you change it with git config --global core.editor "code --wait" (or nano, vim, whatever you use). It was explained in lesson 01-05, and here you will be grateful for it.

  1. The available commands

Command Short form What it does
pick p Applies the commit as it is. It is the default
reword r Applies the commit and opens the editor to change its message
edit e Applies the commit and stops so that you can modify it (or split it)
squash s Joins the commit with the previous one and opens the editor to combine the two messages
fixup f Joins the commit with the previous one and discards its message
fixup -C Joins with the previous one but keeps this message instead of the earlier one
drop d Removes the commit. Equivalent to deleting the line
exec x Runs a shell command at that point in the sequence
break b Stops there without doing anything, so that you can look
label l Gives a temporary name to the current HEAD
reset t Goes back to a previous label
merge m Creates a merge commit with a label

The first four cover 95% of real use. drop and exec turn up from time to time. break is a convenience. label, reset and merge only appear when you use --rebase-merges to rebuild a history containing merges, and we shall not need them day to day: it is enough to know what they mean if you come across them.

A note on squash and fixup: they join with the previous commit in the list, that is, with the line above. That is why they can never be the first line (there is nothing to join with) and why the order matters so much.

  1. The starting point: Bruno's branch

Bruno has spent a week on exporting tasks to CSV. The branch works, but the history is a shambles:

git switch feature/csv-export
git log --oneline main..HEAD
5c1a9f2 wip 3
9e3b8d6 wip 2
2a7f4c1 fix
c8f2b47 more csv stuff
3d6e9a1 Add export button
b1c4f80 Start the export

With --stat you can see what is really in each one:

git log --oneline --stat main..HEAD
5c1a9f2 wip 3
 app.js | 4 ++--
9e3b8d6 wip 2
 app.js | 7 +++++--
2a7f4c1 fix
 app.js | 2 +-
c8f2b47 more csv stuff
 app.js | 18 ++++++++++++++++++
3d6e9a1 Add export button
 index.html |  3 +++
 styles.css | 8 ++++++++
b1c4f80 Start the export
 app.js | 6 ++++++

Read calmly, the branch does two independent things:

  1. A function that converts the task list to CSV and downloads it (app.js): commits 1, 3, 4, 5 and 6.
  2. A button in the interface that calls it (index.html + styles.css): commit 2.

Bruno's aim is to end up with exactly those two commits, in that order, with messages that explain what they do. And the important part: the final content of the branch is not going to change by a single line. A well-executed interactive rebase reorganises how the story is told, not what ends up there.

# The fingerprint of the final state, so that we can check it afterwards
git rev-parse HEAD^{tree}
7f4b2c9e1d8a3b6f5c2e9d4a7b1f8c3e6d5a2b9f

That is the hash of the tree at the tip of the branch. When the rebase finishes it must be the same. It is the best quality control there is for this operation.

  1. A full session: from six commits to two

Bruno launches the interactive rebase:

git rebase -i main

And edits the list that appears. The first job is to reorder so that what belongs together sits together, and then to decide the command for each line. This is how the file looks before saving:

pick b1c4f80 Start the export
squash c8f2b47 more csv stuff
fixup 2a7f4c1 fix
fixup 9e3b8d6 wip 2
fixup 5c1a9f2 wip 3
pick 3d6e9a1 Add export button

Read it from top to bottom like a recipe:

  1. pick b1c4f80 — apply the first commit as it is. It will be the basis of the final export commit.
  2. squash c8f2b47 — join it with the previous one, and let me combine the messages.
  3. fixup 2a7f4c1 — join it with the previous one and throw its message away (fix contributes nothing).
  4. fixup 9e3b8d6 — the same with wip 2.
  5. fixup 5c1a9f2 — the same with wip 3.
  6. pick 3d6e9a1 — the button commit, moved to the end, stays as it is for now.

On saving and closing, Git starts running. At the squash step it stops and opens the editor with the two messages:

# This is a combination of 2 commits.
# This is the 1st commit message:

Start the export

# This is the commit message #2:

more csv stuff

# Please enter the commit message for your changes.

Bruno deletes all of that and writes a decent message:

Add the export of tasks to CSV

Generates a CSV file with the title, the status and the creation date
of each task and downloads it from the browser with no calls to the server.

Quotation marks and semicolons in the title are escaped as per RFC 4180.

He saves, closes, and the rebase carries on. The three fixups ask nothing. At the end:

Successfully rebased and updated refs/heads/feature/csv-export.
git log --oneline main..HEAD
a3f9c14 Add export button
d72e6b8 Add the export of tasks to CSV

The second one's message still needs polishing, but we shall see that in section 8. First, the check we promised:

git rev-parse HEAD^{tree}
7f4b2c9e1d8a3b6f5c2e9d4a7b1f8c3e6d5a2b9f

Identical to the one from before the rebase. The branch's content has not changed; only its history has. That is the guarantee that makes this operation reasonable rather than reckless.

gitGraph
   commit id: "e91d4a8"
   branch before
   commit id: "b1c4f80"
   commit id: "3d6e9a1"
   commit id: "c8f2b47"
   commit id: "2a7f4c1"
   commit id: "9e3b8d6"
   commit id: "5c1a9f2"
   checkout main
   branch after
   commit id: "d72e6b8"
   commit id: "a3f9c14"

  1. squash versus fixup

Both join a commit with the previous one. The only difference is in the message, but it determines which to use in each case:

squash fixup
Resulting content That of the two commits combined That of the two commits combined
Resulting message The two messages together, editable Only that of the previous commit
Does it open the editor? Yes No
When to use it Both commits contribute information to the message The second one was a fix, a typo, a wip
Variant fixup -C keeps the second one's message

In practice: if the message of the commit you are absorbing is worth anything, squash; if it is rubbish (fix, wip, now it works, semicolon), fixup. And since a squash of five commits forces you to clean up five messages by hand in the editor, as soon as you have three or more it usually pays to use fixup on all of them and write the message once with reword.

  1. Reordering and removing commits

You have already seen reordering: in Bruno's session, moving 3d6e9a1 from second position to last was enough to group the work by topic.

Removing is just as straightforward: put drop in front of the line, or delete the line entirely. The two forms are equivalent; drop is preferable because it leaves a visible record of the intention and avoids deleting a line by accident:

pick b1c4f80 Start the export
drop 4c8f1a5 Add console.log for debugging
pick 3d6e9a1 Add export button

Two warnings that apply to both operations:

  • Reordering and removing can cause conflicts. If commit c8f2b47 modified a line that b1c4f80 had just created and you separate them, the second one no longer finds the context it expected. It is resolved like any rebase conflict (lesson 05-01): resolve, git add, git rebase --continue. Remember that the ours/theirs sides are still inverted.
  • Removing a commit from the middle leaves the branch with fewer changes, not necessarily with less code that is needed. If you drop the commit that created a function and keep the one that calls it, the branch no longer builds. --exec (section 10) exists precisely to detect that.

  1. Splitting a commit in two with edit

This is interactive rebase's star turn and the one that impresses most the first time. Suppose Bruno looks at d72e6b8 and decides it mixes two things: the function that generates the CSV and the one that triggers the download. He wants to split it.

Step 1: mark the commit with edit.

git rebase -i main
edit d72e6b8 Add the export of tasks to CSV
pick a3f9c14 Add export button

Git applies the commit and stops:

Stopped at d72e6b8...  Add the export of tasks to CSV
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

Step 2: undo the commit but keep its changes.

git reset HEAD^
Unstaged changes after reset:
M	app.js

This is what does the magic: git reset with no options (equivalent to --mixed) moves HEAD one commit back and leaves all the changes in the working tree, unstaged. The commit has gone; its content is still in the file. It is the only appearance of reset in this module; its three modes and its use as an undo tool are studied thoroughly in lesson 09-02.

git status --short
 M app.js

Step 3: stage and commit in parts. This is where git add -p comes in, which you learned in lesson 02-04: staging only some hunks of the file.

git add -p app.js

Bruno accepts with y the hunks belonging to the tasksToCSV function and rejects with n those belonging to downloadFile. Then:

git commit -m "Add the conversion of tasks to CSV format"
[detached HEAD 6e1d9f3] Add the conversion of tasks to CSV format
 1 file changed, 14 insertions(+)

And the rest:

git add app.js
git commit -m "Download the generated CSV from the browser"
[detached HEAD b8c2a70] Download the generated CSV from the browser
 1 file changed, 9 insertions(+)

Step 4: carry on.

git rebase --continue
Successfully rebased and updated refs/heads/feature/csv-export.
git log --oneline main..HEAD
f04b7e2 Add export button
b8c2a70 Download the generated CSV from the browser
6e1d9f3 Add the conversion of tasks to CSV format

One commit has become two, without losing a line. Note that the commit after it (a3f9c14f04b7e2) has changed hash too: by inserting commits before it, its parent is a different one, and you know by now what that implies.

Trick: if when you run git reset HEAD^ you realise the split is not clean (the changes are interleaved within the same function), you can edit the files by hand before each commit. You are not obliged to make each half an exact subset of the original; only to make the end result the same. Verify it with the rev-parse HEAD^{tree} trick.

  1. Changing just a message with reword

The button commit's message is terse and says nothing about where the button goes. To change only the message, without touching anything else:

git rebase -i main
pick 6e1d9f3 Add the conversion of tasks to CSV format
pick b8c2a70 Download the generated CSV from the browser
reword f04b7e2 Add export button

Git applies the first two, and at the third it opens the editor with the current message. Bruno changes it to:

Add the export button to the action bar

He saves, closes, and that is that:

git log --oneline main..HEAD
9c7e5a1 Add the export button to the action bar
b8c2a70 Download the generated CSV from the browser
6e1d9f3 Add the conversion of tasks to CSV format

Three commits, three messages that explain what each one does, and a branch ready to publish and to be reviewed.

A note for the most frequent case: if the commit you want to rewrite is the last one, you do not need an interactive rebase. git commit --amend (lesson 02-04) does the same with less ceremony. reword is for the ones further back.

What makes a good commit message — the imperative, the subject line, the body, the trailers — is the subject of lesson 08-01. Here we are concerned only with the mechanism of changing it.

  1. --autosquash: commit --fixup and commit --squash

There is a far more comfortable flow than marking fixup by hand, and it consists of deciding it at the moment you make the commit, while you still remember which commit it is correcting.

Imagine Bruno is working and spots a typo in commit 6e1d9f3, which he made two days ago. Instead of creating a commit called fix:

# Correct the typo in app.js
git add app.js
git commit --fixup 6e1d9f3
[feature/csv-export 2b8f4d1] fixup! Add the conversion of tasks to CSV format

Git has created an ordinary commit, but with a special message: fixup! followed by the subject of the commit it corrects. That prefix is a marker Git knows how to interpret.

git log --oneline main..HEAD
2b8f4d1 fixup! Add the conversion of tasks to CSV format
9c7e5a1 Add the export button to the action bar
b8c2a70 Download the generated CSV from the browser
6e1d9f3 Add the conversion of tasks to CSV format

And now for the pretty part:

git rebase -i --autosquash main

The list that appears comes already ordered and with the commands filled in:

pick 6e1d9f3 Add the conversion of tasks to CSV format
fixup 2b8f4d1 fixup! Add the conversion of tasks to CSV format
pick b8c2a70 Download the generated CSV from the browser
pick 9c7e5a1 Add the export button to the action bar

Bruno only has to save and close without touching anything. Git has moved the correcting commit right behind its target and marked it as fixup.

Command Message prefix In the rebase it becomes Effect on the message
git commit --fixup <sha> fixup! fixup The fix's message is discarded
git commit --squash <sha> squash! squash The editor opens so you can combine them
git commit --fixup=reword:<sha> amend! fixup -C Only changes the original's message

So that you do not have to remember --autosquash every time:

git config --global rebase.autoSquash true

And a warning: --autosquash pairs commits by the text of the subject, not by the hash. If two commits on the branch have exactly the same subject, the pairing can be ambiguous. In practice it almost never happens, and you can always review the list before saving (which is exactly what the editor is asking you to do).

This flow — work, correct with --fixup, and flatten everything with a rebase -i --autosquash just before publishing — is probably the most productive way to use Git day to day. It frees you from thinking about the history while you are programming, and lets you tidy it up in one go at the end.

  1. --exec: validating every commit

A clean history that does not build is worth nothing. When you reorder, join or split commits, it is easy for an intermediate one to end up broken: the one that calls a function that does not exist yet, the one that imports a module which arrives two commits later.

--exec runs a command after every reapplied commit and stops the rebase if the command returns a non-zero exit code:

git rebase -i --exec "npm test" main

Git automatically inserts an exec line after each pick:

pick 6e1d9f3 Add the conversion of tasks to CSV format
exec npm test
pick b8c2a70 Download the generated CSV from the browser
exec npm test
pick 9c7e5a1 Add the export button to the action bar
exec npm test

If the second npm test fails:

Executing: npm test
...
warning: execution failed: npm test
You can fix the problem, and then run

  git rebase --continue

The rebase stops at that point, with the repository in exactly the state of that commit. You can fix whatever it is, run git commit --amend, and carry on.

You can also write the exec lines by hand wherever you like, instead of after every commit. And it does not have to be a test suite; any quick check will do:

pick 6e1d9f3 Add the conversion of tasks to CSV format
exec node --check app.js
pick b8c2a70 Download the generated CSV from the browser
exec node --check app.js
exec grep -rn "console.log" app.js && exit 1 || exit 0

That last line is a small guard: it fails if there is a forgotten console.log left. Automating this sort of check permanently, so that it runs by itself on every commit, is the subject of hooks (lesson 06-01).

  1. break, label, reset and merge

Three less frequent commands, but it is worth recognising them.

break stops the rebase at that point without doing anything else. It is a deliberate pause:

pick 6e1d9f3 Add the conversion of tasks to CSV format
break
pick b8c2a70 Download the generated CSV from the browser

Useful for looking at the state of the project in the middle of the sequence, running something by hand or simply drawing breath. You carry on with git rebase --continue.

label, reset and merge only appear when you use git rebase -i --rebase-merges, which rebuilds a history preserving its merge commits instead of flattening it. Git then generates a list looking like this:

label onto

reset onto
pick 6e1d9f3 Add the conversion of tasks to CSV format
label export

reset onto
pick 9c7e5a1 Add the export button to the action bar
label button

reset onto
merge -C 4a7d2f8 export # Merge the export
merge -C 8e3b6c1 button # Merge the button

It reads like this: label X saves a marker at the current point, reset X goes back to that marker, and merge -C <sha> X creates a merge commit with the branch marked as X, reusing the message of commit <sha>. It is a small language for describing a graph. You will hardly ever need it; it is enough not to be alarmed if it turns up.

  1. What to do if you get lost halfway

A long interactive rebase can turn into a maze: four conflicts, an edit half done and the feeling of not knowing where you are. Ways out, from the least to the most drastic:

1. Ask where you are. git status during an interactive rebase is extraordinarily informative:

git status
interactive rebase in progress; onto e91d4a8
Last commands done (3 commands done):
   pick 6e1d9f3 Add the conversion of tasks to CSV format
   fixup 2b8f4d1 fixup! Add the conversion of tasks to CSV format
Next commands to do (2 remaining commands):
   pick b8c2a70 Download the generated CSV from the browser
   pick 9c7e5a1 Add the export button to the action bar
You are currently rebasing branch 'feature/csv-export' on 'e91d4a8'.

It tells you what has been done and what is left. The live list is also in .git/rebase-merge/git-rebase-todo, and git rebase --edit-todo opens it so you can change what remains to be done without aborting. It is the elegant way out when you realise halfway through that you planned badly:

git rebase --edit-todo    # change the pending commands
git rebase --continue

2. See what is failing right now.

git rebase --show-current-patch

It shows the commit being applied, with its full diff. Very useful when the conflict makes no sense at first glance.

3. Abort.

git rebase --abort

It undoes the whole rebase and returns the branch to its original state, with the original hashes. It is safe, it is instantaneous and it leaves no trace. When in doubt, abort: replanning the list with a clear head takes two minutes; untangling a badly handled rebase, much longer.

4. If you have already finished and the result is a disaster. This is where the safety net comes in: the original commits are still in the object database and the reflog records where your branch was before you started. The full procedure for going back is the subject of lesson 09-04. And the same cheap reflex as always, which saves you having to use it:

git branch backup-before-rebase
git rebase -i main
# Gone wrong? -> git reset --hard backup-before-rebase

Common Mistakes and Tips

Mistake 1: reading the list as though it were git log. In the editor, the oldest commit is at the top. Marking squash on the wrong line because of this inversion is mistake number one.

Mistake 2: putting squash or fixup on the first line. There is no previous commit to join with and the rebase fails before it starts. The first line is always pick, reword, edit or drop.

Mistake 3: deleting a line by accident. That commit disappears. Use an explicit drop when you mean to remove one, and if you delete a line by mistake, close the editor without saving: Git aborts the rebase.

Mistake 4: interactively rebasing a branch that is already published and shared. The golden rule has no convenient exceptions. If other people work on that branch, do not rewrite it.

Mistake 5: running git commit instead of git rebase --continue after resolving a conflict. Except in the edit flow (where you do commit yourself), the one that creates the commit is the rebase.

Mistake 6: forgetting that a rebase runs the hooks. If you have a slow pre-commit, a rebase of twenty commits runs it twenty times. --no-verify disables it, with the responsibility that implies.

Mistake 7: flattening everything into one gigantic commit "so that it looks clean". A 900-line commit saying "Add the export" is as useless as six commits called wip. The aim is for each commit to be one complete, comprehensible change, not for there to be only one.

Tip 1: check the tree before and after. git rev-parse HEAD^{tree} must match if your intention was only to reorganise. If it does not match, you have lost or duplicated something.

Tip 2: adopt --fixup from today. As soon as you have the reflex of correcting with git commit --fixup <sha> instead of with a commit called fix, the final tidy-up becomes trivial. Enable rebase.autoSquash true.

Tip 3: do small, frequent interactive rebases. Cleaning up five commits is comfortable; cleaning up forty is not. A rebase -i at the end of each day's work on a branch is an excellent habit.

Tip 4: use --exec when you reorder or split. It is the only cheap way of knowing that no intermediate commit has been left broken.

Tip 5: review the list before saving, always. That open editor is not a formality: it is your last chance to see the whole plan before it runs.

When it is worth tidying up and how much — whether the team insists on a linear history, whether each branch is squashed into one commit when it is integrated, whether rewriting is forbidden — is not a technical decision but a matter of team policy, and it is dealt with in lesson 08-02: Keeping a Clean History.

Exercises

Exercise 1: the full clean-up

Create a repository with a main branch (one commit) and a feature/report branch with these five commits, in this order:

  1. Start the report (creates report.js with a function)
  2. Add report styles (creates report.css)
  3. more stuff (extends report.js)
  4. wip (touches report.js)
  5. fix (touches report.js)

With a single git rebase -i, leave the branch with two commits: one with everything belonging to report.js and a decent message, and another with the styles. Demonstrate with git rev-parse HEAD^{tree} that the final content has not changed.

Exercise 2: splitting a commit

Starting from the result of exercise 1, split the report.js commit in two: one with the data generation and another with the rendering. Use edit, git reset HEAD^ and git add -p.

Exercise 3: --fixup and --exec

On a new branch with three commits:

  1. Deliberately introduce a syntax error in the second commit.
  2. Carry on working and make a third, ordinary commit.
  3. Correct the error with git commit --fixup <sha of the second>.
  4. Apply it with git rebase -i --autosquash.
  5. Launch another rebase with --exec "node --check file.js" and check that all three commits now pass validation.

Solutions

Solution 1:

mkdir /tmp/practice-rebase-i && cd /tmp/practice-rebase-i
git init -b main
echo "# Project" > README.md && git add . && git commit -m "Base"

git switch -c feature/report
echo "function generateReport() {}" > report.js && git add . && git commit -m "Start the report"
echo ".report { margin: 1rem; }" > report.css && git add . && git commit -m "Add report styles"
echo "function reportData() {}" >> report.js && git commit -am "more stuff"
echo "// pending" >> report.js && git commit -am "wip"
echo "function renderReport() {}" >> report.js && git commit -am "fix"

git rev-parse HEAD^{tree}
3e8b1f7c4a9d25b6e8f1c3a7d942b6e5f8c1a3d7    (note it down)
git rebase -i main

The edited list (the report.js commits together at the top, the styles at the end):

pick 4a1c8e3 Start the report
fixup 9d2f7b5 more stuff
fixup 1e6a4c8 wip
fixup 7b3d9f2 fix
pick 5c8e1a4 Add report styles

Then a second pass for the message (or mark reword on the first line from the outset):

git rebase -i main
reword 8f2c5d1 Start the report
pick 3a9e7b4 Add report styles

New message: Add the generation and rendering of the report.

git log --oneline main..HEAD
git rev-parse HEAD^{tree}
c14b8e7 Add report styles
6d3f2a9 Add the generation and rendering of the report

The tree matches the one noted down: only the history has changed.

Solution 2:

git rebase -i main
edit 6d3f2a9 Add the generation and rendering of the report
pick c14b8e7 Add report styles
git reset HEAD^
git status --short
?? report.js

(The file was new in that commit, so it appears as untracked. If it already existed, it would show as M.)

# Stage only the data generation part
git add -p report.js     # accept the generateReport/reportData hunks
git commit -m "Add the generation of the report data"

git add report.js
git commit -m "Add the rendering of the report"

git rebase --continue
git log --oneline main..HEAD
9e7c3b1 Add report styles
2f8a5d6 Add the rendering of the report
b41e9c7 Add the generation of the report data

If git add -p does not offer separable hunks because the file is new, use git add -N report.js first: it registers the file in the index as empty and allows its content to be split into hunks.

Solution 3:

mkdir /tmp/practice-autosquash && cd /tmp/practice-autosquash
git init -b main
echo "const a = 1;" > app.js && git add . && git commit -m "Base"
git switch -c tests

echo "const b = 2;" >> app.js && git commit -am "Add b"
echo "const c = ;" >> app.js && git commit -am "Add c"     # syntax error
echo "const d = 4;" >> app.js && git commit -am "Add d"

git log --oneline main..HEAD
7c1a4e8 Add d
3f9b2d6 Add c
d82e5a1 Add b
# 3. Fix the error, pointing at the guilty commit
sed -i 's/const c = ;/const c = 3;/' app.js
git add app.js
git commit --fixup 3f9b2d6
[tests 4e8c1b9] fixup! Add c
# 4. Apply it
git rebase -i --autosquash main

The list comes ready-made; you only have to save:

pick d82e5a1 Add b
pick 3f9b2d6 Add c
fixup 4e8c1b9 fixup! Add c
pick 7c1a4e8 Add d
git log --oneline main..HEAD
a92f6c3 Add d
5d1e8b7 Add c
d82e5a1 Add b
# 5. Validate every commit
git rebase --exec "node --check app.js" main
Executing: node --check app.js
Executing: node --check app.js
Executing: node --check app.js
Successfully rebased and updated refs/heads/tests.

Had you run it before the --autosquash, the second exec would have failed and the rebase would have stopped at the broken commit.

Conclusion

Interactive rebase is the history repair shop. What we have learned:

  • git rebase -i <base> opens a to-do list with the commits that are going to be reapplied, from oldest to most recent, and carries out whatever you write in it from top to bottom.
  • The four commands you will always use are pick (leave it), reword (change the message), edit (stop to modify or split) and squash/fixup (join with the previous one, keeping or discarding the message). drop removes and exec validates.
  • Reordering lines reorders commits and lets you group by topic what was done out of order; deleting a line removes the commit.
  • Splitting a commit is edit + git reset HEAD^ + several git add -p and git commit + git rebase --continue. It is the most powerful operation of the lot.
  • git commit --fixup <sha> with rebase -i --autosquash turns the clean-up into a formality: you mark the corrections as you go and Git places them on its own. Enable it with rebase.autoSquash true.
  • --exec runs a check after every commit and stops the rebase if it fails: the only cheap way of guaranteeing that no intermediate commit is left broken.
  • If you get lost: git status says where you are, git rebase --edit-todo replans what is left, --show-current-patch shows what is failing and --abort undoes it all without a trace.
  • git rev-parse HEAD^{tree} before and after is the best verification that you have reorganised the history without altering the content.
  • And above all: the golden rule. This is done before publishing.

What comes next

Rebase and interactive rebase always work with the set of commits on a branch. But sometimes what you need is far more surgical: to take one specific commit from one place to another, leaving everything else where it is.

Ana is going to need it tomorrow. She has fixed a bug on main that causes the focus to be lost when a task is deleted, and it turns out that version 1.x, still deployed at the client, has the same bug and lives on a separate maintenance branch. She does not want to merge the whole of main into it: she wants that commit and nothing else.

That is what git cherry-pick is for, and we shall see it in lesson 05-03: Cherry-Picking Commits.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved