Welcome back, intrepid developer! In our previous chapters, we mastered the basics of Git, learned how to create branches, and merged our work back into the main line of development. Merging is fantastic for combining divergent lines of work, but sometimes, the commit history can look a bit… messy, full of extra merge commits.

What if there was a way to integrate changes from one branch into another, but make it look like you developed your changes directly on top of the latest version of the target branch? What if you could even tidy up your own commits before sharing them with the world?

That’s precisely what Git’s rebase command allows us to do! In this chapter, we’ll dive deep into the world of rebasing. We’ll learn how it differs from merging, explore its incredible power for creating clean, linear project histories, and discover the magic of interactive rebasing to sculpt your commits into perfect, logical units. Get ready to elevate your Git game and contribute to projects with a pristine commit log!

What is Rebasing? A Fresh Perspective on History

Imagine your project’s commit history as a story. When you merge a branch, it’s like adding a new chapter that explicitly states, “Here’s where two different storylines converged.” This creates a merge commit, showing the point where the histories joined.

Rebase, on the other hand, is like rewriting a segment of your story to make it appear as if your new events happened after all the latest developments in the main plot. Instead of creating a merge commit, rebase takes your commits, places them on top of the target branch’s latest commit, and then reapplies them one by one. The result? A perfectly linear history, as if you always started your work from the most up-to-date version.

Let’s visualize this difference:

Merging vs. Rebasing: A Visual Story

Consider a main branch and a feature branch that diverged.

Scenario 1: git merge

graph TD A[Initial Commit] --> B[Commit on main] B --> C[Latest on main] B --> D[Commit on feature] D --> E[Latest on feature] C --> F(Merge Commit) E --> F
  • When you merge feature into main, Git creates a new “merge commit” (F). This commit explicitly records that feature was integrated into main. This preserves the exact history of when and how branches diverged and merged.

Scenario 2: git rebase

graph TD A[Initial Commit] --> B[Commit on main] B --> C[Latest on main] D[Commit on feature] --> E[Latest on feature] subgraph Before Rebase B --> D_original(Commit on feature) D_original --> E_original(Latest on feature) end subgraph After Rebase feature onto main C --> D_rebased(Commit on feature - rebased) D_rebased --> E_rebased(Latest on feature - rebased) end
  • When you rebase feature onto main, Git effectively moves the feature branch’s commits (D and E) to come after the latest commit on main (C). It rewrites the history of your feature branch commits, giving them new commit IDs. The original commits (D_original, E_original) no longer exist in the branch’s history; new ones (D_rebased, E_rebased) are created. The history becomes a straight line.

Why Choose Rebase?

  1. Cleaner History: A linear history is often easier to read and understand. It looks like a single, continuous line of development.
  2. Avoid Unnecessary Merge Commits: If you’re frequently pulling changes from main into your feature branch, rebasing can help avoid a “diamond” shape in your history with many small merge commits.
  3. Easier Backporting (Cherry-Picking): A clean, linear history makes it simpler to identify and cherry-pick specific changes if needed.
  4. Squashing Commits: With interactive rebase, you can combine multiple small, in-progress commits into one logical commit before pushing, making your commit messages more meaningful.

When to Be Cautious with Rebase: The Golden Rule!

The absolute most critical rule when it comes to rebasing is:

DO NOT REBASE COMMITS THAT HAVE ALREADY BEEN PUSHED TO A SHARED (PUBLIC) REPOSITORY.

Why? Because rebasing rewrites history. If you rebase commits that others have already pulled and built their work upon, you’re changing the past that they’ve already seen. When they try to push or pull again, Git will see a completely different history and get very confused, leading to potential data loss or complex conflict resolution for everyone involved.

Think of it this way: rebase is your personal history-editing tool for your local, unshared work. Once your commits are public, they’re set in stone.

Step-by-Step Implementation: Rebasing for a Clean History

Let’s get our hands dirty and see rebase in action. We’ll simulate a common scenario: you’re working on a feature branch, and the main branch has new updates you want to incorporate smoothly.

First, let’s create a new Git repository and some initial commits.

# Initialize a new Git repository
git init my_rebase_project
cd my_rebase_project

# Configure user details (if not already set globally)
git config user.name "Your Name"
git config user.email "your.email@example.com"

# Create an initial commit on the 'main' branch
echo "Initial project setup" > README.md
git add README.md
git commit -m "feat: Initial project setup"

# Create a simple file
echo "function greet() { return 'Hello!'; }" > src/app.js
git add src/app.js
git commit -m "feat: Add basic greeting function"

# You are now on the 'main' branch with two commits.
# Let's verify the log
git log --oneline

You should see output similar to this (commit IDs will differ):

