Git Internals for Engineers

Rebase Deep Dive

A senior engineer opens a pull request with nine immaculate commits — each one a logical unit, each message descriptive, each small enough to review. The reviewer spots a typo in commit 4 of 9. They ask the author to fix it. The author does, without creating a messy "fix typo in earlier commit" commit at the top. Ten seconds of git commit --fixup=<sha> followed by git rebase -i --autosquash origin/main produces a clean nine-commit branch with the typo fixed in its original commit. The reviewer loves it. The author moves on.

This is the workflow you can reach when you are comfortable with interactive rebase. This lesson is the full tour: the editor commands, reordering, splitting, squashing, editing messages, executing tests at checkpoints, recovering from errors. After this, rebase is a scalpel in your hand rather than a mystery you avoid.


The Interactive Rebase Editor

git rebase -i HEAD~5

Git opens your editor with something like:

pick a1b2c3d feat: add user model
pick 789abc0 feat: add authentication
pick d4e5f6a fix: typo in authentication
pick 22221111 feat: add session management
pick 33334444 refactor: extract auth helpers

# Rebase 8b2f4c1..33334444 onto 8b2f4c1 (5 commands)
#
# Commands:
# p, pick       use commit
# r, reword     use commit, but edit the commit message
# e, edit       use commit, but stop for amending
# s, squash     use commit, but meld into previous commit
# f, fixup      like "squash", but discard this commit's log message
# x, exec       run command (the rest of the line) using shell
# b, break      stop here (continue rebase later with 'git rebase --continue')
# d, drop       remove commit
# l, label      label current HEAD with a name
# t, reset      reset HEAD to a label
# m, merge      create a merge commit
# ...

You edit the plan. Each line becomes an instruction Git will execute from top to bottom. Save and close to start the rebase.

Important: the list is in chronological order (oldest at top, newest at bottom). This is the opposite of git log. The rebase replays commits in this order onto the base.


The Core Verbs

pick — keep the commit as-is

The default. Git replays the commit on top of the current rebase HEAD, with its original message and content.

reword — change the message only

pick a1b2c3d feat: add user model
reword 789abc0 feat: add authentication   ← change this commit's message
pick d4e5f6a fix: typo

Git replays each commit. When it hits the reword line, it opens your editor for the message. Save a new message, and Git uses it for the new commit. Content is unchanged.

squash / s — combine with previous, edit combined message

pick a1b2c3d feat: add user model
pick 789abc0 feat: add authentication
squash d4e5f6a fix: typo

Git applies d4e5f6a's changes but folds them into 789abc0. The editor opens with BOTH commits' messages concatenated — edit into one combined message.

fixup / f — combine with previous, keep previous's message

pick a1b2c3d feat: add user model
pick 789abc0 feat: add authentication
fixup d4e5f6a fix: typo

Like squash, but the previous commit's message is preserved without prompting. d4e5f6a's message is discarded.

edit / e — pause to amend

pick a1b2c3d feat: add user model
edit 789abc0 feat: add authentication
pick d4e5f6a fix: typo

Git replays up to and including 789abc0, then pauses. You can now do anything: edit files, stage/unstage, amend the commit, even split it into multiple commits. When done:

git rebase --continue

drop / d — delete this commit entirely

pick a1b2c3d feat: add user model
drop 789abc0 feat: add authentication    ← gone
pick d4e5f6a fix: typo

The commit is removed from history. Remaining commits continue. If the dropped commit's changes are required by later commits, you will hit conflicts.

exec / x — run a shell command

pick a1b2c3d feat: add user model
exec npm test                              ← run tests after this commit
pick 789abc0 feat: add authentication
exec npm test                              ← and after this one
pick d4e5f6a fix: typo

After each exec line, Git runs the command. If the command exits non-zero, the rebase pauses — exactly like a conflict. You can fix whatever failed and git rebase --continue.

This is how you verify every commit in history builds / tests pass. Essential before shipping a carefully-crafted series to a reviewer.

