Git Rebase: The Art of Rewriting History and Maintaining a Linear Graph
TL;DR: The git rebase command is Git’s primary mechanism for linearizing divergent commit histories. Instead of combining branches via a merge commit, rebase temporarily stores the unique commits of your current branch, resets the branch pointer to the target branch’s tip, and replays each commit as a brand new object. This produces a clean, chronological timeline of changes.
What Does git rebase Actually Do?
To understand git rebase, we must look at commit objects. In Git, commits are immutable. A commit object is defined by its contents, author, date, and—most importantly—the SHA-1 hash of its parent commit(s). Because of this immutability, you can never edit or move a commit once it is created.
If you have a feature branch that has diverged from main, you have two structural choices to bring in updates:
- Merge (Additive): Run /git-merge. Git finds the Lowest Common Ancestor (LCA) and creates a new “merge commit” with two parents, binding the histories together. The history remains authentic to when changes were written, but the graph can quickly become a complex web.
- Rebase (Destructive / Synthetic): Run
git rebase main. Git calculates which commits are unique to your feature branch, temporarily copies their changes (diffs), resets your branch pointer to the latest commit onmain, and then applies those changes sequentially as brand new commits (with new parent pointers, new timestamps, and new hashes).
Diverged History:
---> [Commit B] (main)
/
[Commit A]
\
---> [Commit C] (feature, HEAD)
After Rebasing feature onto main:
[Commit A] ---> [Commit B] (main) ---> [Commit C*] (feature, HEAD)
In the diagram above, Commit C was abandoned. It was cloned into Commit C*, which has Commit B as its parent. Because the parent hash changed, the commit’s hash changed from C to C*. To the outside observer, it looks like you started your work on the feature branch after the latest commit on main was already written.
This results in a clean, linear history, making it easy to read logs via /git-log without winding branch paths.
What Happens Inside .git/ (Hands-on exploration)
Rebasing is a highly coordinated operation. Let’s perform a hands-on walk-through to see what happens inside .git/ during a successful rebase and a conflicted rebase.
Step 1: Setting up the Divergent Repository
Let’s initialize a repository and make two divergent branches.
$ mkdir git-rebase-demo
$ cd git-rebase-demo
$ git init
Initialized empty Git repository in /users/admin/git-rebase-demo/.git/
$ echo "Base text" > base.txt
$ git add base.txt
$ git commit -m "Commit 1: Base"
[main (root-commit) c1c1c1c] Commit 1: Base
Now let’s create a branch named feature (refer to /git-branch for creation) and commit on it:
$ git checkout -b feature
Switched to a new branch 'feature'
$ echo "Feature modification" > feature.txt
$ git add feature.txt
$ git commit -m "Commit 2: Feature edit"
[feature f1f1f1f] Commit 2: Feature edit
Our feature commit is f1f1f1f. Now let’s go back to main and make a different commit:
$ git checkout main
Switched to branch 'main'
$ echo "Main modification" > main.txt
$ git add main.txt
$ git commit -m "Commit 3: Main edit"
[main m1m1m1m] Commit 3: Main edit
Our main commit is m1m1m1m. Let’s switch back to the feature branch before starting the rebase:
$ git checkout feature
Switched to branch 'feature'
Step 2: The Rebase Under the Hood
Now, let’s run git rebase main:
$ git rebase main
Successfully rebased and updated refs/heads/feature to refs/heads/main.
Let’s look at what happened internally. During a rebase, Git creates a temporary workspace folder at .git/rebase-merge/ (or .git/rebase-apply/). This folder is created to track the state of the rebase. It contains:
orig-head: Contains the hash of the original branch tip before rebase began (f1f1f1f...). This acts as an undo point if the rebase is aborted.onto: Contains the hash of the commit we are rebasing onto (m1m1m1m...).head-name: Contains the name of the branch being rebased (refs/heads/feature).git-rebase-todo: A text file containing the list of commits that Git needs to replay. For our simple example, it looked like this:pick f1f1f1f Commit 2: Feature edit
The execution steps:
- Git reads the file
.git/refs/heads/featureto find the original HEAD commit. - It saves the diff of
f1f1f1fin the.git/rebase-merge/directory. - It moves
.git/HEADto point directly tom1m1m1m(entering a temporary detached HEAD state). - It applies the saved diff of
f1f1f1fon top ofm1m1m1m. - It writes a brand new commit object (which gets a new hash, say
f2f2f2f...) containing the combined changes, and links its parent tom1m1m1m. - It updates
.git/refs/heads/featureto contain the new hashf2f2f2f. - It points
.git/HEADback torefs/heads/feature. - It deletes the
.git/rebase-merge/directory.
If we view the commit object log, we will find that our feature commit hash has changed, and its parent is now the main commit m1m1m1m.
Step 3: Handling a Rebase Conflict
Now let’s see what happens when a conflict occurs. Let’s create a conflicting change.
On main:
$ git checkout main
$ echo "Main conflict change" > conflict.txt
$ git add conflict.txt
$ git commit -m "Commit 4: Main conflict"
[main m2m2m2m] Commit 4: Main conflict
On feature:
$ git checkout feature
$ echo "Feature conflict change" > conflict.txt
$ git add conflict.txt
$ git commit -m "Commit 5: Feature conflict"
[feature f3f3f3f] Commit 5: Feature conflict
Now let’s attempt to rebase:
$ git rebase main
Auto-merging conflict.txt
CONFLICT (content): Merge conflict in conflict.txt
error: could not apply f3f3f3f... Commit 5: Feature conflict
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
The rebase halts midway. Let’s inspect the .git/ folder. The .git/rebase-merge/ folder is not deleted because the rebase is still in progress.
If we look at .git/REBASE_HEAD, we’ll find:
$ cat .git/REBASE_HEAD
f3f3f3f8928424ef245eefb925b42d3851b2c129
This is the commit hash of the step that failed. Just like during a merge conflict, the index (.git/index) contains slots 1, 2, and 3 for conflict.txt.
Resolving the Conflict:
We open conflict.txt, resolve the diff manually, and stage it:
$ echo "Resolved conflict text" > conflict.txt
$ git add conflict.txt
Unlike merging, we do not run git commit to finalize. Doing so would write a normal merge commit. Instead, we run:
$ git rebase --continue
Applying: Commit 5: Feature conflict
Git reads .git/rebase-merge/git-rebase-todo to see if there are more commits to replay. Since there are none, Git completes the rebase, updates the feature ref to the final new commit, and deletes the temporary .git/rebase-merge/ folder.
Detailed Command Options & Advanced Usage
Rebase is a highly configurable tool. It can be used to clean up history or restructure entire commit branches.
1. Interactive Rebase (git rebase -i)
Interactive rebase allows you to rewrite your commit history before publishing it. You can reorder, rename, combine, or delete local commits.
To start an interactive rebase of your last three commits:
$ git rebase -i HEAD~3
This opens a text editor showing a list of commits in chronological order (oldest first, which is the opposite of git log):
pick 1a2b3c4 Add Stripe payment library
pick 4d5e6f7 WIP payment form UI
pick 7g8h9i0 Fix styling bug in form
You can change the commands in front of each commit to modify how Git replays them:
pick(orp): Keep the commit as is.reword(orr): Keep the commit, but pause the rebase to let you change the commit message.edit(ore): Pause the rebase to let you modify files, add new files, or split the commit.squash(ors): Combine the commit with the one directly above it. Git will prompt you to combine the commit messages.fixup(orf): Same as squash, but it automatically discards this commit’s message, keeping only the message of the commit above it.drop(ord): Delete the commit entirely from history.
For example, if you change pick to fixup for the “WIP payment form UI” and “Fix styling bug” commits, they will be silently merged into “Add Stripe payment library”, leaving you with a single, clean commit in your history.
2. Rebasing Onto a Different Branch (git rebase --onto)
The --onto flag allows you to take a branch that was based on one branch, and base it on a completely different branch instead.
Syntax:
$ git rebase --onto <new-parent-branch> <old-parent-branch> <target-branch>
Real-World Example:
Imagine you branched feature-ui off of feature-auth.
---> [Commit B] (feature-auth)
/
[Commit A]
\
---> [Commit C] ---> [Commit D] (feature-ui)
Now, feature-auth is still being worked on, but you want to test feature-ui against main directly. You want to extract Commit C and Commit D and apply them to main (which is currently at Commit M), bypassing feature-auth.
You run:
$ git rebase --onto main feature-auth feature-ui
This tells Git: “Find the commits that are in feature-ui but not in feature-auth (which is C and D), and replay them on top of main.”
---> [Commit C*] ---> [Commit D*] (feature-ui)
/
[Commit M] (main)
This is an incredibly powerful command for managing multi-layered branch structures.
The Golden Rule of Rebase
[!CAUTION] Never rebase commits that have been pushed to a public repository.
Rebasing changes commit hashes. If you rebase commits that other developers have already pulled, you rewrite the history that their work is based on.
The Disaster Scenario:
- You push commit
Ato a public repository. - Your teammate, Bob, pulls
Aand creates a new branch on top of it. - You decide to clean up your history, so you rebase local commits including
A, which changes its hash toA*. - You run
git push --forceto pushA*to the server. - Bob runs
git pull. Git notices thatAis no longer on the remote branch, but Bob has it locally. It tries to merge Bob’s local branch (which tracksA) with the remote branch (which hasA*). - This results in duplicate commits (
AandA*both appear in history, even though they contain the exact same changes) and massive, confusing merge conflicts.
Safe Pushing with --force-with-lease
If you rebase a private branch (e.g., a feature branch only you are working on) and need to update it on the remote server, you must force push because the histories have diverged. Instead of using the raw git push --force, always use:
$ git push --force-with-lease
This flag acts as a safety guard. It checks if the remote branch has received any new commits from your teammates since your last fetch. If it has, it aborts the push, preventing you from accidentally overwriting someone else’s work.
Common Pitfalls and Mistakes
1. Running git commit During a Rebase Conflict
When you resolve conflicts during a rebase, your instinct is to type git commit. However, doing so will create a standard commit that is detached from the rebase flow.
- Fix: Always run
git add <file>to stage, and then rungit rebase --continue. If you accidentally made a commit, you can undo it withgit reset --soft HEAD~1and then continue.
2. Forgetting to Clean the Working Directory
If you have uncommitted changes in your working tree when you run rebase, Git will abort and tell you to commit or stash them first. Rebase needs to checkout commits and manipulate files, which it cannot do safely if you have modified files in your working directory.
- Fix: Run
git stashbefore rebasing, andgit stash popafter it completes. See /git-stash for details.
3. Rebase Loops and Cumulative Conflicts
If you rebase a long-lived feature branch with many commits, you might have to resolve the same conflict over and over again for every single commit being replayed.
- Fix: Use
git rebase --abortto cancel. Consider squashing your commits into a single commit first, then rebase. This ensures you only resolve conflicts once. Alternatively, enable Git’srererefeature (Reuse Recorded Resolution) to automate conflict resolutions:$ git config --global rerere.enabled true
Command Quick Reference
| Command | Action | Git Internals Action |
|---|---|---|
git rebase <branch> | Rebases current branch onto target | Saves diffs, resets HEAD to target, replays diffs |
git rebase -i HEAD~N | Interactive rebase of last N commits | Opens todo list editor to rewrite local history |
git rebase --continue | Resumes rebase after resolving conflict | Applies the next commit in .git/rebase-merge/git-rebase-todo |
git rebase --abort | Cancels rebase in progress | Restores branch ref to hash in .git/rebase-merge/orig-head |
git rebase --skip | Skips the current commit | Deletes current commit line from todo list and continues |
git rebase --onto <new> <old> <target> | Re-bases a slice of history | Replays commits that are child of <old> on top of <new> |
FAQ (People Also Ask)
What is the difference between git merge and git rebase?
git merge is a non-destructive operation that combines two branches by creating a new merge commit. It preserves the original commit history exactly as it occurred. git rebase rewrites history by applying your branch’s commits on top of the target branch, producing a linear history without merge commits.
How do I undo a rebase that went wrong?
If you completed a rebase and want to restore your branch to its exact pre-rebase state, you can use the reflog:
- Run
git reflogand find the commit hash of your branch tip before the rebase started (it will often be labeled ascheckout: moving from...or the state before the first rebase step). - Reset your branch to that commit:
$ git reset --hard <pre-rebase-hash>
Does git rebase delete my commits?
No, it does not delete them immediately. The old commits still exist in the .git/objects/ database. However, since no branch or tag references them, they are considered “dangling” commits. They will remain in the database for a few weeks (saved by the reflog) before Git’s garbage collection deletes them permanently.
Test Your Knowledge
Loading quiz…