b1a2c3d feat: Add basic greeting function
a0b1c2d feat: Initial project setup

Scenario: Diverging Branches

Now, let’s create a feature branch and make some changes there, while also simulating new changes happening on main.

# Create a new feature branch
git checkout -b feature/login-form

# Make some changes on the feature branch
echo "<!-- Login Form -->" > src/login.html
echo "<input type='text' placeholder='Username'>" >> src/login.html
git add src/login.html
git commit -m "feat: Add basic login form HTML"

echo "<input type='password' placeholder='Password'>" >> src/login.html
git add src/login.html
git commit -m "feat: Add password field to login form"

# Check the log on feature/login-form
git log --oneline

Your feature/login-form branch now has two new commits on top of main.

Now, let’s switch back to main and simulate someone else making changes:

git checkout main

# Make a new commit on the main branch
echo "/** Global utility functions */" > src/utils.js
echo "function logEvent(event) { console.log(event); }" >> src/utils.js
git add src/utils.js
git commit -m "feat: Add global utility logging function"

# Check the log on main
git log --oneline

You now have a situation where feature/login-form is two commits ahead of main from where it branched, and main is one commit ahead of where feature/login-form branched off. They have diverged!

graph TD A(Initial Commit) --> B(Add greeting function) B --> C_main(Add utility logging) B --> D_feature(Add basic login form) D_feature --> E_feature(Add password field) subgraph main C_main end subgraph feature/login-form D_feature E_feature end

Performing a git rebase

Now, let’s bring the feature/login-form branch up-to-date with main using rebase.

# Switch back to your feature branch
git checkout feature/login-form

# Rebase feature/login-form onto main
git rebase main

Git will now attempt to “replay” your feature/login-form commits one by one on top of the latest main commit. You should see output similar to:

Successfully rebased and updated refs/heads/feature/login-form.

Let’s look at the log now:

git log --oneline

Notice how the commits from feature/login-form now appear after the main branch’s new commit, and there’s no merge commit! The history is perfectly linear. The commit IDs for your feature branch commits will also have changed, as they are technically new commits.

graph TD A(Initial Commit) --> B(Add greeting function) B --> C_main(Add utility logging) C_main --> D_rebased(Add basic login form - new ID) D_rebased --> E_rebased(Add password field - new ID) subgraph main C_main end subgraph feature/login-form D_rebased E_rebased end

This is a clean, linear history. Excellent!

Resolving Conflicts During Rebase

What happens if changes on main conflict with changes on your feature branch? Git will pause the rebase process and let you resolve them.

Let’s simulate a conflict:

# First, reset our feature branch to before the rebase for a fresh start
git reset --hard HEAD~2 # This assumes the last two commits were from the feature branch
git checkout main
git branch -D feature/login-form # Delete the rebased branch
git checkout -b feature/login-form # Recreate the branch from main's earlier state

# (Re-add feature commits - let's simplify for the example, assume we're back at divergence point)
# For a real scenario, you'd be in a state before your first rebase attempt.
# Let's manually create a conflict for demonstration.

# Create a fresh repository for a clean conflict demo
cd ..
rm -rf my_rebase_project
git init my_rebase_project
cd my_rebase_project
git config user.name "Your Name"
git config user.email "your.email@example.com"

echo "Line 1" > conflict_file.txt
echo "Line 2" >> conflict_file.txt
git add conflict_file.txt
git commit -m "Initial commit with conflict_file"

git checkout -b feature/conflict-example

# Change on feature branch
sed -i '' 's/Line 2/Feature Line 2 Modified/' conflict_file.txt # Use 'sed -i' for macOS/BSD
# For Linux: sed -i 's/Line 2/Feature Line 2 Modified/' conflict_file.txt
git add conflict_file.txt
git commit -m "feat: Modify Line 2 on feature"

git checkout main

# Change on main branch (conflicting with feature)
sed -i '' 's/Line 2/Main Line 2 Modified/' conflict_file.txt # Use 'sed -i' for macOS/BSD
# For Linux: sed -i 's/Line 2/Main Line 2 Modified/' conflict_file.txt
git add conflict_file.txt
git commit -m "feat: Modify Line 2 on main"

git checkout feature/conflict-example

# Now, attempt to rebase and create a conflict
git rebase main

Git will pause and tell you there’s a conflict:

Applying: feat: Modify Line 2 on feature
Using index info to reconstruct a base tree...
M       conflict_file.txt
.git/rebase-apply/patch:3: trailing whitespace.
Line 2

error: patch failed: conflict_file.txt:1
error: conflict_file.txt: patch does not apply
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can abort the rebase with "git rebase --abort".

