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:

  1. Find the common ancestor of feature-x and main (which is E).
  2. Get the diffs (patches) introduced by commits A, B, and C on feature-x.
  3. Reset the feature-x branch to point to the latest commit on main (G).
  4. Apply the patches for A, B, and C one by one on top of G, creating new commits A', B', and C'. 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
  • Updating a local feature branch with latest changes from base (e.g., main).
  • Cleaning up local commit history (interactive rebase) before sharing.
  • Maintaining linear history on project’s main line (if team policy).
  • Integrating a completed feature branch into a shared/main development line (e.g., main, develop).
  • Preserving the exact historical context of parallel development.
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 on main 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 or develop). 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 onto main and then, if possible, fast-forwarding main 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:

  1. Your local branch history will diverge from the remote branch history.
  2. If you try to git push, Git will reject it because it’s not a fast-forward update.
  3. If you git push --force (which you should generally avoid), you overwrite the history on the remote.
  4. 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. Use git 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:

Bash
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 (or p): Use the commit as is. This is the default.
  • reword (or r): Use the commit, but Git will pause and let you change its commit message.
  • edit (or e): 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 and git commit --amend, use git rebase --continue to proceed.
  • squash (or s): 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 (or f): Like squash, 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 (or d): Removes the commit entirely. Its changes will be lost.
  • exec (or x): 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:

  1. Open the conflicted file(s). They will contain the standard conflict markers (<<<<<<<, =======, >>>>>>>).
  2. Edit the file(s) to resolve the conflicts, keeping the desired changes and removing the markers.
  3. Use git add <resolved_file> to stage the resolved file(s).
  4. 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:

Bash
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.

Bash
# 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.

Bash
# On feature/login branch
git rebase main

Expected Output (if no conflicts):

Bash
Successfully rebased and updated refs/heads/feature/login.

Or, if Git is more verbose (older versions or specific configurations):

Bash
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:

Bash
git log --oneline --graph --all

Expected Output (structure):

Bash
* <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.

Bash
# 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):

Bash
C4 -- F1' -- F2' -- F3 (HEAD -> feature/login)

We want to:

  1. Reword F3’s message to fix “fild” -> “field”.
  2. Squash F2′ into F1′ (assuming F2′ was a minor addition/fix to F1′).

We need to rebase the last 3 commits on feature/login.

Bash
git rebase -i HEAD~3 # Rebase the last 3 commits

This will open your editor with:

Bash
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:

  1. Change pick to reword (or r) for the F3 commit.
  2. Change pick to squash (or s) for the F2 commit.
  3. Keep pick for F1.

The file should look like this:

Bash
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:

  1. It will apply F1′.
  2. 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.
  3. 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:

Bash
[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:

Bash
git log --oneline -n 2 # Show last 2 commits

Expected Output:

Bash
<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:

Bash
# 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:

Bash
git checkout feature/login
git rebase main

Expected Output (will indicate a conflict):

Bash
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:

Bash
<<<<<<< HEAD
PROJECT V3 - Feature branch change
=======
PROJECT V3 - Conflicting change
>>>>>>> C3-main: Overwrite project.txt
  1. 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
  2. Stage the resolved file:git add project.txt
  3. Continue the rebase:git rebase --continue

Expected Output:

Bash
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 your core.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 has file.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: The exec 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 with exec is compatible with the shell environment where git 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.

  1. Feature Branch Update via Rebase:
    1. Ensure main has a few commits (C1, C2, C3).
    2. Create a new branch feature/new-ui from C2 on main.
    3. Add two commits (F1, F2) to feature/new-ui.
    4. Add another commit (C4) to main.
    5. Rebase feature/new-ui onto the latest main.
    6. Inspect the history using git log --oneline --graph --all. Verify that F1′ and F2′ are now on top of C4.
  2. Interactive Rebase for Local Cleanup:
    1. On a new branch feature/reporting (based off main), 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)
    2. 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.
    3. Verify the new, cleaner history of feature/reporting.
  3. Conflict Resolution During Rebase:
    1. Set up a scenario:
      • main branch with a file config.txt containing “Version=1”. Commit this as C1.
      • Create feature/config-update from main.
      • On feature/config-update, change config.txt to “Version=2-feature” and commit as F1.
      • On main, change config.txt to “Version=2-main” and commit as C2.
    2. Attempt to rebase feature/config-update onto main.
    3. A conflict should occur in config.txt.
    4. Resolve the conflict by setting the content of config.txt to “Version=3-resolved”.
    5. Stage the resolved file and continue the rebase.
    6. Verify the history and the content of config.txt on feature/config-update.

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, then git 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

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top