Chapter 12: Rebasing with Git
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the concept of rebasing and how it differs from merging.
- Perform a basic rebase of a feature branch onto a base branch (
git rebase <base-branch>
). - Analyze the pros and cons of rebasing versus merging, particularly regarding history linearity.
- Adhere to the “golden rule of rebasing”: never rebase commits that have been shared with others.
- Use interactive rebasing (
git rebase -i
) to modify, reorder, squash, edit, and drop commits. - Resolve conflicts that arise during a rebase operation.
- Safely abort a rebase if things go wrong.
Introduction
In previous chapters, we explored how to manage branches and integrate changes, primarily using git merge
. Merging is a fundamental way to combine work from different branches, but it always results in a merge commit, which can sometimes lead to a complex, non-linear history graph, especially in projects with many contributors and branches.
Git offers another powerful strategy for integrating changes: git rebase
. Rebasing allows you to take all the commits from one branch and replay them, one by one, on top of another branch. This can result in a much cleaner, linear project history. However, rebasing rewrites history, which comes with its own set of considerations and potential pitfalls, especially in collaborative environments.
This chapter delves into the mechanics of git rebase
, contrasting it with git merge
. We’ll explore its common use cases, including keeping feature branches up-to-date and cleaning up local commit history using interactive rebase before sharing your work. Crucially, we’ll emphasize the “golden rule of rebasing” to prevent issues when working with shared repositories.
Theory
What is Rebasing?
At its core, rebasing is the process of moving or combining a sequence of commits to a new base commit. When you rebase your current branch onto another branch (let’s call it the “base” branch or “upstream” branch), Git takes the commits that are unique to your current branch, temporarily saves them as patches, resets your current branch to match the base branch, and then reapplies each of your unique commits one by one on top of the base branch.
Imagine you started a feature branch (feature-x
) from main
. While you were working on feature-x
, main
received new commits.
Initial state:
A---B---C feature-x
/
D---E---F---G main
Here, A
, B
, and C
are commits on feature-x
made after branching from E
on main
. F
and G
are new commits on main
.
If you run git rebase main
while on feature-x
, Git will:
- Find the common ancestor of
feature-x
andmain
(which isE
). - Get the diffs (patches) introduced by commits
A
,B
, andC
onfeature-x
. - Reset the
feature-x
branch to point to the latest commit onmain
(G
). - Apply the patches for
A
,B
, andC
one by one on top ofG
, creating new commitsA'
,B'
, andC'
. These new commits will have different SHA-1 hashes than the originals because their parent commit (and potentially content, if conflicts arise) has changed.
After rebasing feature-x
onto main
:
A'--B'--C' feature-x
/
D---E---F---G main
The feature-x
branch now appears as if it was branched from the latest state of main
(G
) and all its work (A'
, B'
, C'
) was done on top of that. The history is linear. The original commits A
, B
, C
are no longer referenced by feature-x
(they become “dangling” and will eventually be garbage collected if not referenced elsewhere).
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%% graph LR subgraph "Before Rebase: <i>feature-x</i> diverged from <i>main</i>" direction LR D0["D"] --> E0["E"] E0 --> F0["F (main)"] --> G0["G (main)"] E0 --> A0["A (feature-x)"] --> B0["B (feature-x)"] --> C0["C (feature-x)"] class D0,E0,F0,G0,A0,B0,C0 commit; class G0 branch-main; class C0 branch-feature; end subgraph "After <i>git rebase main</i> (on <i>feature-x</i>)" direction LR D1["D"] --> E1["E"] --> F1["F (main)"] --> G1["G (main)"] G1 --> A1["A' (feature-x)"] --> B1["B' (feature-x)"] --> C1["C' (feature-x)"] class D1,E1,F1,G1,A1,B1,C1 commit; class G1 branch-main; class C1 branch-feature; subgraph "Note" N1["Original commits A, B, C are<br>abandoned (dangling).<br>A', B', C' are new commits<br>with same changes but new SHA-1s."] class N1 note; end end %% Styling classDef commit fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef branch-main fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46,font-weight:bold; classDef branch-feature fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6,font-weight:bold; classDef note fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E,font-style:italic; style E0 fill:#FEF3C7,stroke:#D97706; style E1 fill:#FEF3C7,stroke:#D97706;
Rebase vs. Merge
Both git rebase
and git merge
are designed to integrate changes from one branch into another, but they do so in fundamentally different ways, leading to different project histories.
Feature / Aspect | git rebase |
git merge |
---|---|---|
Primary Goal | Integrate changes by replaying commits on top of another branch. | Integrate changes by combining two branch histories with a new commit. |
History Outcome | Creates a linear history. Appears as if work was done sequentially. | Preserves the exact history as it happened. Creates a branched (graph-like) history. |
Commit Creation | Rewrites existing commits (creates new commits with different SHA-1s for each replayed commit). Does not typically create an extra “merge commit” when integrating back. | Non-destructive to existing commits. Creates a new merge commit to tie histories together (unless it’s a fast-forward merge). |
Commit Integrity | Original commits on the rebased branch are abandoned (become dangling). | Original commits on both branches remain intact and part of the history. |
Collaboration Impact | Dangerous on shared/public branches (violates Golden Rule). Safe for local cleanup before sharing. | Generally safe for shared branches as it doesn’t rewrite published history. |
Conflict Resolution | Conflicts are resolved per commit as they are replayed. Can be repetitive if conflicts span multiple commits. | All conflicts are resolved at once in the final merge commit. |
Readability | Can make history easier to read due to linearity, especially for feature progression. | Merge commits explicitly show integration points, but can clutter history if overused for minor updates. |
Typical Use Cases |
|
|
Analogy | Taking your stack of work, moving to a new starting point, and re-doing each piece of work on top of it. | Bringing two separate streams of work together and tying them with a knot that signifies their joining. |
git merge
:
- Preserves history as it happened: It takes two (or more) branches and ties them together with a new “merge commit.” The original commit histories of both branches remain intact.
- Non-destructive: It doesn’t change existing commits.
- Creates a merge commit: This commit has multiple parents and explicitly shows where the histories diverged and were brought back together.
- Can lead to a complex, graph-like history: Especially in busy repositories, the history can become cluttered with many merge commits, making it harder to follow the linear progression of a specific feature.
Example of merging feature-x
into main
:
A---B---C feature-x
/ \
D---E---F---G---H main (H is the merge commit)
git rebase
:
- Rewrites history: It replays commits from one branch onto another, creating new commits (with different SHA-1s) for each original commit.
- Can be destructive to the original commits: The original commits on the rebased branch are abandoned (though they might still be recoverable via
git reflog
for a while). - Does not create a merge commit (typically): When integrating a rebased feature branch back into its target (e.g.,
main
), it can often be done with a “fast-forward” merge if no new commits have occurred onmain
since the rebase point, thus avoiding a merge commit. - Results in a linear history: Makes the project history look as if development happened sequentially, which can be easier to read and understand.
Pros of Rebasing:
- Cleaner, linear history: Easier to follow the progression of changes.
- Avoids unnecessary merge commits: Keeps the history tidy, especially for short-lived feature branches.
- Can make
git bisect
more straightforward: A linear history simplifies identifying when a bug was introduced.
Cons of Rebasing:
- Rewrites history: This is the biggest caveat. If you rebase commits that others have already pulled and based their work on, it can cause significant problems for collaboration (see “The Golden Rule of Rebasing”).
- Can lose context of when branches diverged: The rebased history doesn’t show when the feature work was actually done in parallel with the base branch.
- Conflict resolution can be more complex: You might have to resolve similar conflicts multiple times, once for each commit being replayed (though Git can sometimes be smart about this). With a merge, you resolve all conflicts at once in the merge commit.
- Slightly more complex to understand and use correctly than merging.
Pros of Rebasing | Cons of Rebasing |
---|---|
Cleaner, Linear History: Makes the project history appear as if development happened sequentially, which can be easier to read, understand, and navigate. | Rewrites History: This is the primary drawback. Original commits are replaced with new ones (different SHA-1s). Extremely problematic if done on shared/public branches (violates the Golden Rule). |
Avoids Unnecessary Merge Commits: Keeps the history tidy by eliminating extra merge commits, especially when integrating short-lived feature branches or updating local branches. | Can Lose Context: The rebased history doesn’t explicitly show when feature work was actually done in parallel with the base branch, or the exact point of divergence. Merge commits preserve this. |
Simplified `git bisect`: A linear history can make it easier and more straightforward to use tools like `git bisect` to identify when a bug was introduced. | More Complex Conflict Resolution: You might have to resolve similar conflicts multiple times if they appear in several of the commits being replayed. Merge resolves all conflicts in one go. |
Local Commit Management: Interactive rebase (`git rebase -i`) offers powerful tools to squash, reword, edit, or reorder local commits before sharing, leading to a more polished set of changes. | Slightly Steeper Learning Curve: Understanding the implications of rebasing, especially interactive rebase and the golden rule, requires more care than basic merging. |
Easier to “Fast-Forward” Main Branch: When a feature branch is rebased onto `main`, `main` can often be fast-forwarded to the tip of the feature branch, avoiding a merge commit on `main`. | Risk of Dangling/Lost Commits: If not handled carefully, especially with forced pushes after rebasing a shared branch, original commits can become harder for others to find or reconcile. |
When to Use Which?
- Rebase for local cleanup: Before you push your feature branch for the first time, or to update your local feature branch with the latest changes from the main development line (
main
ordevelop
). This keeps your feature branch commits grouped together and on top of the latest base. - Merge for integrating completed features into a shared/main branch: When merging a feature branch that has been reviewed and is ready for integration into
main
, a merge commit explicitly records this integration point. This is especially true if the feature branch was long-lived or involved multiple developers. - Some teams adopt a “rebase and fast-forward merge” policy for feature branches to maintain a linear history on
main
. This involves rebasing the feature branch ontomain
and then, if possible, fast-forwardingmain
to the tip of the feature branch.
The Golden Rule of Rebasing
Warning: Never rebase commits that have already been pushed to a public or shared repository and that other collaborators might have based their work on.
Rebasing replaces old commits with new ones. If you rebase a branch that others are using:
- Your local branch history will diverge from the remote branch history.
- If you try to
git push
, Git will reject it because it’s not a fast-forward update. - If you
git push --force
(which you should generally avoid), you overwrite the history on the remote. - Other collaborators who pull these rebased changes will have a messy time trying to reconcile their local work with the rewritten history. Their local branches will contain the “old” commits, while the remote has the “new” rebased commits. This leads to duplicated commits and complex merge scenarios.
The rule of thumb:
- It’s generally safe to rebase your own local commits that you haven’t shared yet.
- Once you
git push
a branch and others might be using it, consider that history “published” and avoid rebasing it. Usegit merge
to incorporate new changes from the base branch, or communicate very clearly with your team if a rebase of a shared branch is deemed necessary (an advanced and risky maneuver).
Interactive Rebasing (git rebase -i
or git rebase --interactive
)
Interactive rebase is one of Git’s most powerful (and potentially dangerous if misused) features. It allows you to not just replay commits, but to actively modify them in the process. You can reorder, reword, edit, combine (squash/fixup), or even delete commits.
To start an interactive rebase, you use git rebase -i <base>
. <base>
can be a branch name, a commit hash, or a reference like HEAD~N
(to rebase the last N
commits).
Command | Abbreviation | Description |
---|---|---|
pick |
p |
Use the commit as is, without any changes. This is the default action for each commit in the list. |
reword |
r |
Use the commit’s changes, but pause the rebase to allow you to edit (reword) the commit message. |
edit |
e |
Use the commit’s changes, but pause the rebase after applying this commit. This allows you to amend the commit (e.g., add/remove files, change content, split the commit). After making changes and using git commit --amend , run git rebase --continue . |
squash |
s |
Meld this commit’s changes into the changes of the commit immediately preceding it (the one on the line above it in the ‘todo’ list). The rebase will pause to allow you to combine and edit the commit messages from both (or more, if multiple squashes) commits. |
fixup |
f |
Similar to squash , but it melds this commit’s changes into the previous commit and discards this commit’s log message entirely. The previous commit’s message is used. Useful for incorporating small fixup commits silently. |
drop |
d |
Remove the commit completely. Its changes will be lost from the branch history. |
exec |
x |
Run a shell command (specified on the rest of the line). If the command exits with a non-zero status (fails), the rebase will pause at that point. Useful for running tests on each commit. |
break |
b |
Stop the rebase at this point before this commit is applied. You can continue later with git rebase --continue . Useful for inspection or manual operations during a rebase. |
(Reordering Lines) | N/A | You can change the order of the commit lines in the ‘todo’ list. Git will attempt to apply the commits in the new specified order. |
label <name> |
l |
Label the current HEAD with <name> . Does not create a commit. |
reset <label> |
t |
Reset HEAD to a previously defined <label> . Subsequent pick commands will be applied on top of this reset point. |
merge ... |
m |
Create a merge commit. This is more advanced and typically used when rebasing merge commits themselves. Requires specifying a label or commit for the merge. |
When you run git rebase -i
, Git opens your configured text editor with a list of the commits that are about to be rebased. Each line represents a commit, starting with the command (defaulting to pick
), followed by the commit hash and the commit message.
Example of the interactive rebase “todo” list:
pick a1b2c3d Commit message for A
pick e4f5g6h Commit message for B
pick i7j8k9l Commit message for C
# Rebase ... onto ...
#
# 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 <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# 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>]
# . create a merge commit using the original merge commit's
# . message (or the oneline, if no original merge commit was
# . specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
# ...
You can change the command for each commit and/or reorder the lines.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%% graph TD A["Start: Feature branch with commits<br>e.g., F1, F2, F3"] --> B["Run: git rebase -i <base><br>(e.g., HEAD~3, main)"]; B --> C{"Editor opens with 'todo' list:"}; C --> D["pick sha1_F1 Message F1<br>pick sha1_F2 Message F2<br>pick sha1_F3 Message F3<br>... (rebase commands help text) ..."]; D --> E{"Modify 'todo' list:<br>- Change commands (pick, reword, edit, squash, fixup, drop)<br>- Reorder lines"}; E --> F["Example Modification:<br><br><b>pick</b> sha1_F1 Message F1<br><b>squash</b> sha1_F2 Small fix for F1<br><b>reword</b> sha1_F3 Needs better msg"]; F --> G{"Save and Close Editor"}; G --> H{"Git processes 'todo' list top to bottom"}; H --> I{"Command requires interaction?<br>(e.g., reword, squash, edit)"}; I -- Yes --> J["Git pauses, opens editor for message (squash/reword),<br>or drops to shell (edit)"]; J --> K{"User provides input / amends commit"}; K --> L{"User runs git rebase --continue"}; L --> H; I -- No (e.g. pick, drop, fixup without message conflict) --> M["Commit processed automatically"]; M --> H; H -- All commands processed successfully --> N["Rebase successful!"]; N --> O["Result: Cleaner, modified history<br>e.g., F1' (F1+F2 squashed), F3' (reworded)"]; O --> Z["End: Branch history is rewritten"]; H -- Conflict Occurs --> P["Rebase pauses: 'CONFLICT ...'"]; P --> Q["User resolves conflicts in files"]; Q --> R["git add <resolved_files>"]; R --> S["git rebase --continue"]; S --> H; P -- "Or user decides to abort" --> T["git rebase --abort"]; T --> U["Branch reset to pre-rebase state"]; U --> Z; P -- "Or user decides to skip commit" --> V["git rebase --skip"]; V --> H; %% Styling classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef endNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; classDef checkNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; classDef editorNode fill:#FFFBEB,stroke:#F59E0B,stroke-width:1px,color:#B45309; classDef resultNode fill:#E0E7FF,stroke:#4338CA,stroke-width:1px,color:#3730A3; class A startNode; class B,E,G,K,L,Q,R,S,V processNode; class C,I,H decisionNode; class D,F,J editorNode; class N,O,U resultNode; class Z endNode; class P,T checkNode;
Common Interactive Rebase Commands:
pick
(orp
): Use the commit as is. This is the default.reword
(orr
): Use the commit, but Git will pause and let you change its commit message.edit
(ore
): Use the commit, but Git will pause after applying it, allowing you to amend the commit (e.g., add/remove changes, split the commit). After making changes andgit commit --amend
, usegit rebase --continue
to proceed.squash
(ors
): Melds this commit’s changes into the previous commit’s changes. Git will pause and let you combine and edit the commit messages of the squashed commits.fixup
(orf
): Likesquash
, but it discards the current commit’s message entirely, using only the message from the previous commit. Useful for small fixup commits where the message isn’t important.drop
(ord
): Removes the commit entirely. Its changes will be lost.exec
(orx
): Runs a shell command. For example,exec make test
. If the command fails (exits non-zero), the rebase will pause.- Reordering Commits: Simply change the order of the lines in the “todo” list. Git will apply them in the new order.
After saving and closing the editor, Git will attempt to perform the rebase according to your instructions.
Resolving Conflicts During Rebase
Conflicts can occur during a rebase if a change in one of your replayed commits touches the same lines of code that were modified on the base branch since you diverged. When a conflict occurs, the rebase process pauses at the problematic commit.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%% graph TD A["Start: <i>git rebase <base></i> or <i>git rebase -i ...</i> initiated"] --> B{"Git attempts to apply a commit"}; B -- Successfully Applied --> C["Commit applied cleanly"]; C --> D{"More commits to rebase?"}; D -- Yes --> B; D -- No --> E["Rebase Successful!"]; E --> Z["End: Branch updated"]; B -- Conflict Occurs --> F["Rebase Pauses! Git reports: <i>CONFLICT (content): Merge conflict in <file>...</i>"]; F --> G["Problematic commit is NOT YET APPLIED to the branch."]; G --> H["Open conflicted file(s): Look for <i><<<<<<</i>, <i>=======</i>, <i>>>>>>></i> markers."]; H --> I["Edit file(s) to resolve conflicts: Choose desired changes, remove markers."]; I --> J["Stage resolved file(s): <i>git add <resolved_file1> ...</i>"]; J --> K{"Choose next action:"}; K -- "Continue Rebase" --> L["<i>git rebase --continue</i>"]; L --> M{"Conflict resolved & commit applied?"}; M -- Yes --> D; M -- No (e.g. forgot to stage, or new conflict in same commit somehow) --> F; K -- "Skip This Commit" --> N["<i>git rebase --skip</i>"]; N --> O["Problematic commit is discarded. Caution: Changes from this commit are lost!"]; O --> D; K -- "Abort Rebase" --> P["<i>git rebase --abort</i>"]; P --> Q["Rebase is cancelled. Branch is returned to its state before the rebase started."]; Q --> Z; %% Styling classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef endNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; classDef checkNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; classDef infoNode fill:#E0E7FF,stroke:#4338CA,stroke-width:1px,color:#3730A3; class A startNode; class H,I,J,L,N,P processNode; class B,D,K,M decisionNode; class C,E,Q,Z endNode; class F,O checkNode; class G infoNode;
Git will tell you which file(s) have conflicts. You need to:
- Open the conflicted file(s). They will contain the standard conflict markers (
<<<<<<<
,=======
,>>>>>>>
). - Edit the file(s) to resolve the conflicts, keeping the desired changes and removing the markers.
- Use
git add <resolved_file>
to stage the resolved file(s). - Run
git rebase --continue
to proceed with the rebase. Git will then try to apply the next commit in the sequence.
Tip: During a rebase conflict,
git status
provides helpful information about the state of the rebase and which files need attention.
Aborting a Rebase (git rebase --abort
)
If you get into a complicated conflict situation during a rebase, or if you decide you don’t want to proceed with the rebase for any reason, you can abort it:
git rebase --abort
This command will stop the rebase process and return your branch to the state it was in before you started the rebase. No changes will have been made to your branch.
Skipping a Commit During Rebase (git rebase --skip
)
If you encounter a conflict on a particular commit during a rebase and decide that the changes from that specific commit are no longer needed or are causing too much trouble, you can use git rebase --skip
. This will discard the problematic commit entirely and attempt to apply the next commit in the sequence.
Warning: Use
git rebase --skip
with caution. It means you are throwing away the changes introduced by that commit. Make sure this is what you intend. Often, resolving the conflict or aborting the rebase is a safer option.
Practical Examples
Setup:
Let’s create a scenario with a main branch and a feature branch.
# Create and navigate to a new directory
mkdir git-rebase-practice
cd git-rebase-practice
# Initialize a Git repository
git init
# Configure user (if not set globally)
# git config user.name "Your Name"
# git config user.email "youremail@example.com"
# Create initial commit on main
echo "Initial content for project." > project.txt
git add project.txt
git commit -m "C1: Initial project setup"
echo "Main v2" >> project.txt
git add project.txt
git commit -m "C2: Update project on main"
# Create and switch to a feature branch
git checkout -b feature/login
# Add commits to the feature branch
echo "Login feature: Basic structure" > login.feature
git add login.feature
git commit -m "F1: Add basic login structure"
echo "Login feature: Add username field" >> login.feature
git add login.feature
git commit -m "F2: Add username field to login"
# Meanwhile, main branch gets updated
git checkout main
echo "Main v3 - Important update" >> project.txt
git add project.txt
git commit -m "C3: Important update on main"
echo "Main v4 - Another update" >> project.txt
git add project.txt
git commit -m "C4: Another update on main"
# Switch back to the feature branch to perform rebase
git checkout feature/login
# Current state:
# feature/login is based on C2.
# main has advanced to C4.
#
# F1---F2 feature/login
# /
# C1---C2---C3---C4 main
You can visualize this with git log --oneline --graph --all
.
1. Basic Rebase
We want to update feature/login
with the latest changes from main
(C3, C4) and make it appear as if feature/login
was started from C4.
# On feature/login branch
git rebase main
Expected Output (if no conflicts):
Successfully rebased and updated refs/heads/feature/login.
Or, if Git is more verbose (older versions or specific configurations):
First, rewinding head to replay your work on top of it...
Applying: F1: Add basic login structure
Applying: F2: Add username field to login
Now, check the log:
git log --oneline --graph --all
Expected Output (structure):
* <new_hash_F2'> (HEAD -> feature/login) F2: Add username field to login
* <new_hash_F1'> F1: Add basic login structure
* <hash_C4> (main) C4: Another update on main
* <hash_C3> C3: Important update on main
* <hash_C2> C2: Update project on main
* <hash_C1> Initial project setup
Notice that feature/login
‘s commits F1′ and F2’ are now on top of C4 from main
. The history is linear. The original F1 and F2 commits are no longer part of the feature/login
branch history (though they exist in the reflog for a while).
2. Interactive Rebase (git rebase -i
)
Let’s say on our feature/login branch (which is now rebased), we want to clean up its commits before merging to main.
Suppose F1 had a typo in the message, and F2 was a small fix for F1 that we want to combine.
First, let’s add another commit to feature/login
to have more to work with.
# Ensure you are on feature/login
echo "Login feature: Add password field" >> login.feature
git add login.feature
git commit -m "F3: Add password fild" # Intentional typo: "fild"
Current feature/login
history (on top of main
‘s C4):
C4 -- F1' -- F2' -- F3 (HEAD -> feature/login)
We want to:
- Reword F3’s message to fix “fild” -> “field”.
- Squash F2′ into F1′ (assuming F2′ was a minor addition/fix to F1′).
We need to rebase the last 3 commits on feature/login
.
git rebase -i HEAD~3 # Rebase the last 3 commits
This will open your editor with:
pick <hash_F1'> F1: Add basic login structure
pick <hash_F2'> F2: Add username field to login
pick <hash_F3> F3: Add password fild
# ... (comments explaining commands)
Modify it as follows:
- Change
pick
toreword
(orr
) for the F3 commit. - Change
pick
tosquash
(ors
) for the F2 commit. - Keep
pick
for F1.
The file should look like this:
pick <hash_F1'> F1: Add basic login structure
squash <hash_F2'> F2: Add username field to login
reword <hash_F3> F3: Add password fild
Save and close the editor.
Git will proceed:
- It will apply F1′.
- Then it will try to squash F2′ into F1′. It will pause and open another editor window for you to combine the commit messages of F1′ and F2′.
# This is a combination of 2 commits.
# The first commit's message is:
F1: Add basic login structure
# This is the 2nd commit message:
F2: Add username field to login
Edit this to be a single, coherent message, for example:F1+F2: Add login structure with username field
Save and close this editor. - Next, Git will process the
reword
for F3. It will pause again and open an editor with F3’s message:F3: Add password fild
Correct the typo:F3: Add password field
Save and close this editor.
Expected final output:
[detached HEAD <new_hash_F1+F2_squashed>] F1+F2: Add login structure with username field
Date: ...
Successfully rebased and updated refs/heads/feature/login.
Check the log on feature/login
:
git log --oneline -n 2 # Show last 2 commits
Expected Output:
<new_hash_F3_reworded> (HEAD -> feature/login) F3: Add password field
<new_hash_F1+F2_squashed> F1+F2: Add login structure with username field
The history is now cleaner with only two meaningful commits for the feature, on top of main
.
3. Rebase with Conflicts
Let’s create a conflict scenario.
First, reset feature/login and main to before the first rebase for a clean setup.
(This is advanced, using reflog. For simplicity in a real scenario, you might just recreate the branches or use commit hashes if you noted them down).
A simpler way for this example:
# Go back to a state where feature/login and main diverge and feature/login hasn't been rebased
git checkout main
git reset --hard C4 # Assuming C4 is the commit hash before feature/login was rebased
git checkout feature/login
# Find the original F2 commit hash (e.g., from 'git reflog feature/login')
# Let's assume original F2 was 'abcdef'.
# git reset --hard abcdef # This would reset feature/login to its state before any rebase
# For a simpler, reproducible setup for conflict:
# Reset main to C2
git checkout main
git reset --hard C2
# Reset feature/login to F2 (which was based on C2)
git checkout feature/login
# Ensure login.feature has the content from F1 and F2
# If F1 was "Login feature: Basic structure"
# and F2 added "Login feature: Add username field"
# login.feature should contain:
# Login feature: Basic structure
# Login feature: Add username field
# Recreate F1, F2 if necessary for clean state based on C2.
# For this example, let's assume feature/login is at F2 and main is at C2.
# Now, make conflicting changes:
# On main:
git checkout main
echo "PROJECT V3 - Conflicting change" > project.txt # Overwrite, not append
git commit -am "C3-main: Overwrite project.txt"
# On feature/login:
git checkout feature/login
echo "PROJECT V3 - Feature branch change" > project.txt # Overwrite, not append
git commit -am "F3-feature: Modify project.txt on feature branch"
# Current state:
# F1---F2---F3-feature feature/login
# /
# C1---C2
# \
# C3-main main
# Both F3-feature and C3-main modified project.txt in conflicting ways.
Now, try to rebase feature/login
onto main
:
git checkout feature/login
git rebase main
Expected Output (will indicate a conflict):
First, rewinding head to replay your work on top of it...
Applying: F1: Add basic login structure
Applying: F2: Add username field to login
Applying: F3-feature: Modify project.txt on feature branch
Using index info to reconstruct a base tree...
M project.txt
Falling back to patching base and 3-way merge...
Auto-merging project.txt
CONFLICT (content): Merge conflict in project.txt
error: Failed to merge in the changes.
Patch failed at 0003 F3-feature: Modify project.txt on feature branch
The copy of the patch that failed is found in: .git/rebase-apply/patch
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".
Inspect project.txt
. It will contain conflict markers:
<<<<<<< HEAD
PROJECT V3 - Feature branch change
=======
PROJECT V3 - Conflicting change
>>>>>>> C3-main: Overwrite project.txt
- Edit
project.txt
to resolve the conflict. For example, decide to keep both lines or choose one:PROJECT V3 - Combined change from feature and main
- Stage the resolved file:
git add project.txt
- Continue the rebase:
git rebase --continue
Expected Output:
Applying: F3-feature: Modify project.txt on feature branch
Successfully rebased and updated refs/heads/feature/login.
The feature/login
branch is now rebased onto main
, with the conflict resolved in the new version of the F3 commit.
OS-Specific Notes
- Editor for Interactive Rebase: Git will use the editor configured via
core.editor
(e.g., Vim, Nano, VS Code, Sublime Text). This configuration is consistent across Windows, macOS, and Linux, but the path to the editor executable might differ. Ensure yourcore.editor
is set to an editor you are comfortable using for tasks like modifying the interactive rebase “todo” list and commit messages.- On Windows, if using an editor like Notepad, be mindful of line endings if the rebase “todo” file is sensitive to them (though usually, it’s robust). Using Git Bash with editors like Nano or Vim, or configuring a more advanced editor like VS Code, is common.
- Case Sensitivity: As mentioned in previous chapters, filesystem case sensitivity (Linux: typically sensitive; Windows/default macOS: typically insensitive) can sometimes cause subtle issues if commit content or file paths differ only by case. Rebasing involves applying patches, and if a patch refers to
File.txt
but your filesystem hasfile.txt
, Git usually handles it, but extreme edge cases with case changes in filenames during a rebase could be tricky. It’s best practice to maintain consistent casing. exec
command in Interactive Rebase: Theexec
command runs a shell command. The shell used and the availability of commands will depend on your OS and environment (e.g., Bash/Zsh on Linux/macOS, CMD/PowerShell/Git Bash on Windows). Ensure any script or command used withexec
is compatible with the shell environment wheregit
is being run.
Common Mistakes & Troubleshooting Tips
Command | Abbreviation | Description |
---|---|---|
pick |
p |
Use the commit as is, without any changes. This is the default action for each commit in the list. |
reword |
r |
Use the commit’s changes, but pause the rebase to allow you to edit (reword) the commit message. |
edit |
e |
Use the commit’s changes, but pause the rebase after applying this commit. This allows you to amend the commit (e.g., add/remove files, change content, split the commit). After making changes and using git commit --amend , run git rebase --continue . |
squash |
s |
Meld this commit’s changes into the changes of the commit immediately preceding it (the one on the line above it in the ‘todo’ list). The rebase will pause to allow you to combine and edit the commit messages from both (or more, if multiple squashes) commits. |
fixup |
f |
Similar to squash , but it melds this commit’s changes into the previous commit and discards this commit’s log message entirely. The previous commit’s message is used. Useful for incorporating small fixup commits silently. |
drop |
d |
Remove the commit completely. Its changes will be lost from the branch history. |
exec |
x |
Run a shell command (specified on the rest of the line). If the command exits with a non-zero status (fails), the rebase will pause at that point. Useful for running tests on each commit. |
break |
b |
Stop the rebase at this point before this commit is applied. You can continue later with git rebase --continue . Useful for inspection or manual operations during a rebase. |
(Reordering Lines) | N/A | You can change the order of the commit lines in the ‘todo’ list. Git will attempt to apply the commits in the new specified order. |
label <name> |
l |
Label the current HEAD with <name> . Does not create a commit. |
reset <label> |
t |
Reset HEAD to a previously defined <label> . Subsequent pick commands will be applied on top of this reset point. |
merge ... |
m |
Create a merge commit. This is more advanced and typically used when rebasing merge commits themselves. Requires specifying a label or commit for the merge. |
Exercises
Use the git-rebase-practice
repository. You may need to reset it to specific states (e.g., using git reset --hard <commit_hash>
) for different exercises.
- Feature Branch Update via Rebase:
- Ensure
main
has a few commits (C1, C2, C3). - Create a new branch
feature/new-ui
from C2 onmain
. - Add two commits (F1, F2) to
feature/new-ui
. - Add another commit (C4) to
main
. - Rebase
feature/new-ui
onto the latestmain
. - Inspect the history using
git log --oneline --graph --all
. Verify that F1′ and F2′ are now on top of C4.
- Ensure
- Interactive Rebase for Local Cleanup:
- On a new branch
feature/reporting
(based offmain
), create the following sequence of commits:- “Add basic report structure”
- “Fix typo in report title” (a small change)
- “Implement data gathering for report”
- “Refine report layout”
- “oops, forgot to save a file for layout” (a small fixup commit)
- Use
git rebase -i HEAD~5
(or an appropriate base) to clean up this history:- Squash the “Fix typo…” commit into “Add basic report structure”.
- Reword the “Implement data gathering for report” commit message to be more descriptive.
- Fixup the “oops, forgot to save…” commit into “Refine report layout”.
- Ensure the final commit messages are clean and informative.
- Verify the new, cleaner history of
feature/reporting
.
- On a new branch
- Conflict Resolution During Rebase:
- Set up a scenario:
main
branch with a fileconfig.txt
containing “Version=1”. Commit this as C1.- Create
feature/config-update
frommain
. - On
feature/config-update
, changeconfig.txt
to “Version=2-feature” and commit as F1. - On
main
, changeconfig.txt
to “Version=2-main” and commit as C2.
- Attempt to rebase
feature/config-update
ontomain
. - A conflict should occur in
config.txt
. - Resolve the conflict by setting the content of
config.txt
to “Version=3-resolved”. - Stage the resolved file and continue the rebase.
- Verify the history and the content of
config.txt
onfeature/config-update
.
- Set up a scenario:
Summary
- Rebasing (
git rebase <base-branch>
): Replays commits from the current branch onto the tip of<base-branch>
, creating a linear history. - Rebase vs. Merge: Rebase creates a linear history by rewriting commits; merge preserves history and creates a merge commit.
- Golden Rule of Rebasing: Never rebase commits that have already been pushed to a shared repository and that others may have based work on. This rewrites public history and causes problems for collaborators.
- Interactive Rebasing (
git rebase -i <base>
): Allows fine-grained control over commits being rebased:pick
: Use commit as is.reword
: Change commit message.edit
: Amend commit content/message.squash
: Combine with previous commit, merging messages.fixup
: Combine with previous commit, discarding this commit’s message.drop
: Delete commit.- Reorder lines to reorder commits.
- Conflict Resolution: If conflicts occur, Git pauses. Edit files to resolve,
git add
them, thengit rebase --continue
. - Aborting a Rebase:
git rebase --abort
stops the rebase and returns the branch to its pre-rebase state. - Skipping a Commit:
git rebase --skip
discards the current problematic commit and proceeds (use with caution).
Rebasing is a powerful tool for maintaining a clean and understandable project history, especially for local commit cleanup before sharing. However, its history-rewriting nature demands careful usage, particularly in collaborative settings.
Further Reading
- Pro Git Book:
- Chapter 3.6 Git Branching – Rebasing: https://git-scm.com/book/en/v2/Git-Branching-Rebasing (Covers rebase, the perils of rebasing, and interactive rebase)
- Chapter 7.6 Git Tools – Rewriting History: https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History (More details on interactive rebase and other history rewriting tools)
- Official Git Documentation:
git-rebase(1)
: https://git-scm.com/docs/git-rebase
- Atlassian Git Tutorials – Merging vs. Rebasing: https://www.atlassian.com/git/tutorials/merging-vs-rebasing