This is familiar territory if you’ve resolved merge conflicts.

  1. Open the conflicted file (conflict_file.txt):

    <<<<<<< HEAD
    Feature Line 2 Modified
    =======
    Main Line 2 Modified
    >>>>>>> feat: Modify Line 2 on main
    
  2. Resolve the conflict: Edit the file to the desired state. Let’s say we want to keep both, or a combined version:

    Line 1
    Combined Line 2 from Feature and Main
    
  3. Stage the resolved file:

    git add conflict_file.txt
    
  4. Continue the rebase:

    git rebase --continue
    

    If there are more commits to replay that also have conflicts, Git will pause again. Once all commits are replayed and conflicts resolved, the rebase will complete.

    You can always use git rebase --abort if you get stuck and want to go back to the state before you started the rebase.

Interactive Rebasing: Sculpting Your History (git rebase -i)

This is where rebase becomes truly powerful for cleaning up your local branch history before pushing. git rebase -i (interactive rebase) lets you modify individual commits, combine them, reorder them, or even delete them.

Let’s say you’ve been working on a feature, and you have a series of commits like this:

  • “feat: Add login form structure”
  • “fix: Typo in login form label”
  • “refactor: Adjust CSS for login form”
  • “feat: Add password field”
  • “fix: Another small typo”

This is a lot of granular commits. For a cleaner history, you might want to combine the typo fixes and the CSS refactor into the main login form commits.

Let’s create a scenario:

# Ensure we are on a clean main
cd ..
rm -rf my_rebase_project
git init my_rebase_project
cd my_rebase_project
git config user.name "Your Name"
git config user.email "your.email@example.com"

echo "Initial project setup" > README.md
git add README.md
git commit -m "feat: Initial setup"

git checkout -b feature/interactive-demo

echo "function validateEmail(email) { /* ... */ }" > src/validation.js
git add src/validation.js
git commit -m "feat: Add email validation function"

echo "function validatePassword(password) { /* ... */ }" >> src/validation.js
git add src/validation.js
git commit -m "feat: Add password validation function"

# Oops, found a bug right after adding password validation
sed -i '' 's/validatePassword/isValidPassword/' src/validation.js
# For Linux: sed -i 's/validatePassword/isValidPassword/' src/validation.js
git add src/validation.js
git commit -m "fix: Rename isValidPassword function"

echo "function validateUsername(username) { /* ... */ }" >> src/validation.js
git add src/validation.js
git commit -m "feat: Add username validation"

# Another small fix
sed -i '' 's/isValidPassword/validatePasswordStrength/' src/validation.js
# For Linux: sed -i 's/isValidPassword/validatePasswordStrength/' src/validation.js
git add src/validation.js
git commit -m "fix: Improve password validation naming"

git log --oneline

You’ll see something like:

e1f2g3h fix: Improve password validation naming
c4d5e6f feat: Add username validation
a7b8c9d fix: Rename isValidPassword function
f0g1h2i feat: Add password validation function
j3k4l5m feat: Add email validation function
... (main commits)

Notice the two “fix” commits. We can squash them into their respective feature commits or into one logical “validation functions” commit.

Starting an Interactive Rebase

To interactively rebase the last N commits, you use git rebase -i HEAD~N. If you want to rebase all commits since your branch diverged from main, you can use git rebase -i main.

Let’s rebase the last 5 commits (the ones from feature/interactive-demo):

git rebase -i HEAD~5

Git will open your default text editor (like Vim, Nano, or VS Code). You’ll see a file listing your commits in reverse chronological order (oldest first), along with instructions:

pick j3k4l5m feat: Add email validation function
pick f0g1h2i feat: Add password validation function
pick a7b8c9d fix: Rename isValidPassword function
pick c4d5e6f feat: Add username validation
pick e1f2g3h fix: Improve password validation naming

# Rebase 8a5f3d4..e1f2g3h onto 8a5f3d4 (5 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) for each commit
# 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, tdo <label> = like 'label', but only if empty
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit and edit its message (or use the one from <commit> or <shortref>)
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here, that commit will be dropped from the series.
#
# However, if you remove everything, the rebase will be aborted.
#

Common Interactive Rebase Commands

  • pick (p): Use the commit as is. This is the default.
  • reword (r): Use the commit, but stop to edit its commit message.
  • edit (e): Use the commit, but stop to amend its contents (e.g., add/remove files, change code) and/or message.
  • squash (s): Use the commit, but merge its changes into the previous commit. Git will then open an editor to combine the commit messages.
  • fixup (f): Like squash, but it discards this commit’s log message, keeping only the previous commit’s message. Useful for small fixes you don’t want to explicitly mention.
  • drop (d): Remove the commit entirely.

Our Goal: Clean Up the Validation Commits