PRO TIP

exec <cmd> after every commit is one of the most under-used rebase features. Pair it with --exec "make test" on the command line to avoid editing the plan. It transforms "I squashed 15 commits into 5 clean ones" into "I squashed 15 into 5 clean ones AND verified each of the 5 compiles and passes tests." Future you (or git bisect) will thank you.

# Run tests after every commit automatically
git rebase -i --exec "npm test" HEAD~5
# Inserts `exec npm test` between every pair of picks

Common Rebase Recipes

Squash the last N commits into one

git rebase -i HEAD~5
# In editor:
# pick a    ← keep as base
# squash b
# squash c
# squash d
# squash e
# Save. Editor opens for combined message. Edit.

Reorder commits

# Original:
pick A
pick B
pick C

# Change to:
pick C
pick A
pick B

Save. Git tries to reorder. Conflicts likely if the commits touch overlapping code.

Drop an accidental commit

pick a     — keep
drop b     — my experimental dump
pick c     — keep

Split a commit

Use edit:

pick a
edit b    ← the huge commit to split
pick c

When Git pauses at b:

git reset HEAD^    # undo the commit, keep changes in working directory (mixed reset)
# Now stage and commit in multiple pieces:
git add file1.py
git commit -m "part 1: validation"
git add file2.py
git commit -m "part 2: handler"
git rebase --continue

Rebase onto a different base

# Feature branched from develop. You want it based on main instead.
git rebase --onto main develop feature
# Result: feature's commits replayed onto main, skipping anything from develop

