Git rebase vs merge: a pragmatic policy
Effective Git workflow management hinges on a clear understanding of integration strategies. Two primary methods, rebasing and merging, each offer…
Effective Git workflow management hinges on a clear understanding of integration strategies. Two primary methods, rebasing and merging, each offer distinct advantages and disadvantages concerning commit history, collaboration, and maintainability. This article explores a pragmatic policy for leveraging both git rebase and git merge, focusing on scenarios common in software development teams ranging from small startups to large enterprises, emphasizing a clean, traceable history without sacrificing developer productivity.
Our policy is designed to maintain a linear, easy-to-follow history on main branches while preserving the context of feature development. We will analyze specific Git commands, their impact on the commit graph, and best practices for their application.
Understanding Git Merge
git merge integrates changes from one branch into another, creating a new "merge commit" by default. This merge commit has two or more parent commits, clearly indicating where and when the integration occurred. This method is non-destructive, as it never rewrites existing history.
Types of Merges
- Fast-forward merge: If the target branch has not diverged from the source branch, Git can simply move the target branch pointer forward to the tip of the source branch. No merge commit is created. This happens automatically if possible.
- Three-way merge (merge commit): When branches have diverged, Git performs a three-way merge, using the two branch tips and their common ancestor. The result is a new commit, explicitly documenting the merge event. This is the default behavior when a fast-forward is not possible or when
--no-ffis used.
Example: Merging a feature branch into main with a merge commit.
# Assume you are on 'main'
git checkout main
git pull origin main # Ensure main is up-to-date
git merge --no-ff feature/new-login-flow -m "Merge feature/new-login-flow into main"
git push origin main
The --no-ff flag is crucial here. Even if a fast-forward merge were possible, this flag forces Git to create a merge commit, preserving the branch structure in the history. This provides a clear audit trail of when a feature branch was integrated.
Understanding Git Rebase
git rebase is a powerful command for rewriting commit history. It essentially reapplies commits from one branch onto another base branch. Instead of creating a merge commit, it moves or "replays" the commit sequence, resulting in a linear history.
How Rebase Works
- Git finds the common ancestor of your feature branch and the target base branch (e.g.,
main). - It temporarily shelves the commits unique to your feature branch.
- It updates your feature branch to the latest commit of the target base branch.
- It then reapplies your shelved commits one by one onto the new base.
Each replayed commit receives a new commit hash. This rewriting of history is the core characteristic and primary caution for git rebase.
Example: Rebasing a feature branch onto main.
# Assume you are on 'feature/new-login-flow'
git checkout feature/new-login-flow
git fetch origin
git rebase origin/main
# Resolve any conflicts. For each conflict:
# git add <conflicted-file>
# git rebase --continue
# If you need to stop: git rebase --abort
# After successful rebase:
git push origin feature/new-login-flow --force-with-lease # Requires --force or --force-with-lease due to history rewrite
The --force-with-lease flag is highly recommended over plain --force when pushing a rebased branch. It checks that no one else has pushed to the remote branch since you last pulled, preventing accidental overwrites of collaborators' work. Always exercise extreme caution when force-pushing.
The Pragmatic Policy: Rebase for Personal Branches, Merge for Main
Our recommended policy blends the strengths of both strategies:
- Before creating a Pull Request (PR) for a feature branch: Rebase your feature branch onto the latest
main(or development branch). - When merging a PR into
main: Use a standard merge commit (git merge --no-ff). - Never rebase shared branches: Once a branch is pushed and potentially being worked on by others, do not rebase it.
1. Rebase Feature Branches Before PR
Purpose: To achieve a clean, linear history for your feature branch relative to main before integration. This makes PR reviews easier by showing only the changes introduced by the feature, rather than interleaved merge commits from main.
Process: Developers should frequently rebase their local feature branches onto the latest main during development, especially before submitting a PR. This resolves integration conflicts early and ensures the feature is built on the most current codebase.
Command: git rebase origin/main (assuming origin/main is up-to-date locally via git fetch).
Benefits:
- Linear history: The feature branch history appears as if it was developed directly on top of the latest
main. - Easier reviews: PRs will only show new, relevant commits, not merge commits from syncing with
main. This simplifies code review significantly. - Cleaner commit messages: Allows for interactive rebase (
git rebase -i) to squash or reorder commits, further cleaning up the feature branch history before it's proposed for merge.
2. Merge PRs with Merge Commits
Purpose: To explicitly record when a feature branch was integrated into main, preserving the context of its development and providing clear points for reverting if necessary.
Process: After a feature branch has been reviewed and approved, it should be merged into main using a merge commit. Most modern Git platforms (GitHub, GitLab, Bitbucket) offer a "Squash and Merge," "Rebase and Merge," and "Merge Commit" option. We specifically advocate for "Merge Commit" here.
Command: git checkout main && git pull origin main && git merge --no-ff feature/branch-name -m "Merge feature/branch-name"
Benefits:
- Clear audit trail: Each merge commit explicitly states when and which branch was integrated, making it easy to see where features landed.
- Context preservation: The entire history of the feature branch (after its own rebases) is preserved within the merge commit.
- Easy reverts: A single
git revert -m 1 <merge-commit-hash>can undo an entire feature if it introduces critical bugs, assuming the merge commit is the first parent of the new state.
3. Never Rebase Shared Branches
Critical Rule: Once a branch has been pushed to a remote repository and other developers might have pulled it or based their work on it, do not rebase it. Rebasing rewrites history, changing commit hashes. If others have based their work on the old history, a rebase will cause significant problems for them (e.g., needing to force pull, resolve complex conflicts, or abandon their work).
Exception: It is acceptable to rebase a remote branch if you are absolutely certain that no one else is using it, or if you can coordinate with all users of that branch to perform the necessary recovery steps (e.g., git pull --rebase for their local changes). In practice, this is rare for long-lived branches or main development lines.
Comparison Summary
| Feature | git merge (with merge commit) |
git rebase |
|---|---|---|
| History | Non-linear, preserves original commit history and branch structure. Shows explicit merge points. | Linear, rewrites history by replaying commits. Appears as if work was done directly on the base branch. |
| Safety | Non-destructive, never rewrites history. Safe for shared branches. | Destructive, rewrites history (new commit hashes). Unsafe for shared branches unless coordinated. |
| Clarity | Clear record of integration points and feature lifespan. Can sometimes clutter history. | Clean, linear history. Can lose context of parallel development unless commits are well-squashed. |
| Revertability | Easy to revert an entire feature with one git revert on the merge commit. |
Reverting a set of rebased commits is more complex, potentially requiring multiple git revert operations or a git reset. |
| Conflict Resolution | Typically, one large conflict resolution at the merge point. | Can require resolving conflicts commit by commit during the rebase process, potentially multiple times. |
| Use Case | Integrating finished features into main/release branches. Recording significant integrations. |
Syncing local feature branches with main during development. Cleaning up local commit history (e.g., squashing). |
Common Pitfalls and Troubleshooting
- Rebasing a shared branch: The most critical error. If you accidentally rebase a shared branch and force push, collaborators will face issues. They will likely need to run
git pull --rebaseor even delete and re-clone their local branch. Communicate immediately if this happens. - Forgetting
git fetchbefore rebase: Always ensure your local remote-tracking branches (e.g.,origin/main) are up-to-date viagit fetchbefore rebasing. Otherwise, you might rebase onto an old version ofmain. - Complex conflicts during rebase: If a rebase involves many commits or significant divergence, conflict resolution can be tedious. Take your time, resolve conflicts carefully,
git addthe changes, and thengit rebase --continue. If overwhelmed,git rebase --abortis your friend. - Squashing too much: While squashing commits with
git rebase -ican clean up history, avoid squashing away useful intermediate commits that might represent logical steps or important checkpoints. Balance conciseness with traceability. - Merge vs. Rebase in CI/CD: Ensure your CI/CD pipelines are configured to use the correct merge strategy for PRs into
main(e.g., prefer "merge commit" over "rebase and merge" or "squash and merge" if you want full feature history onmain).