Let’s modify the editor content to achieve a cleaner history:

  1. Squash fix: Rename isValidPassword function into feat: Add password validation function.
  2. Squash fix: Improve password validation naming into feat: Add username validation (or combine it into the password one if it’s related). For simplicity, let’s squash it into the previous feature commit.

So, we’ll change the file to:

pick j3k4l5m feat: Add email validation function
pick f0g1h2i feat: Add password validation function
fixup a7b8c9d fix: Rename isValidPassword function # Use 'fixup' to discard its message
pick c4d5e6f feat: Add username validation
fixup e1f2g3h fix: Improve password validation naming # Use 'fixup' to discard its message

# ... (rest of the comments)

Important: The fixup and squash commands always apply to the commit above them in the list.

Save and close the editor. Git will then proceed with the rebase. If you used squash, Git will open another editor for you to combine the commit messages. If you used fixup, it will silently combine them.

After the rebase completes, check your git log --oneline:

git log --oneline

You should now see a much cleaner history, with the “fix” commits integrated into their logical feature commits. The number of commits will be reduced, and their IDs will be new.

This is incredibly powerful for maintaining a clean, understandable project history, especially before creating a Pull Request or Merge Request for your team to review.

Mini-Challenge: Consolidate Your Work

You’ve been working on a new data-fetching module. You started with a basic setup, then added error handling, then refactored a small part, and finally added a loading state. You now have four commits:

  1. feat: Initial data fetching module setup
  2. feat: Add error handling to data fetching
  3. refactor: Minor cleanup in data fetching logic
  4. feat: Implement loading state for data fetching

Challenge: Use git rebase -i to consolidate these four commits into two logical commits:

  • One commit for the initial setup and error handling.
  • One commit for the loading state and refactoring.

Steps:

  1. Create a new Git repository or a new branch in your existing one.
  2. Make the four commits as described above.
  3. Start an interactive rebase to target these four commits.
  4. Use squash or fixup to combine them as requested.
  5. Edit the commit messages to be clear and concise for the two new commits.

Hint: Remember that squash and fixup apply to the commit above them in the interactive rebase editor. You might need to reorder commits slightly in the editor to get the desired grouping.

What to observe/learn: After completing the challenge, use git log --oneline to verify that your history now reflects only two commits, each with a clear, combined message, representing the consolidated work. This demonstrates how you can group related changes for better readability.

Common Pitfalls & Troubleshooting

  1. Rebasing Public/Shared Branches: We’ve covered this, but it’s worth reiterating: Never rebase commits that have already been pushed to a public or shared remote repository. Doing so will cause headaches for your collaborators. If you must, the only way is to git push --force-with-lease (or git push --force), but only if you are absolutely certain no one else has pulled your old commits, or if you’ve communicated clearly with your team and they are prepared to handle the rewritten history. This is an advanced, risky operation.
  2. Forgetting git add during Conflict Resolution: Just like with git merge, if Git pauses for a conflict during rebase, you must resolve the conflict in the file(s) and then git add the resolved file(s) to the staging area before running git rebase --continue. Forgetting git add will result in Git complaining that you haven’t marked conflicts as resolved.
  3. Getting Lost in rebase -i: The interactive rebase editor can be intimidating. If you find yourself confused or make a mistake in the editor, simply close it without saving changes (e.g., :q! in Vim) or use git rebase --abort to return to the state before the rebase started.
  4. Losing Commits: While rebase rewrites history, Git’s reflog is your safety net. If you accidentally drop a commit or mess up a rebase, git reflog will show you a history of where your HEAD has been, allowing you to find the commit hash of your previous state and reset back to it (git reset --hard <commit_hash>).
  5. Reordering Commits with Dependencies: Be careful when reordering commits in rebase -i. If commit B depends on changes introduced in commit A, and you reorder them so B comes before A, Git will likely encounter conflicts or errors because B’s changes won’t have their prerequisites.

Summary

Congratulations! You’ve unlocked the power of git rebase. You now understand:

  • The fundamental difference between git merge and git rebase and their impact on commit history.
  • How git rebase creates a clean, linear history by replaying your commits on top of a target branch.
  • How to resolve conflicts that arise during a rebase operation.
  • The immense power of git rebase -i (interactive rebase) for sculpting your local commit history: squashing, fixing up, reordering, editing, and dropping commits to create meaningful, atomic changes.
  • The crucial “golden rule” of rebasing: Never rebase commits that have already been shared with others.
  • Common pitfalls and troubleshooting techniques when working with rebase.

Rebasing is a powerful tool that, when used correctly, can significantly improve the clarity and maintainability of your project’s commit history. It’s a hallmark of professional Git workflows, especially for preparing feature branches for review.

In the next chapter, we’ll build on this knowledge to explore advanced branching strategies and team workflows, integrating these techniques into a collaborative development environment.


References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.