Rebase just one commit (rewrite a single commit's content)

git rebase -i HEAD~3
# mark the commit to change as `edit`
# save

# When rebase pauses:
# ... edit files, stage changes ...
git commit --amend
git rebase --continue

The Autosquash Workflow

This is the modern, high-leverage rebase pattern for PR cleanup:

Step 1: work normally

git commit -m "feat: add login route"
git commit -m "feat: add logout route"
git commit -m "test: cover login route"

Step 2: notice an issue in an earlier commit

git log --oneline
# c3d4e5f test: cover login route
# b2c3d4e feat: add logout route
# a1b2c3d feat: add login route    ← this commit has a bug

# Fix the bug in your working dir
vim src/login.py

# Stage the fix and commit with --fixup pointing at the buggy commit
git add src/login.py
git commit --fixup=a1b2c3d
# Commit created with message: "fixup! feat: add login route"

Step 3: autosquash rebase

git rebase -i --autosquash origin/main
# Git pre-arranges:
#   pick a1b2c3d feat: add login route
#   fixup <new> fixup! feat: add login route
#   pick b2c3d4e feat: add logout route
#   pick c3d4e5f test: cover login route
# Save — Git squashes the fixup into a1b2c3d with a1b2c3d's message preserved.

Result

git log --oneline
# new-c3d test: cover login route
# new-b2c feat: add logout route
# new-a1b feat: add login route    ← has the fix, original message preserved

Clean. If you had done this by hand (git rebase -i, find the commit, mark edit, amend, continue), it would have taken five times longer. --fixup + --autosquash is the muscle-memory pattern.

Enable autosquash by default:

git config --global rebase.autosquash true

Now all interactive rebases autosquash by default; plain git rebase -i origin/main just works.


Rebasing With Merge Commits

Default rebase flattens merge commits:

# Before:
A---B---C (main)
     \
      D---M---E  (feature, where M is a merge of another branch)

# After: git rebase main feature
A---B---C---D'---E'  (feature)
# M and its second parent are gone; D and E are replayed linearly

If preserving merges matters (semantic merges, complex branch structures), use:

git rebase --rebase-merges main

This replays merge commits as merge commits, preserving the graph structure. More complex, more prone to conflict, but occasionally necessary.

For most PR workflows, flattening is fine.


Recovering From a Botched Rebase

The hero: git reflog.

# Mid-rebase, you realize you made a wrong choice in the plan
git rebase --abort
# Rebase state discarded; you're back where you started

# OR: you finished the rebase, then realized it's wrong
git reflog
# a1b2c3d HEAD@{0}: rebase -i (finish): refs/heads/feature
# 11111111 HEAD@{1}: rebase -i (pick): ...
# 22222222 HEAD@{2}: rebase -i (pick): ...
# ...
# 55555555 HEAD@{7}: commit: my last good commit before rebase

git reset --hard HEAD@{7}
# Branch is now at the pre-rebase state. Nothing lost.

Backup branch pattern

Before risky rebases:

git branch backup-feature-YYYYMMDD
git rebase -i origin/main

# If the rebase went wrong:
git reset --hard backup-feature-YYYYMMDD

# If the rebase is good, delete the backup:
git branch -D backup-feature-YYYYMMDD

This is cheap insurance. Branches cost 41 bytes.

WARNING

The reflog retains entries for ~30 days by default. After that, unreachable commits may be garbage-collected. For rebases you want durable recovery on for longer (pre-rebase snapshots from months ago), use a backup branch — it keeps the state reachable indefinitely.


Conflicts During Rebase

Conflicts during rebase work exactly like merge conflicts (Lesson 3.2), but they happen per commit:

git rebase -i origin/main
# ... applying a1b2c3d: feat: add login
# CONFLICT in src/auth.py
# Fix conflicts and run "git rebase --continue"

vim src/auth.py        # resolve markers
git add src/auth.py
git rebase --continue   # proceed with next commit

If the same conflict appears again in later commits:

  • rerere (Reuse Recorded Resolution) auto-resolves if enabled (Module 3 Lesson 2).
  • Without rerere, you resolve manually each time.

Options during a stuck rebase:

git rebase --continue    # after resolving the current commit's conflicts
git rebase --skip        # skip this commit entirely (drop it from the rebase)
git rebase --abort       # bail out completely, restore pre-rebase state
git rebase --edit-todo   # reopen the plan editor to change remaining picks

--root — Rebase from the Very Beginning

git rebase -i --root

Rebase all commits, including the root. Useful for:

  • Retroactively setting the same committer email on all commits (history import).
  • Splitting the initial commit of a repo.
  • Reordering even the very first commit.

Rarely needed but exists for completeness.


--exec Without -i

Interactive is optional; you can run execs on a non-interactive rebase:

git rebase --exec "npm test" origin/main
# No editor, just: for every commit in [origin/main..HEAD], replay + run tests
# If tests fail at any commit, rebase pauses

Great for verifying that every commit in a series compiles and passes tests before opening a PR.


git pull --rebase

By default, git pull is git fetch + git merge. This creates merge commits when you have local commits.

git pull --rebase is git fetch + git rebase — your local commits are replayed on top of the freshly-fetched remote. No merge commit; linear history.

git pull --rebase

# Set as default for this repo
git config pull.rebase true

# Or globally
git config --global pull.rebase true

Most teams on linear-history workflows default to this. Teams with more branch-commit-preserving norms leave it off.


Anti-Patterns

Rebasing pushed, shared branches

See Module 2 Lesson 3's Golden Rule. Never rebase commits that others have pulled. If you must, coordinate a fleet-wide re-clone or reset.

"I'll just squash everything into one commit"

Tempting, but robs reviewers of per-commit context. Prefer a thoughtful series of logical commits over a single mega-commit. Use squash-merge on the PR platform if your convention is one-commit-per-PR on main.

Dropping commits you need later

drop a1b2c3d important-fix    ← later commits depend on this

Git will hit conflicts on every subsequent commit. If you realize this mid-rebase: git rebase --abort and redo the plan.

Not pulling remote changes before rebasing

# Don't:
git rebase -i HEAD~10         # rebases onto HEAD~10 of YOUR local
git push                       # ...but origin has moved. This fails.

# Do:
git fetch origin
git rebase -i origin/main     # rebase onto the remote's current tip
git push --force-with-lease

Quality-of-Life Configuration

# Enable rerere (save conflict resolutions)
git config --global rerere.enabled true
git config --global rerere.autoUpdate true

# Autosquash on by default
git config --global rebase.autosquash true

# Pull = rebase by default
git config --global pull.rebase true

# Save backups of commits you rewrite (automatic in Git 2.0+, useful to know)
git config --global rebase.backupRefsDir .git/rebase-backups

# Better conflict markers
git config --global merge.conflictstyle zdiff3

# When a rebase takes ages, you can commit and resume later
git config --global rebase.updateRefs true

Run these once on a new machine; never think about rebase UX again.


A Complete PR Cleanup Example

# Start: messy feature branch with 15 commits
git log --oneline origin/main..HEAD
# 15 commits: wip, try, revert, fix, lint, actual, ...

# 1. Fetch latest main
git fetch origin

# 2. Make a backup
git branch backup-feature

# 3. Interactive rebase against latest main
git rebase -i --autosquash origin/main

# In editor, consolidate into 4 logical commits:
# pick abc123 feat: add user model
# squash def456 fix user model typo
# pick 789abc feat: add authentication
# squash 111222 fix auth bug
# squash 333444 more auth work
# pick 555666 feat: add session tests
# reword 777888 feat: session management   ← rename message
# drop 999aaa  wip debug log               ← remove accidental commit

# Save. Git replays commits, prompts for merged messages, asks for new message on reword.

# 4. Verify tests pass for every commit
git rebase --exec "npm test" origin/main
# If tests fail at any commit, Git pauses. Fix, continue.

# 5. Push with protection
git push --force-with-lease origin feature

# 6. Delete backup once merged
git branch -D backup-feature

Clean. Reviewer gets four focused commits each with a meaningful message. CI is green. History is a story your future self can read.


Key Concepts Summary

  • git rebase -i <base> opens an editor with a list of commits to replay.
  • Verbs: pick, reword, edit, squash, fixup, drop, exec.
  • fixup! + autosquash is the clean workflow for mid-PR cleanup.
  • edit lets you amend or split commits mid-rebase.
  • exec <cmd> runs a shell command between commits — verify each builds/tests.
  • --rebase-merges preserves merge structure (else merges are flattened).
  • Conflicts happen per-commit. rerere auto-resolves repeats.
  • Recovery: git rebase --abort mid-flight; git reset --hard HEAD@{N} from reflog after completion.
  • Backup branch before risky rebases. Cheap and safe.
  • Use --force-with-lease, never bare --force, on pushes after rebase.
  • git pull --rebase keeps local history linear.

Common Mistakes

  • Rebasing a shared branch and force-pushing without warning. Colleagues' pulls break.
  • Editing the plan backwards thinking it is in log order (newest first). The plan is chronological — oldest at top.
  • Forgetting --autosquash and manually ordering fixup commits by hand. Autosquash does it for you.
  • Not enabling rerere. You will resolve the same conflict many times during an iterative rebase.
  • Dropping a commit that later commits depend on. Conflicts everywhere. Read the plan carefully before saving.
  • Using pick when you meant edit (or vice versa). Muscle memory grows; initially, compare to the help text in the editor comments.
  • Running git rebase --continue with a file still containing <<<<<<< HEAD markers. Git usually refuses, but if you somehow get past it, that commit is now broken.
  • Rebasing over a merge commit without --rebase-merges when the merge was semantic. The merge is flattened away; sometimes that is right, sometimes it is not.
  • Not running tests after rebase. A carefully-crafted series of commits means nothing if any individual commit does not compile.
  • Using git push --force when --force-with-lease is the safer default.

KNOWLEDGE CHECK

You are rebasing 10 commits interactively. At commit 6 of 10, the rebase stops with a conflict. You resolve it and run `git rebase --continue`. Commit 7 applies. Commit 8 produces the SAME conflict as commit 6 (two commits touched the same line in different ways). Without any special setup, what happens, and what would have prevented it?