Handbooks / Git / Chapter 4

Rebase vs Merge

47 pages · ~100 min✓ Reviewed

Builds on Resolving Merge Conflicts. Next up: History Archaeology.

Part 1 · Rebase vs Merge

Rebase vs Merge: Choosing How Git Integrates Branches

Every team that uses Git eventually argues about merge versus rebase, and the argument usually goes nowhere because both sides are describing taste. This chapter replaces taste with mechanics. A commit is an immutable snapshot that points at its parents, a branch is a pointer to one of those commits, and every integration command is just graph surgery plus a comparison of trees. Once you can see the graph, the behaviour of fast-forward, three-way merge, rebase and cherry-pick stops being a list of flags and becomes a set of consequences you can predict.

It matters because the two tools ship the same final code but leave different history behind, and that difference decides how easy it is to bisect a regression, revert a bad feature, read a log, or recover after a mistake. Rebase gives new commit hashes to work you have already written, which is harmless on a private branch and painful on a shared one. Merge never rewrites anything, but it records every divergence. Conflicts, force-pushes and lost commits are where these trade-offs turn into bad afternoons, so we will look at how conflicts are built, why the labels ours and theirs flip during a rebase, and how the reflog lets you undo almost anything.

By the end you will be able to read a commit graph and say which algorithm Git will use, tidy your own branch with interactive rebase, resolve conflicts by reading the base rather than guessing, and push rewritten history safely with --force-with-lease. You will also be able to pick an integration policy for a team (merge commits, rebase plus fast-forward, or squash) and defend it with the golden rule: do not rewrite commits that other people may already have built on. The last section condenses all of this into a decision rulebook and cheat sheet.

Before you start

You need Git 2.38 or newer to follow every example (it adds --update-refs), a terminal, and a scratch repository you can break freely: run git init in an empty folder and make a few commits on main and a feature branch. You should already be comfortable with git add, git commit, git branch and git log. Run git config merge.conflictStyle zdiff3 once now so conflict markers show the common base later, and keep git log --oneline --graph --all open in a second terminal to watch each command move the pointers.

Part 2 · The Commit Graph: What Integration Actually Operates On

Commits Are Snapshots, Not Diffs

Every integration command in Git (merge, rebase, cherry-pick) works on one data structure: the commit graph. Before you can predict what a merge or rebase will do, you need to know exactly what a commit is and what it is not.

A commit is an immutable object with five ingredients: the SHA of a tree (a full snapshot of the project), zero or more parent SHAs, an author, a committer, and a message. You can see all of them with git cat-file -p.

output
$ git cat-file -p HEAD
tree 9f2a1c4e07b5d3a8c61f0e92b4d7a35c8e10f6ab
parent 4c1b0e7d93a2f5586b1c0d4e7a9f3b26d8c15e70
author Ada <a@x.dev> 1772000000 +0530
committer Ada <a@x.dev> 1772000000 +0530

add retry to the fetch loop

One commit, all of it. The tree line is the whole snapshot, not a patch.

Notice what is missing: there is no diff in there. Git stores snapshots, not deltas. When you run git show or git diff, Git loads two trees and compares them on demand. The diff is a view that is computed when you ask, never something that is stored.

Graph surgery plus tree comparison

Every integration operation does two things: it moves pointers and creates commits in the graph, and it compares trees to work out what content to put in them. There is nothing else going on under the hood.

Change anything, change the hash

A commit's SHA is a hash of all its fields together: content (the tree), parents, and metadata (author, committer, timestamps, message). Change one byte in any of them and you get a different SHA. That is why a commit can never be edited in place. Any tool that appears to edit a commit actually creates a new one beside it.

The same rule explains rebase. Replay a commit onto a different base and its parent field changes, so its hash must change, even when the diff is byte-identical. The snippet below builds commit objects with the same layout Git uses, so you can see that rule in action.

python
import hashlib

def commit_sha(tree, parents, who, message):
    head = ["tree " + tree] + ["parent " + p for p in parents]
    stamp = " 1772000000 +0530"
    head += ["author " + who + stamp, "committer " + who + stamp]
    body = ("\n".join(head) + "\n\n" + message + "\n").encode()
    return hashlib.sha1(b"commit %d\0" % len(body) + body).hexdigest()

tree = hashlib.sha1(b"snapshot of src/").hexdigest()
base = commit_sha(tree, [], "Ada", "init")
a = commit_sha(tree, [base], "Ada", "add parser")
same = commit_sha(tree, [base], "Ada", "add parser")
edited = commit_sha(tree, [base], "Ada", "add parser.")
other_base = commit_sha(tree, [a], "Ada", "init")
replayed = commit_sha(tree, [other_base], "Ada", "add parser")

print("same fields:", a == same)
print("one dot added to the message:", a == edited)
print("same tree and message, new parent:", a == replayed)
print("hash length:", len(a))

Identical fields give an identical hash; any difference gives a new one.

output
same fields: True
one dot added to the message: False
same tree and message, new parent: False
hash length: 40

The last comparison is a rebase in miniature. The tree and message are the same and only the parent differs, but the result is a different commit. The old one still exists, and nothing points at it any more.

Refs: Branches and HEAD Are Just Pointers

If commits are immutable, something has to move when you commit. That something is a ref. A branch is a tiny file in .git/refs/heads/ holding one commit SHA: 40 hex characters plus a newline, so 41 bytes. It is a movable pointer and nothing more, which is why creating a branch is O(1). Git writes one small file and copies no history.

HEAD is also a ref, but a symbolic one. Normally it holds the name of a branch, and that branch holds the commit. When you commit, Git creates the new commit and then moves the branch that HEAD names. In detached HEAD state, HEAD holds a raw commit SHA instead, so there is no branch to move along with you.

output
$ cat .git/refs/heads/main
a1b2c3d4e5f60718293a4b5c6d7e8f9012345678

$ cat .git/HEAD
ref: refs/heads/main

$ git switch --detach HEAD~2
$ cat .git/HEAD
4c1b0e7d93a2f5586b1c0d4e7a9f3b26d8c15e70

Attached HEAD names a branch; detached HEAD holds a SHA directly.

Branch refHEAD (attached)HEAD (detached)
Lives in.git/refs/heads/<name>.git/HEAD.git/HEAD
ContainsOne commit SHAref: refs/heads/<name>One commit SHA
Moves when you commitYes, if HEAD names itNo, the branch movesYes, HEAD itself moves
Cost to createO(1), one tiny fileNot createdNot created

After git gc, many branch refs are folded into a single .git/packed-refs file, but the idea is the same: a name mapped to a SHA.

Common mistake: committing on a detached HEAD

Commits made while detached have no branch pointing at them. The moment you switch away they become unreachable, and only the reflog remembers them. Run git switch -c rescue before you leave, and the commits stay reachable.

Reading the Topology: Parents, Ancestry and Ranges

Parent count decides the kind of commit

The only structural fact about a commit is how many parents it has. That count is all that separates the first commit, an everyday commit and a merge, and git log --graph draws exactly this topology.

ParentsKindNotes
0Root commitThe first commit of a history. A repository can have several.
1Normal commitLinear history, one step from its parent.
2 or moreMerge commitJoins histories. HEAD^1 is the branch you were on, HEAD^2 the one merged in.
output
*   9c1e4d2 (HEAD -> main) Merge branch 'feature'
|\
| * 5b7a3f0 (feature) add retry
| * 2d8c611 add parser
* | 3f6e2b8 update docs
|/
* e41f0aa fix typo
* 7a90b3c init

From git log --graph --oneline --decorate. 9c1e4d2 has two parents; 7a90b3c has none.

One question decides fast-forward or merge

Git can ask whether commit A is an ancestor of commit B, meaning you can reach A by following parent links from B. git merge-base --is-ancestor A B answers with its exit code: 0 for yes and 1 for no. This single question decides whether a merge is a fast-forward or a three-way merge.

What git merge feature does

The merge base is the best common ancestor of two commits, found with git merge-base A B. It is the fork point where the two histories diverged, and a three-way merge uses it as the BASE side of its comparison. In the example above, e41f0aa is the merge base of main and feature before they were joined.

The program below models a small graph as a dictionary of parent lists. It implements the ancestry test, the merge base and the range operators. Commits C1 to C3 sit on one line, and F1 and F2 branch off C2.

python
parents = {
    "C1": [], "C2": ["C1"], "C3": ["C2"],
    "F1": ["C2"], "F2": ["F1"],
}

def ancestors(c):
    seen, todo = set(), [c]
    while todo:
        x = todo.pop()
        if x not in seen:
            seen.add(x)
            todo += parents[x]
    return seen

def is_ancestor(a, b):
    return a in ancestors(b)

def merge_base(a, b):
    common = ancestors(a) & ancestors(b)
    return [c for c in common if not any(c in ancestors(o) and o != c for o in common)][0]

def reachable_not(b, a):
    return sorted(ancestors(b) - ancestors(a))

print("C2 ancestor of F2:", is_ancestor("C2", "F2"))
print("C3 ancestor of F2:", is_ancestor("C3", "F2"))
print("merge base C3,F2:", merge_base("C3", "F2"))
print("C3..F2:", reachable_not("F2", "C3"))
print("F2..C3:", reachable_not("C3", "F2"))
print("C3...F2:", sorted(set(reachable_not("F2", "C3")) | set(reachable_not("C3", "F2"))))

The same questions Git asks, on a five-commit graph.

output
C2 ancestor of F2: True
C3 ancestor of F2: False
merge base C3,F2: C2
C3..F2: ['F1', 'F2']
F2..C3: ['C3']
C3...F2: ['C3', 'F1', 'F2']

C3 is not an ancestor of F2, so merging F2 into C3 cannot fast-forward. The merge base is C2, the fork point. The last three lines show the range syntax, which is how you ask Git to list the commits in a region of the graph.

SyntaxMeaningExample use
main..featureReachable from feature but not from maingit log main..feature lists exactly what feature adds
feature..mainReachable from main but not from featureWhat you are missing, i.e. how far behind you are
main...featureSymmetric difference: reachable from either, but not bothBoth sides' divergences, e.g. git log --left-right main...feature
Common mistake: assuming .. means the same everywhere

With git log, A..B and A...B select commits as shown above. With git diff they mean something else: A..B is the same as comparing A and B directly, and A...B compares B against the merge base of A and B. Check which command you are in before you trust a range.

The Reflog: The Undo Log Behind Rewrites

Rebase and reset leave old commits unreachable from any branch, so you might expect them to be gone. They are not, thanks to the reflog. Git records every movement of HEAD and of each branch tip in a local log. By default those entries survive for about 90 days, or about 30 days when the commit is no longer reachable from anything else, so the old tips remain recoverable for a good while.

output
$ git reflog
3f6e2b8 HEAD@{0}: rebase (finish): returning to refs/heads/feature
3f6e2b8 HEAD@{1}: rebase (pick): add retry
e41f0aa HEAD@{2}: rebase (start): checkout main
5b7a3f0 HEAD@{3}: commit: add retry
2d8c611 HEAD@{4}: commit: add parser

Each line is one move; HEAD@{n} counts back from the present.

Read the log from the bottom: two commits, then a rebase that started from main, replayed the commit and finished. The commit before the rebase is 5b7a3f0, found at HEAD@{3}. Because the rebase created new commits, that old SHA still exists in the object store, and the reflog is what lets you name it.

Two limits matter. The reflog is local: it is never pushed, so a teammate cannot use yours. And it only covers committed work, so changes you never committed cannot be recovered from it.

Why this matters for the rest of the chapter

Fast-forward moves a pointer, a three-way merge adds a commit with two parents, and rebase makes new copies of commits on a new base. Each of them is a different edit to the same graph. Once you can picture the graph, you can predict which one you need.

Part 3 · Fast-Forward Merge: When History Is a Straight Line

The condition and the mechanics

A fast-forward is the simplest integration Git can perform, and it happens whenever the branch you are standing on has nothing the other branch lacks. Formally, the current tip is an ancestor of the tip you are merging. The two branches have not diverged: one is just the other, stopped a few commits earlier. There are no competing edits, so there is nothing to reconcile.

Take the usual picture. main points at C2, and feature was cut from C2 and gained two commits, C3 and C4. Walking back from feature reaches C2, so the test passes. Git does not need to look for a fork point, because C2 is itself the fork point.

main is behind feature on the same line
  1. 1C1root
  2. 2C2main points here
  3. 3C3feature work
  4. 4C4feature points here

The merge itself is pointer movement only. Git rewrites the one-line file behind refs/heads/main so it holds C4's SHA instead of C2's, and then updates the working tree and index to match C4's snapshot. No commit object is written. There is no new parent list, no new tree and no new message, and the commits C3 and C4 keep exactly the SHAs they had on feature.

bash
git checkout main            # main sits at C2
cat .git/refs/heads/main     # SHA of C2
git merge feature            # Fast-forward
cat .git/refs/heads/main     # SHA of C4, same file, new content

Read the ref, not the log: that file changing is the whole operation

Because only a ref changes, the cost is O(1) for the ref write plus the checkout of whichever files differ between C2 and C4. Git never computes a merge base and never runs a three-way diff, so a conflict is impossible here, not merely unlikely. After the merge main and feature point at the same commit and the history is still one straight line.

The toy model below keeps a commit graph as a dictionary of parent lists and refs as a dictionary of names to SHAs. It uses the same ancestry test Git applies and counts the objects before and after.

python
parents = {"C1": [], "C2": ["C1"], "C3": ["C2"], "C4": ["C3"]}
refs = {"main": "C2", "feature": "C4"}

def is_ancestor(a, b):
    stack = [b]
    while stack:
        c = stack.pop()
        if c == a:
            return True
        stack.extend(parents[c])
    return False

def merge(target, source, mode="default"):
    if is_ancestor(refs[target], refs[source]) and mode != "no-ff":
        refs[target] = refs[source]
        return "Fast-forward"
    if mode == "ff-only":
        return "fatal: Not possible to fast-forward, aborting."
    name = f"M{len(parents) - 3}"
    parents[name] = [refs[target], refs[source]]
    refs[target] = name
    return "Merge made by the 'ort' strategy."

print(merge("main", "feature"))
print(refs["main"], len(parents))
output
Fast-forward
C4 4

The ref moved from C2 to C4 and the object count stayed at four. Nothing was created, which is what a fast-forward means.

One test decides it

If the current tip is an ancestor of the incoming tip, Git moves the ref and stops. If it is not, the histories have diverged and a real merge is required.

Choosing the shape: --ff-only and --no-ff

By default Git fast-forwards when it can and creates a merge commit when it must. That is convenient, but sometimes you want the outcome decided by you rather than by the state of the graph. Two flags take that decision away from the graph.

FlagBehaviourUse for
defaultfast-forwards when possible, merge commit otherwiseeveryday syncing
--ff-onlyfast-forwards or fails; never creates a commitscripts and CI gates
--no-ffalways creates a merge commit, even when a fast-forward is possiblekeeping the topic-branch bubble

git merge --ff-only feature is a safety flag. If the branches have diverged it refuses with a non-zero exit code and changes nothing, so a deploy script or CI gate fails loudly instead of quietly inventing a merge commit nobody reviewed. git merge --no-ff feature goes the other way. Even though a fast-forward is possible, Git writes a merge commit with two parents, and the shape of the feature branch stays visible in the graph.

python
refs["main"] = "C2"
print(merge("main", "feature", "no-ff"))
print(refs["main"], parents[refs["main"]], len(parents))

parents["C5"] = ["C2"]
refs["main"] = "C5"
print(merge("main", "feature", "ff-only"))
print(refs["main"], len(parents))

Continues the model above: first force a merge commit, then diverge main and try --ff-only

output
Merge made by the 'ort' strategy.
M1 ['C2', 'C4'] 5
fatal: Not possible to fast-forward, aborting.
C5 6

The first merge added one object, M1, whose parents are the old main tip and the feature tip. In the second part main has gained its own commit C5, so C5 is not an ancestor of C4. The --ff-only merge refuses, and main still points at C5 with no commit added.

What git merge feature does

Both choices have a price. A fast-forward gives a clean, linear history that is easy to read and to bisect, but it erases the fact that a branch ever existed. Once main and feature point at the same commit, nothing records where the work started or that it was done as a unit. --no-ff keeps that bubble, so git log --first-parent main shows one line per integrated branch, at the price of an extra commit for each one.

Fast-forward--no-ff
Shape of historyone straight lineline with a visible topic-branch bubble
Branch boundarylostkept as a merge commit with two parents
Extra commitsnoneone per merge
Best whensmall, linear changesa branch is a unit you may want to revert or review as a whole
Choose per branch

Decide for each kind of branch whether its boundary matters, then apply that rule consistently. A policy that changes with mood leaves a history with no reliable first-parent view.

Defaults, platform buttons and the quiet gotcha

You can stop thinking about the flags by setting them as configuration. merge.ff = only makes every git merge behave as if --ff-only were given, so no implicit merge commit can appear. pull.ff = only does the same for git pull, which is the safe default there. When your local branch and the remote have diverged, a plain pull would silently create a merge commit. With this setting it stops and makes you choose between merging and rebasing.

bash
git config --global merge.ff only   # no implicit merge commits
git config --global pull.ff only    # git pull refuses to invent history
git merge --no-ff feature           # still allowed: an explicit request

An explicit --no-ff on the command line overrides the config

Hosting platforms offer the same choice as buttons. On GitHub, Rebase and merge replays the branch's commits onto the target and Squash and merge collapses them into one commit. Both leave the target with a straight line of single-parent commits, so the target ref could have been fast-forwarded to the result. Create a merge commit writes a two-parent commit and does not give linear history.

GitHub buttonProducesLinear?
Rebase and mergethe branch's commits, replayedyes
Squash and mergeone commityes
Create a merge commita commit with two parentsno

Note that the two linear buttons do not literally fast-forward your branch. Rebase and merge creates new SHAs, so your local feature no longer matches what landed, and deleting it after the merge is the sensible habit. Only a true local git merge fast-forward keeps the original SHAs.

Common mistake: expecting merge machinery to run

A fast-forward silently discards nothing, but it also runs no merge driver and creates no merge commit. Hooks such as pre-merge-commit, prepare-commit-msg and commit-msg never fire, and any tooling keyed on two-parent commits sees nothing. post-merge still runs. If a policy check or changelog step depends on a merge commit existing, use --no-ff or move the check to a hook that fires either way.

Part 4 · Three-Way Merge: The Core Algorithm

Why a Merge Needs Three Inputs

A fast-forward only moves a pointer, and it works only when your current tip is an ancestor of the branch you are merging. Once both branches have commits since their merge base, the histories have diverged. Neither tip contains the other's work, so no pointer move can produce a state that holds both. Git has to build a new snapshot that reconciles two sets of edits. That job is the three-way merge.

Which integration does Git pick?

The name comes from the inputs. Git needs three trees, not two. BASE is the tree of the merge base, the best common ancestor of the two tips. OURS is the tree at the tip of the branch you are standing on. THEIRS is the tree at the tip of the branch you are merging in, which Git records as MERGE_HEAD while the merge runs.

InputTree comes fromMeaning
BASEgit merge-base main featureWhere the two lines of work last agreed
OURSHEADThe branch you are on
THEIRSMERGE_HEADThe branch you are merging in

Git finds BASE by walking the commit graph back from both tips until the paths meet. The cost is close to linear in the number of commits since the divergence, so a long-lived repository does not slow it down. What matters is how far the two branches have drifted apart.

bash
git merge-base main feature
git merge feature

The first command shows the fork point. The second starts the three-way merge.

The Per-Hunk Rule and Why BASE Matters

Git compares BASE to OURS and BASE to THEIRS, file by file. Inside each file it works hunk by hunk, where a hunk is a contiguous changed region. For each region it asks which sides moved away from BASE. Only one side moved, or both sides moved to the same result: there is nothing to decide. Both sides moved to different results: Git cannot choose for you.

Changed relative to BASEResult
Only oursTake ours
Only theirsTake theirs
Both, identicallyTake it once
Both, differentlyConflict

The rule is small enough to write out in a few lines. This toy version works per line instead of per hunk, but it makes the same decisions. Each line shows one of the four outcomes.

python
base   = ['host=a', 'port=80', 'retries=3', 'debug=false', 'mode=dev']
ours   = ['host=a', 'port=8080', 'retries=3', 'debug=true', 'mode=staging']
theirs = ['host=a', 'port=80', 'retries=5', 'debug=true', 'mode=prod']

def merge3(base, ours, theirs):
    merged = []
    for b, o, t in zip(base, ours, theirs):
        if o == t:
            merged.append(o)
        elif o == b:
            merged.append(t)
        elif t == b:
            merged.append(o)
        else:
            merged.append(f'CONFLICT: ours {o} / theirs {t}')
    return merged

for line in merge3(base, ours, theirs):
    print(line)

A line-by-line toy of the per-hunk rule

output
host=a
port=8080
retries=5
debug=true
CONFLICT: ours mode=staging / theirs mode=prod

Read the output top to bottom. host never changed, so it stays. port changed only on our side, so ours wins. retries changed only on their side, so theirs wins. debug changed to true on both sides, so it is taken once. mode changed to two different values, and that one line is the only conflict.

This is also why a two-way diff is not enough. Compare only OURS and THEIRS and every difference is ambiguous. If the two files differ by one line, either they added it or we deleted it, and the two cases look identical. BASE settles it, because the side that differs from BASE is the side that acted.

Line in OURSLine in THEIRSWithout BASEWith BASE containing the line
presentabsentCould be our addition or their deletionTheir deletion, so the line is removed
presentabsentSame pictureWith BASE lacking the line: our addition, so the line stays
BASE supplies intent

A diff only shows that two files differ. The merge base shows which side made the change, and that decides who wins.

The Merge Commit and Its Two Parents

When the merge finishes without conflicts, Git writes a merge commit whose tree is the reconciled snapshot. This commit has two parents. The first parent is the branch you were on, and the second parent is the branch you merged in. The order is fixed by where you stood when you ran git merge, so it is not cosmetic.

Merging feature into main
  1. 1On maintip becomes parent 1
  2. 2git merge featuretip becomes parent 2
  3. 3Merge commit Mnew tree, two parents
bash
git show HEAD^1   # the main side, where you were
git show HEAD^2   # the feature side, what you merged in

Selecting each parent of a merge commit

Because the first parent is always the branch you integrated into, following only first parents walks the history of that branch's integrations. The commits made on the feature branch are reachable through the second parent, and --first-parent skips them.

bash
git log --oneline --first-parent main
output
e41c9d0 Merge branch 'search-filters'
7b20af3 Merge branch 'login-fix'
1c5d8e2 Merge branch 'export-csv'

Illustrative output

Each line is one integration, and the intra-feature noise (wip commits, typo fixes) is hidden. This only works if you merge consistently into the same branch. If people merge main into feature branches the wrong way round, the first parent no longer means the same thing.

Common mistake: merging in the wrong direction

Running git merge main on a feature branch and later merging the feature into main makes the feature branch parent 1 of the first merge. History is still correct, but --first-parent on that branch now mixes in unrelated commits. Know which branch you are standing on before you merge.

Strategy, Criss-Cross Bases and Renames

The default merge strategy is ort, short for Ostensibly Recursive's Twin. It became the default in Git 2.34 and replaced recursive. It is faster, has fewer edge-case bugs, and handles renames better, so a plain git merge uses it with no flags.

Sometimes two branches have merged into each other in both directions. This is a criss-cross merge, and it leaves more than one best common ancestor. There is no single BASE to pick. Both ort and recursive solve it by first merging those bases with each other into a synthetic virtual base, then running the usual three-way merge against it.

Git does not store renames. A commit holds only trees, so when a file moves, Git sees one path deleted and another added. To make edits follow the file, the merge infers renames by comparing content: a deleted file and an added file that are at least 50% similar by default count as one rename. You can change that threshold, for example with -X find-renames=40%. Below the threshold, the edits on the other side will not follow the file.

bash
git merge -X find-renames=40% feature

Accept weaker matches as renames

Strategy options control what happens at conflicts, and they are easy to mix up with strategies. -X ours and -X theirs resolve only the conflicting hunks in favour of one side. Non-conflicting changes from both sides still merge normally. -s ours is a different thing: it takes our tree completely, discards everything from the other branch, and still records it as a second parent.

OptionScopeEffect
-X oursConflicting hunks onlyPrefer our side, still merge the rest
-X theirsConflicting hunks onlyPrefer their side, still merge the rest
-s oursThe whole mergeKeep our tree, drop their content, record the parent
Common mistake: using -s ours when you meant -X ours

-s ours throws away every change on the other branch. Afterwards, Git considers that branch merged, so a later merge will not bring the lost work back. If you only want to win the conflicts, use -X ours.

Overall, a merge costs roughly the number of changed files, each paying for its own diff. Finding the base is near-linear in the commits since divergence. So the price scales with the size of the branch, not with the size of the repository.

Part 5 · Rebase: Replaying Commits Onto a New Base

What rebase does to your commits

Suppose feature forked from main a while ago, and main has moved on since. Running git rebase main while on feature does not merge anything. Git first works out which commits belong only to your branch, which is the range main..feature. It then re-applies each of them, oldest first, on top of the current tip of main.

bash
git switch feature
git log --oneline main..feature   # the commits that will be replayed
git rebase main                 # replay them onto main's tip

The range before the rebase is the exact list of work that gets moved.

Internally this is a short, mechanical sequence. Git checks out the new base into a detached HEAD, so no branch moves yet. It then cherry-picks your commits one at a time, in order. Only when the last pick succeeds does it move the feature ref to the final result and reattach HEAD to it.

The result is a linear history. Your work now looks as if you had started it from the latest main, and no merge commit exists to record that the branches ever diverged.

Every replayed commit is a new commit

A commit's hash covers its tree, its parents, its author and its message. When a commit is replayed, its parent is now different, so its hash must be different. This holds even when the resulting tree is byte-identical. The old commits are not edited. They simply become unreachable from any branch, and they survive in the reflog until it expires. The tiny model below uses the string tree<-parent as a stand-in for a real hash.

python
def replay(base, trees):
    tip = base
    for t in trees:
        tip = f"{t}<-{tip}"
        print("  pick", t, "->", tip)
    return tip

print("original, based on T0:")
old = replay("T0", ["T2", "T3"])
print("rebased, based on T1:")
new = replay("T1", ["T2", "T3"])
print("same content:", old.split("<-")[0] == new.split("<-")[0])
print("same commit:", old == new)
output
original, based on T0:
  pick T2 -> T2<-T0
  pick T3 -> T3<-T2<-T0
rebased, based on T1:
  pick T2 -> T2<-T1
  pick T3 -> T3<-T2<-T1
same content: True
same commit: False
Common mistake

Thinking a rebase moves commits. It copies them. If a teammate already has your old commits, their copies and your new ones are unrelated to Git, even though the diffs match.

Transplanting a range with --onto

Plain git rebase main assumes the old base is also the upstream you name. The three-argument form separates the two ideas: git rebase --onto NEWBASE UPSTREAM BRANCH. Git replays the commits in UPSTREAM..BRANCH on top of NEWBASE. This is the tool for moving a branch off the wrong parent.

Say feat was cut from spike by mistake, and it must sit on main with only its own three commits. Here main is the new parent, spike is the old base that gets excluded, and feat is the branch being moved.

bash
git log --oneline spike..feat            # confirm: exactly 3 commits
git rebase --onto main spike feat
git push --force-with-lease              # feat has new SHAs now

Anything reachable from spike is left behind, so spike's own commits never come along.

ArgumentRoleIn the example
NEWBASEWhere the replayed commits landmain
UPSTREAMExcluded: only commits after it are movedspike
BRANCHThe branch whose ref is movedfeat
Common mistake

Swapping UPSTREAM and BRANCH, or forgetting UPSTREAM. Run git log --oneline UPSTREAM..BRANCH first. The list it prints is precisely what the rebase will replay.

Conflicts come once per replayed commit

A merge resolves all disagreements in a single step. A rebase performs one three-way merge for every commit it replays, so each commit can conflict on its own. If ten of your commits touch the same line that main also changed, you can be asked to resolve that same spot ten times.

Stopping, resolving and remembering

When a pick conflicts, the rebase halts in the middle of the replay. You fix the files, stage them with git add, and tell Git to carry on. Two other verbs let you leave the loop: --skip and --abort.

A rebase that stops on a conflict
CommandUse it whenWhat happens
git rebase --continueFiles fixed and stagedCommits the pick, moves to the next one
git rebase --skipThe current commit is not wantedDrops that commit from the replay
git rebase --abortYou want outRestores the exact pre-rebase state

Let Git remember your resolutions

git rerere stands for reuse recorded resolution. Once enabled, Git records how you resolved each conflict hunk. When the same hunk shows up again, it applies your earlier answer automatically. That turns the ten-conflict rebase into a single manual fix. The model below uses a simplified rule: the same spot is only asked about once.

bash
git config --global rerere.enabled true

Turn it on before the rebase that you expect to conflict repeatedly.

python
def conflicts(n, rerere):
    seen = set()
    asked = 0
    for _ in range(n):
        spot = "config.yml line 12"
        if rerere and spot in seen:
            continue
        asked += 1
        seen.add(spot)
    return asked

print("without rerere:", conflicts(10, False))
print("with rerere:", conflicts(10, True))
output
without rerere: 10
with rerere: 1

Pulling, cost and getting back out

Plain git pull fetches and then merges, which creates a small merge bubble whenever you have local commits and upstream moved. git pull --rebase fetches and then replays your local commits on top of the fetched upstream instead. That keeps history straight. To make it your default, set pull.rebase to true.

bash
git pull --rebase
git config --global pull.rebase true

What it costs

A rebase of n commits performs n cherry-picks, so the work grows linearly with the number of commits replayed. Each pick pays for a full three-way merge of that commit's changes. Ten commits is ten merges. This is also why a long branch with many conflicting commits feels slow and noisy to rebase.

Undoing a rebase

Because the old commits are only unreachable and not destroyed, you can always get back. The pre-rebase tip is recorded in the reflog as HEAD@{n}. For a rebase that already finished, ORIG_HEAD points at the tip from before it started.

SituationCommand
Rebase finished, you want the old branch backgit reset --hard ORIG_HEAD
ORIG_HEAD was overwritten by a later commandgit reflog, then git reset --hard HEAD@{n}
Rebase is still stopped on a conflictgit rebase --abort
bash
git reflog                      # find the entry just before the rebase
git reset --hard HEAD@{5}       # the pre-rebase tip
Common mistake

Running git reset --hard in the middle of a stopped rebase. Use git rebase --abort instead, because it restores the original branch and clears the half-finished state together.

In short

Rebase replays main..feature as brand-new commits on a new base, so history stays linear. The price is new SHAs, one possible conflict per commit, and the rule that the old tip stays recoverable only through the reflog.

Part 6 · Cherry-Pick and the Rebase Primitive

Cherry-pick: replaying one commit

Rebase can look like magic, but it is built from one small operation you can run by hand. git cherry-pick C takes the change that commit C introduced, meaning the diff between C and C's parent, and applies it on top of whatever HEAD is now. The result is a brand new commit with the same message and author but a different parent, and therefore a different SHA. Nothing about C itself moves or changes.

What one cherry-pick does
  1. 1Pick Cany commit, on any branch
  2. 2Diff C^ to Cthe change C introduced
  3. 3Apply onto HEADa three-way merge
  4. 4New commit C'new parent, new SHA
bash
git switch release/1.4
git cherry-pick 9f2a1c

Copy one fix from another branch onto the release branch

output
[release/1.4 4be1f07] Fix null check in parser
Date: Tue Oct 6 11:42:10 2026 +0530
1 file changed, 2 insertions(+), 1 deletion(-)

Notice the new SHA 4be1f07. The original 9f2a1c is untouched on its own branch. This is the identity rule from the commit graph at work: a commit's hash covers its tree, its parents and its metadata, so replaying it on a different parent can never give you the same hash.

Why a cherry-pick can conflict

It is tempting to think a cherry-pick pastes a patch, but Git does something smarter. It runs the same three-way merge machinery you met earlier, with the roles filled in a slightly unusual way. The picked commit's parent plays the common ancestor, your current commit is one side, and the picked commit is the other side.

RoleTree usedMeaning
BASEC's parentThe state C was written against
OURSHEADWhere you are replaying onto
THEIRSCThe state after the change you want

Because BASE is C's parent and not your HEAD, Git compares what C changed with what your branch has changed since then. If both touched the same lines differently, the per-hunk rule gives a conflict. If your branch has drifted far from where C was written, the pick may conflict even though the patch looks tiny.

Rebase is a loop over cherry-pick

A rebase checks out the new base as a detached HEAD, cherry-picks each commit of the range in order, then moves the branch ref to the final result. Every conflict you meet mid-rebase is a cherry-pick conflict, which is why the labels flip the same way.

Ranges, staging and tracking backports

Picking several commits

Give cherry-pick a range and it replays each commit in it, oldest first. The range follows the same two-dot rule as git log: A..B means commits reachable from B but not from A, so the left endpoint is excluded. If you want A itself, write A^..B.

Sometimes you want the combined effect of several commits but only as one commit on the target. The -n (--no-commit) flag applies each change to the index and working tree without committing, so you can pick as many as you like and then commit once.

bash
git cherry-pick main~4..main~1      # three commits, kept separate
git cherry-pick -n a1b2c3 d4e5f6    # staged only, nothing committed
git commit -m "Backport parser fixes"

Range, then a squash-style pick

Common mistake: the missing first commit

Writing git cherry-pick A..B and expecting A to come along. The left endpoint is excluded, so the oldest commit you wanted is silently left behind. Check the range first with git log --oneline A..B.

Leaving a trail with -x

After a cherry-pick, the new commit has no recorded link to its source. A reader of the release branch cannot tell where the fix came from. The -x flag appends a line to the message naming the original commit, which is how teams track what has been backported.

bash
git cherry-pick -x 9f2a1c
git log -1 --format=%B
output
Fix null check in parser

(cherry picked from commit 9f2a1c4e7b03d85a61f2c9e0b4d7a3185c6e2f90)

Use -x whenever the target branch is long-lived and shared, such as a release branch. Skip it for private experiments, since the reference points at a commit your colleagues may never see.

FlagWhat it doesReach for it when
(none)Pick and commit, original messageA quick local copy
-nStage only, no commitCombining picks into one commit
-xAppend the source SHA to the messageBackports to shared branches

Duplicates, patch-ids and revert

The duplicate-commit hazard

A cherry-pick copies a change but does not tell the graph that the two commits are related. Suppose you pick a fix from feature onto main, and later merge feature into main. The merge sees the fix twice: once as the original commit arriving on THEIRS and once as your copy already on OURS.

If both copies are identical and nothing around them has changed, the per-hunk rule takes the change once and all is quiet. But when either side has touched neighbouring lines in the meantime, the two versions no longer match exactly and you get a spurious conflict over a change you already have. The safest habit is to merge the source branch once rather than picking from it and merging it later.

Finding commits that are already upstream

Git can tell that your copy and the original are the same change even though their SHAs differ. Two commands expose this. git cherry lists the commits on your branch, marking those whose change is not yet upstream with + and those already present with -.

bash
git cherry -v main feature
git log --oneline --cherry-mark main...feature
output
- 9f2a1c Fix null check in parser
+ 3d8e5b Add retry to the fetcher
+ c07a41 Log the retry count

Here the first commit already exists on main as a cherry-picked copy, so it is marked -, while the other two still need to land. With --cherry-mark, equivalent commits get an = and unique ones a +.

Patch-id: how Git knows

The mechanism is the patch-id: a hash of a commit's diff after normalising it. Line numbers in the hunk headers and whitespace are ignored, so the same edit placed at a slightly different position, or with different indentation noise, hashes the same. Two commits with equal patch-ids are treated as the same change, regardless of parent, message or SHA.

Part of the commitCounts toward patch-id?
Changed lines and their contextYes
Line numbers in hunk headersNo
WhitespaceNo
Parent, author, date, messageNo

The equality is only as exact as the diff. If you resolved a conflict while picking, or the surrounding context differs, the patch-id will differ and Git will no longer see the two as twins.

Why re-rebasing a merged branch is harmless

By default, git rebase drops any commit whose patch-id already exists upstream. If your branch was already merged, or its commits were picked across, rebasing it again usually leaves nothing to replay and ends as a no-op. Pass --reapply-cherry-picks if you really want those commits kept.

Revert: the inverse operation

Cherry-pick adds a change; git revert C removes one. It computes the reverse of C's diff and commits it as a new commit on top of your branch. History grows by one commit instead of being rewritten, so no one else's copy of the branch is invalidated. That makes it the right tool for undoing something already published.

bash
git revert 3d8e5b
git revert -m 1 b7c9d2    # undo a merge commit, keep parent 1's side
Cherry-pickRevert
AppliesThe change C introducedThe opposite of that change
CreatesA new commitA new commit
Rewrites existing historyNoNo
Typical useBackport one fixUndo a public commit safely
Common mistake: undoing shared work with a rebase

Dropping a bad commit from a pushed branch with rebase or reset forces everyone else to repair their copies. If others may have pulled it, use git revert and let history record both the mistake and the fix.

Part 7 · Interactive Rebase: Rewriting Your Own History

The todo list and its verbs

A plain rebase replays your commits exactly as they were. Interactive rebase lets you edit the plan before the replay starts. Run git rebase -i HEAD~5 and Git opens your editor on a todo list of the last five commits. The list is ordered oldest first, the reverse of git log, because that is the order Git will replay them. Each line begins with a command verb, and changing that verb is how you tell Git what to do with the commit.

text
pick a1b2c3d add parser
pick c3d4e5f typo in parser
pick e5f6a7b fix retry bug
pick 1f2e3d4 wip
pick 9a8b7c6 add tokenizer tests

The todo list for git rebase -i HEAD~5. Git runs it from the top line down.

Git executes the list top to bottom once you save and close the editor. Because the list is the whole plan, you can reorder lines to reorder commits, delete a line to drop that commit, or change a verb to change its treatment. If the reordered commits touch the same lines, you may hit conflicts, which are resolved like any other rebase conflict.

VerbWhat happensReach for it when
pickKeep the commit as it isDefault; also the way to reorder
rewordKeep the change, open an editor for a new messageA typo in a subject line
editApply the commit, then stop and hand control backYou need to amend, split or test the commit
dropRemove the commit entirelyThe commit was a mistake or leftover debugging

pick is the verb you will leave on most lines, and it is also how reordering works: moving a pick line up or down moves the commit. reword replays the commit unchanged but pauses to let you rewrite its message. edit is the most powerful of the four: Git applies the commit and then stops, with the commit already in place as HEAD. You can amend it, add commits after it, or run the test suite. When you are done, git rebase --continue resumes the rest of the list.

Closing the editor does not cancel

Saving or closing the editor with the list unchanged does not abort anything. Git replays the list exactly as written, which for an all-pick list is a no-op that still takes a moment. The rebase only aborts when the todo list is empty, meaning every line is deleted (or the only line is noop). To cancel on purpose, delete every line, or run git rebase --abort if the rebase has already started.

Folding, halting and testing commits

The next group of verbs is about cleaning up a messy branch before review. squash folds a commit into the commit above it and opens an editor so you can combine both messages into one. fixup does the same fold but throws this commit's message away, which is what you want for "typo" and "wip" commits whose messages add nothing. In both cases the commit must sit directly below the commit it folds into, so you often reorder the line first.

VerbFolds into previous commit?Message
squashYesEditor opens with both messages to combine
fixupYesThis commit's message is discarded
exec cmdNo, it is not a commitRuns a shell command at that point
breakNo, it is not a commitStops unconditionally so you can look around

The last two verbs are not commits at all, they are instructions placed between commits. exec cmd runs a shell command after the line above it has been applied, and the rebase stops if the command fails. break stops at that point no matter what, which is handy for inspecting the tree, checking git status, or running something by hand before continuing.

text
pick a1b2c3d add parser
fixup c3d4e5f typo in parser
pick e5f6a7b fix retry bug
exec npm test
break
drop 1f2e3d4 wip
pick 9a8b7c6 add tokenizer tests

A todo list using fixup, exec, break and drop

You do not have to type exec lines yourself. git rebase -i --exec 'npm test' main inserts that command after every commit in the range. The rebase halts at the first commit where the command fails, so you learn exactly which commit broke the build instead of only knowing that the branch tip is red.

bash
git rebase -i --exec 'npm test' main
# tests run after EVERY replayed commit
# the rebase stops on the first commit that fails
# fix it, git commit --amend, then:
git rebase --continue

Splitting one commit into several

Sometimes a single commit does two jobs and reviewers would prefer two. Mark it edit, and when Git stops, undo the commit itself while keeping its changes in your working tree with git reset HEAD^. From there you build the pieces yourself, staging and committing one logical change at a time.

Splitting a commit
  1. 1Mark it editchange the verb in the todo list
  2. 2git reset HEAD^commit undone, files stay changed
  3. 3git add -p, git commitstage the first piece and commit it
  4. 4Repeat until cleanone commit per logical change
  5. 5git rebase --continuereplay the remaining commits

Autosquash, the root, and what gets rewritten

Review feedback without hand-editing the list

When a reviewer points out a problem in a commit from three commits ago, you can fix it with the fast workflow instead of hunting through the todo list. Make the fix and commit it with git commit --fixup=<sha>, naming the commit it belongs to. Git gives the new commit a subject beginning fixup! followed by the target's subject. Later, git rebase -i --autosquash main reads those subjects, moves each fixup directly under its target, and sets the verb to fixup for you. You only have to save the list.

bash
git commit --fixup=a1b2c3d
git rebase -i --autosquash main
# each fixup! commit is parked under its target
git config --global rebase.autoSquash true

The last line makes --autosquash the default for interactive rebases

Reaching the first commit

HEAD~5 can only reach commits that have a parent outside the range, so the very first commit of a repository is out of reach. git rebase -i --root removes that limit and lists every commit, including the root. You can then reword or split the initial commit, which is useful for removing a file that should never have been committed. It also rewrites the entire history of the branch.

Everything after the edit gets a new SHA

A commit's identity includes its parent, so changing one commit changes the SHA of every commit after it, even the ones you never touched. If you edit the third commit of five, the first two stay as they were while commits three, four and five are all new objects. The old ones remain reachable through the reflog until it expires. The depth of the rewrite is measured from your earliest edit, so editing a commit near the tip is cheap and editing one near the root is not.

Position in listBeforeAfter the edit at C
Aa1b2a1b2 (unchanged)
Bc3d4c3d4 (unchanged)
Ce5f67a8b (edited)
D1f2e9c0d (parent changed)
E9a8b3e4f (parent changed)

Stacked branches

If feat-b is built on top of feat-a, rebasing feat-b rewrites the commits that feat-a points at, and the feat-a branch is left pointing at the old, orphaned commits. Git 2.38 added --update-refs, which finds the branch pointers inside the range being rebased and moves them along with the replay. Git writes them into the todo list as update-ref lines so you can see what will move.

bash
git switch feat-b
git rebase -i --update-refs main
git config --global rebase.updateRefs true

The last line makes --update-refs the default

Rewriting commits that are already pushed

Everything on this page rewrites SHAs. Do it to commits only you have, and expect a push of an already-published branch to need --force-with-lease.

Remember

The todo list is the plan: reorder lines to reorder commits, change verbs to fold, split, stop or drop them, and expect every commit from your first edit onward to get a new SHA.

Part 8 · Conflicts: Anatomy and Resolution

What a conflict is: stages and markers

When Git cannot combine two edits on its own, it does not store a half-merged file. It records the disagreement in the index as several entries for the same path, called stages. Stage 1 holds the base version (the common ancestor), stage 2 holds ours, and stage 3 holds theirs. A normal, clean path has a single entry at stage 0.

You can see these entries with git ls-files -u, which lists only unmerged paths. Each line shows the file mode, the blob hash, the stage number and the path.

output
100644 3b18e5c0a1f4 1	src/app.ts
100644 7c9d02aa41be 2	src/app.ts
100644 e04a9917cd52 3	src/app.ts
StageHoldsComes from
0Normal, resolved entryA clean path, or a conflict you have staged
1BASEThe merge base, the common ancestor
2OURSThe side you are standing on
3THEIRSThe side being brought in

Staging the path with git add throws away stages 1 to 3 and writes one stage 0 entry. That collapse is exactly what Git means by resolved. The small model below mimics it with a dictionary keyed by path and stage.

python
index = {('src/app.ts', 1): '3b18', ('src/app.ts', 2): '7c9d', ('src/app.ts', 3): 'e04a'}

def conflicted(idx):
    return sorted({path for path, stage in idx if stage > 0})

print(conflicted(index))
index = {k: v for k, v in index.items() if k[0] != 'src/app.ts'}
index[('src/app.ts', 0)] = '51ad'
print(conflicted(index))

Staging replaces three stage entries with one stage 0 entry

output
['src/app.ts']
[]

Reading the conflict markers

For text files Git also writes the disagreement into the working-tree file. The part from <<<<<<< HEAD to ======= is ours, and the part from ======= to >>>>>>> branch is theirs. Resolving means editing the region down to the content you actually intend and deleting every marker line. The tiny merger below applies the per-line rule (take the changed side, conflict if both changed differently) and prints markers the way Git does.

python
base   = ['def total(a, b):', '    return a + b']
ours   = ['def total(a, b):', '    return a + b + TAX']
theirs = ['def total(a, b):', '    return round(a + b)']

def merge_line(b, o, t):
    if o == t:
        return [o]
    if o == b:
        return [t]
    if t == b:
        return [o]
    return None

out = []
for b, o, t in zip(base, ours, theirs):
    m = merge_line(b, o, t)
    if m is None:
        out += ['<<<<<<< HEAD', o, '||||||| base', b, '=======', t, '>>>>>>> feature']
    else:
        out += m
print('\n'.join(out))

Both sides changed the same line differently, so it conflicts

output
def total(a, b):
<<<<<<< HEAD
    return a + b + TAX
||||||| base
    return a + b
=======
    return round(a + b)
>>>>>>> feature
Show the base with diff3

The ||||||| section above only appears if you set git config --global merge.conflictStyle diff3 (or zdiff3, which also trims lines both sides share). Seeing the original text next to both edits tells you what each side meant to change, which is far easier than guessing from two finished versions.

Whose side is ours? Labels and structural conflicts

The words ours and theirs sound fixed, but they depend on the operation. During a merge, ours is the branch you are on and theirs is the branch you pulled in. During a rebase Git checks out the upstream first and then replays your commits onto it, so ours is the upstream you are replaying onto and theirs is your own commit. The labels invert, and that is the single most common reason people resolve a rebase backwards.

OperationOURS isTHEIRS is
mergeYour current branchThe incoming branch
rebaseThe upstream you replay ontoYour own commit being replayed
cherry-pickHEAD, where you are pickingThe commit being picked
Common mistake: --ours during a rebase

Running git checkout --ours file in the middle of a rebase keeps the upstream version and silently discards your own commit's change. If you meant to keep your work, you wanted --theirs. Check which operation is in progress before picking a side.

Content conflicts versus tree conflicts

A content conflict is two edits to the same lines, and it leaves markers you can edit. A tree conflict is about the structure of the repository rather than the text of a file: one side deleted or moved a path that the other side touched. Git has no markers to put anywhere, so the path just sits unmerged in the index and you must make an explicit decision.

ConflictWhat happenedHow you decide
modify/deleteOne side edited the file, the other deleted itKeep it with git add file, or accept the deletion with git rm file
rename/renameBoth sides renamed the same file to different namesPick one name, git rm the other path, then git add the survivor
rename/modifyOne side renamed the file, the other edited itGit often carries the edit to the new name; check the result, then git add
add/addBoth sides created the same path with different contentEdit the merged file by hand (it has markers) or choose a side, then git add
Common mistake: a blanket --theirs on a modify/delete

Taking one whole side for a modify/delete conflict can resurrect a file that was deliberately removed, or delete work someone edited. Look at both histories and choose with git rm or git add on purpose.

Inspecting, resolving and finishing

While you are stuck, a few commands let you take a whole side, rebuild damaged markers, or look at the state from different angles. Pick the tool by what you need.

CommandWhat it does
git checkout --ours fileOverwrites the working file with stage 2, the whole ours side
git checkout --theirs fileOverwrites the working file with stage 3, the whole theirs side
git checkout --merge fileRecreates the conflict markers if you overwrote or mangled them
git diffCombined diff against both parents; only still-conflicted hunks appear
git diff --ours / --theirs / --baseCompares the working file against a single stage
git diff --checkWarns about leftover conflict markers and whitespace errors

The bare git diff during a conflict uses a combined diff: two columns of plus and minus signs, one for each parent. Hunks that already match one parent disappear, so only the real disagreement is left on screen.

The resolve cycle

Editing the file does not resolve anything on its own. Git only treats a path as resolved once you stage it. After every conflicted path is staged you continue, or you give up and unwind.

Finishing a conflicted merge or rebase

Merge tools and remembered resolutions

git mergetool launches whichever three-pane tool you configured, showing base, ours and theirs alongside the result, and stages the file when you save. For conflicts that keep coming back, for example the same hunk during every step of a long rebase, turn on rerere (reuse recorded resolution) with git config --global rerere.enabled true. Git then remembers how you resolved each conflicting hunk and replays that answer automatically the next time the identical conflict appears.

Common mistake: committing the markers

Staging a file with <<<<<<< still inside it marks it resolved, because git add does not read the content. Run git diff --check before staging, or add a pre-commit hook that runs git diff --cached --check, so broken files never reach a commit.

Clean merges that are still broken, and generated files

The worst conflict is the one Git never reports. A semantic conflict happens when two changes touch different lines, so the merge completes without a single marker, yet the combined program no longer works. Suppose one branch renames calc_total to compute_total everywhere it exists, while another branch, started earlier, adds a new call to calc_total. Neither edit overlaps the other, so Git merges them happily, and the result calls a function that is gone.

python
# merged result: the rename landed, and so did the new call to the old name
def compute_total(items):
    return sum(items)

def report(items):
    return 'total=' + str(calc_total(items))

try:
    print(report([1, 2, 3]))
except NameError as err:
    print('broken after clean merge:', err)

No markers, no warning from Git, still broken

output
broken after clean merge: name 'calc_total' is not defined

Git compares text, not meaning, so it cannot see this. Only a build, a type checker or the test suite can. That is why you should run the tests after every merge or rebase, even when it finished without a conflict.

No conflict does not mean no problem

A conflict-free merge only proves the text edits did not overlap. Treat passing tests as the real signal that integration worked.

Generated files and custom merge drivers

Line-based merging makes no sense for lockfiles, compiled output or images: two valid versions merged line by line usually produce garbage. The .gitattributes file lets you tell Git to handle such paths differently, either by choosing a built-in behaviour or by naming a merge driver.

Line in .gitattributesEffect
*.png binaryNever attempt a text merge; a conflict means pick one whole file
*.lock merge=oursUse the driver named ours, which keeps the current side without conflict
package-lock.json merge=oursSame, for one specific generated file

The name ours here refers to a driver, and Git needs it defined before it works: run git config merge.ours.driver true. The driver then does nothing and keeps our version. For lockfiles the usual next step is to regenerate them with your package manager after the merge, so the file matches the merged manifest.

Part 9 · Merge vs Rebase: Head-to-Head

Same Start, Two Different Histories

Merge and rebase answer the same question: how do the commits from one branch end up on another? They answer it by editing the commit graph in opposite ways. Merge adds a commit and leaves everything that exists alone. Rebase replays your commits on a new base and abandons the originals. Almost every trade-off in this section follows from that one difference.

Take a concrete starting point. main has moved on with commit C while feature forked at B and gained D and E. Here is what each tool does with that fork.

Before: diverged
BranchCommits (oldest first)
mainA, B, C
featureA, B, D, E
After git merge
BranchCommits
mainA, B, C, D, E, plus M with parents C and E
After git rebase main
BranchCommits
featureA, B, C, D', E' in a straight line

History shape

A merge preserves the true branching topology. The graph still shows that feature forked at B, that work happened in parallel, and where the two lines rejoined. A rebase produces a straight line that never actually happened: D' and E' look as if they were written after C, though they were written before it existed. That is not a flaw. A clean line is easier to read, and a straight line is exactly what you want in a patch series. But you are choosing a story over a record.

SHAs: what changes and what does not

A commit's hash covers its tree, its parents, its metadata and its message. A merge creates exactly one new object, M, and every existing commit keeps its hash. A rebase gives every replayed commit a new parent, so every replayed commit gets a new SHA, even when the diff is byte-for-byte identical. The old commits are not edited. They simply become unreachable except through the reflog.

bash
git log --oneline feature      # before rebase
git rebase main
git log --oneline feature      # after rebase

Same messages, same diffs, different hashes

output
e5f6a7b add tests
d4c3b2a add parser
b0b0b0b shared base

7c8d9e0 add tests
1a2b3c4 add parser
c9c9c9c latest main work
b0b0b0b shared base

The two hashes d4c3b2a and 1a2b3c4 are different objects that carry the same change. Anyone who still has the old ones in their clone now holds commits your branch no longer contains. The next page covers what that does to a team.

The shipped code is the same

When there are no conflicts, merging feature into main and rebasing feature onto main produce the same final tree. The result is the same code in the same files. The difference lies entirely in what is recorded about how the code got there. You can check this directly by doing both on throwaway branches and comparing the tree hash that each tip points to.

bash
git switch -c try-merge main && git merge feature
git switch -c try-rebase feature && git rebase main
git rev-parse try-merge^{tree} try-rebase^{tree}
output
3f9a1c2d8e7b6a5409c1d2e3f4a5b6c7d8e9f001
3f9a1c2d8e7b6a5409c1d2e3f4a5b6c7d8e9f001
Choose on history, not on code

If there is no conflict, neither tool can ship different code. So the decision between them is never about the result. It is about what history you want to keep, and who else can already see your commits.

Safety, Conflicts and Effort

Safety on shared branches

Merge only ever adds objects, so a merge is always safe to push. Your branch tip is a descendant of the remote tip, which makes it an ordinary fast-forward push, and no collaborator's clone is disturbed. A rebase changes the hashes of commits that may already be on the server. After it, your local branch and the remote branch share no commits past the fork point, so a normal push is rejected and you need a force-push.

The force-push is not only an inconvenience for you. Every teammate who fetched the old commits now has local work built on hashes that no longer exist upstream. When they pull, Git sees two diverged histories containing the same changes under different names, and the result is duplicated commits and confusing conflicts. If you must rewrite a shared branch, use --force-with-lease so you get an error instead of silently overwriting a push you had not seen.

Conflict handling

A merge is a single three-way comparison between the two tips and their merge base. All the disagreements surface together, you resolve them once at the merge point, and you record the answer in one merge commit. A rebase is a loop of cherry-picks, and each one is its own three-way merge. If two of your commits touch the same lines that main changed, you can meet the same conflict once per replayed commit.

bash
git rebase main

Three commits on feature all edit config.yml

output
CONFLICT (content): Merge conflict in config.yml
error: could not apply 1a2b3c4... add parser
# resolve, git add config.yml, git rebase --continue
CONFLICT (content): Merge conflict in config.yml
error: could not apply 7c8d9e0... add tests
# resolve again, git add config.yml, git rebase --continue
CONFLICT (content): Merge conflict in config.yml
error: could not apply 5d6e7f8... fix typo

Enabling rerere lets Git remember a resolution and reapply it to the same hunk, which softens this a lot. Squashing the noisy commits first also helps, since fewer replayed commits means fewer chances to conflict. Merge avoids the problem outright: the same edits conflict once.

Effort

A merge is one operation regardless of branch size. Git finds the base, compares three trees, and writes one commit. Its cost grows with the number of changed files, not with how many commits led there. A rebase does a full three-way merge per replayed commit, so its cost grows linearly with the number of commits and the chance of interruptions grows with it.

MergeRebase
Existing commits on the branchUntouchedAll replayed with new SHAs
Push afterwardsNormal fast-forward pushForce-push required
Teammates with clonesUnaffectedTheir local history diverges
ConflictsResolved once, at the mergePossibly once per replayed commit
Work for a 30-commit branchOne operation30 cherry-picks
Common mistake: rebasing a branch someone else pulled

Rebasing is fine on commits only you have. Once a teammate has based work on your commits, rewriting them leaves their clone pointing at hashes that are gone. Merge instead, or agree on the rewrite first.

Traceability, Bisecting and Reverting

Traceability

A merge commit is a record of the integration itself. It says exactly when the branch joined, who performed the integration, and what the two parents were. That is useful when you later ask who brought a change into main and under which review. A rebase throws that event away. There is no commit that says the integration happened, and the committer dates of the replayed commits reflect the rebase, not the original work. Per-commit authorship survives, though: the author field is carried across each replay, so you still know who wrote each change.

Bisecting

git bisect does a binary search through history for the commit that introduced a bug. On a rebased, linear history this is clean: each step tests one commit with one parent, so the answer names a single change. On a merge-heavy history bisect still works, but the commit it finds can be a merge whose two parents were each fine on their own. The bug then comes from the combination, and the result has mixed causes that you have to untangle by hand.

There is a catch even on linear history. Every replayed commit is a newly created snapshot that might never have been built or tested in that exact combination. Bisect can land on a commit that fails only because of the rebase, not because of the original author's change.

Reverting

Reverting linear commits is straightforward: git revert <sha> applies the inverse diff as a new commit. A merge commit has two parents, so Git cannot tell which side you want to keep. You must name the mainline with -m 1, meaning keep the first parent, which is normally the branch you merged into.

bash
git revert -m 1 9a8b7c6           # undo the merge
git merge feature                 # try to bring it back later
output
[main 4e5f6a7] Revert "Merge branch 'feature'"
Already up to date.

That second line is the trap. The revert undid the changes, but the history still says the commits of feature were merged. The merge base now already contains them, so Git sees nothing left to merge. This is why a reverted merge poisons future re-merges of that branch. To bring the work back you have to revert the revert first, or recreate the commits under new hashes on a fresh branch.

Auditing what shipped

Following only the first parent of each commit on main shows the integration events and hides the work inside each branch. That gives a tidy list of what shipped and when, but only if every integration is a merge commit.

bash
git log --first-parent --oneline main
output
f1e2d3c Merge pull request #42 from feature/search
a7b8c9d Merge pull request #41 from fix/login
e0f1a2b Merge pull request #40 from feature/export

If the team mixes policies, some branches merged and some rebased, the same command gives a confusing list. Rebased commits appear individually, with no event marking where a feature arrived. The view is clean only under consistent use of merge commits. With a pure rebase workflow --first-parent has nothing to summarise, because there are no merge commits, and the plain linear log is already your audit trail.

Common mistake: reverting a merge without -m

git revert <merge> alone fails because Git cannot choose a parent. Use -m 1. Then remember that re-merging the same branch will do nothing until you revert the revert.

The Scorecard

Put the ten points side by side and a pattern appears. Merge is the tool that records what happened and never touches existing history. Rebase is the tool that edits the story to make it easier to read, at the price of new hashes and force-pushes. Neither changes the shipped code when there are no conflicts.

AxisMergeRebase
History shapeTrue topology with the fork visibleStraight line that never happened
Existing SHAsUntouchedEvery replayed commit rewritten
Shared branchAlways safe to pushNeeds force-push, breaks clones
ConflictsOnce, at the merge pointUp to once per commit
TraceabilityWho and when integratedPer-commit author only
BisectCan land on a merge with mixed causesUnambiguous on linear history
Revert-m 1, and re-merge is poisonedPlain revert per commit
--first-parent logClean and useful if used consistentlyNot applicable
EffortOne operationScales with commits replayed
Final treeIdenticalIdentical

Deciding between them

The first question is always whether anyone else can already see these commits. That one answer settles most cases.

Which tool for this branch?
Common mistake: expecting rebase to change the code

People sometimes rebase hoping to avoid a merge's effect on the files. Without conflicts both give the same tree, so the only thing you change is the recorded history. A broken result after either is a semantic conflict that tests must catch, not a flaw in the tool.

Remember

Merge records what happened. Rebase edits the story. The code is identical, so choose by who can already see the commits and what history you want to read later.

Part 10 · Squash, Merge, or Rebase: Choosing an Integration Policy

Squash merge: one commit per branch

Once you know how merge and rebase behave, the remaining question is team-level: when a branch is finished, which operation lands it on the target? Three policies cover almost every team: squash merge, merge commit, and rebase then fast-forward. We start with squash because it is the most popular button on hosting platforms and the one with the sharpest edge.

A squash merge takes everything a branch changed and records it as a single new commit on the target. The final diff is preserved exactly. The intermediate commits, with their messages, their order and their individual authors, are not part of the target's history at all.

What a squash merge does to a three-commit branch
  1. 1feature: F1, F2, F3WIP, typo fix, review feedback
  2. 2Take the combined diffmain's tip versus feature's tip
  3. 3Write one commit S on mainparent is main's old tip only
  4. 4F1, F2, F3 stay on the branchmain never learns they existed

On the command line the same thing is two steps, because --squash stages the combined result but leaves the commit to you. Notice that the resulting commit has one parent; nothing links it back to the feature branch.

bash
git switch main
git merge --squash feature
git commit -m "Add CSV export for reports"
git log --oneline --graph -3

Squash merge by hand

output
* 7d3e9a1 Add CSV export for reports
* c40b2f8 Fix timezone in report header
* 91a6d05 Bump dependencies

The trade-off is easiest to judge side by side. Everything on the left comes from main holding one commit per feature; everything on the right comes from the branch's inner history being thrown away.

GainCost
RevertOne atomic unit: reverting S removes the whole featureNone
Main historyClean, one readable line per featureNone
Messy WIP commitsHidden: 'fix', 'oops', 'wip 2' never reach mainNone
AuthorshipNoneCo-authors inside the branch collapse into one author
BisectNonegit bisect can only name the whole squash, not the step inside the feature that broke things

The hazard is subtle. Git recognises 'the same change' by its patch-id, a hash of the normalised diff. The squashed commit S contains the sum of F1, F2 and F3, so its patch-id matches none of them. Git therefore cannot tell that S already contains those changes. The branch's ancestry is also unchanged: feature still forks from the old base and S is not an ancestor of it.

If anyone keeps working on that branch and merges or rebases it into main again, Git replays or three-way merges edits that main already has in a different shape. Where the branch touched the same lines again, you get conflicts over work that is already landed. The cure is boring: delete the branch right after squashing it, and start the next piece of work from a fresh branch off main.

bash
git branch -d feature   # refuses: feature is not an ancestor of main
git branch -D feature   # correct after a squash; the work lives in S
git push origin --delete feature

Why -D is expected after a squash

Common mistake: reusing a squashed branch

You squash-merge feature, then keep committing on it and open a second PR. Git sees F1 to F3 as brand-new work and conflicts with the squash commit that already contains them. Delete the branch after the squash and branch again from main.

Merge commits and rebase then fast-forward

The merge-commit policy always integrates with a real two-parent commit, usually --no-ff. Every commit on the branch keeps its identity, and the merge commit records who integrated what and when. This is the right fit when the branch structure is itself information: long-lived teams, release branches, and any project where you want git log --first-parent to read as a list of integrations.

The rebase-then-fast-forward policy goes the other way. The author rebases the branch onto the current target, and the target pointer simply slides forward over those commits. There is no merge commit and no bubble, just a straight line where every commit was written, tested and reviewed on top of the latest main. It suits small teams and open-source patch series, where each commit must stand alone and be reviewable on its own.

bash
git switch feature
git rebase main            # replay your commits on main's tip
git switch main
git merge --ff-only feature # pointer moves; refuses if main moved again

Rebase then fast-forward

The --ff-only flag is the safety catch. If somebody else landed a commit while you were rebasing, the fast-forward is impossible and Git refuses instead of inventing a merge. You rebase again and retry.

The best-known compromise is rebase locally, merge publicly. Rebase is allowed only on your own unpushed work, where nobody can have built on it, to turn drafts into a tidy story. Once commits are shared, the only integration is a merge, which rewrites nothing.

Put the three policies next to each other and the right choice usually falls out of the team's shape rather than of taste.

PolicyFitsHistory shapePer-commit value
Merge commitLong-lived teams, release branchesTrue topology with visible bubblesKept, plus a record of the integration
Rebase then fast-forwardSmall teams, patch seriesOne straight lineEvery commit must stand alone
Squash mergePR-per-feature, messy WIPOne line, one commit per featureDiscarded; the PR is the unit
Which one is it for your team?

Ask what the unit of review is. If reviewers read commit by commit, rebase and fast-forward. If they read the whole PR, squash. If the branch itself is a meaningful event, merge.

Workflows and branch lifetime

A policy only works if it matches the branching model around it. Trunk-based development keeps everyone integrating into one main line in small, frequent steps, with branches that live hours or a day or two. That pairs naturally with rebase or squash: the branches are so short that rewriting them costs almost nothing, and the payoff is a main line that reads as a sequence of small, complete changes.

Git Flow is the opposite. It has permanent branches (develop, release, main) and work moves between them in batches. The model depends on merge commits to carry a release from develop into a release branch and then into main, and to record that a hotfix was merged back. Squash or rebase there would destroy the very relationships the model is built from.

Trunk-basedGit Flow
Branch lifetimeHours to a couple of daysWeeks to months for develop and release
Natural integrationRebase or squashMerge commits between branches
Main historyLinearTopology is the record
WhySmall steps, always releasableBatches move between stages

Branch lifetime matters even more than the operation you choose. A branch that lives for weeks and is kept current by frequent rebases makes you resolve the same conflict again on every replayed commit and again at every rebase, and each rebase rewrites the commits others may have seen. A branch kept current by periodic merges from main pays differently: one merge per sync, each conflict resolved once and recorded in the merge commit, so the cost is amortised.

Strategy for a long branchConflict costSide effect
Frequent rebases onto mainSame conflicts, repeatedly (rerere helps)Every commit gets a new SHA, so force-pushes
Periodic merges from mainEach conflict resolved once, amortisedMerge commits clutter the branch
Short branchesFew conflicts to begin withNone worth naming

If you must pick between the first two, pick merges. If you can, pick neither: split the work, hide unfinished behaviour behind a flag, and integrate in small slices.

Choosing an integration policy
Common mistake: keeping a feature branch alive for weeks

Rebasing it every morning feels tidy but forces the same conflicts to be resolved again and again. Break the work into short branches instead of choosing a better way to sync a long one.

Make the policy mechanical

A policy that lives in a wiki page will be broken on the first busy Friday. The reliable way to hold a policy is to make the wrong action impossible, or at least loud, in the settings and the CI pipeline. Which setting you reach for depends on the policy you chose.

PolicyServer-side settingLocal or CI setting
Rebase or squash (linear main)Branch protection with 'require linear history'; allow only squash or rebase buttonsgit config pull.ff only, CI check that main has no merge commits
Merge commitsAllow only 'Create merge commit' on develop and release branchesgit config merge.ff false on integration branches
Any policyProtect main and release branches from force-push and direct pushesRequired status checks before merging
bash
# linear-history policy
git config --global pull.ff only
git config --global merge.ff only

# merge-commit policy, inside the repo that owns develop
git config merge.ff false

# CI guard: fail if main gained a merge commit
test -z "$(git rev-list --merges origin/main~20..origin/main)"

Configuration that backs each policy

Pick one direction per branch. 'Require linear history' rejects merge commits outright, while merge.ff = false guarantees one for every merge. They express opposite policies, so a given branch should have only one of them.

Common mistake: mixing buttons without noticing

A repo that enables all three merge buttons ends up with a blend of squashes, replayed commits and merge bubbles. Bisect, revert and first-parent logs then behave differently depending on who clicked what. Enable only the buttons your policy uses.

The rulebook in one breath

Squash for one-feature PRs and messy WIP, rebase then fast-forward when every commit must stand alone, merge commits when branch structure matters. Rebase your own unpushed work, merge what is public, keep branches short, delete them after squashing, and let branch protection enforce the choice.

Part 11 · The Golden Rule and Force-Push Safety

The Golden Rule and Why Pushes Break

Rebase never edits a commit. It builds new commits with the same changes on a new parent and then moves your branch name onto them. That is harmless while the old commits exist only in your repository. It becomes a problem once someone else holds those old commits. The golden rule of rebasing is: never rebase commits that exist outside your repository and that others may have based work on. Everything else in this section follows from that rule.

The rule follows from the identity rule you saw earlier. A commit's SHA covers its tree, its parents and its metadata. Replaying a commit onto a new base changes the parent, so the SHA changes even when the diff is byte-identical. A teammate who branched from your old commit now has a parent that your rewritten history does not contain.

What a rebase of a pushed branch does
  1. 1You push featureorigin/feature = C3
  2. 2You rebase onto mainC1' C2' C3' are new SHAs
  3. 3Old C1 C2 C3 are orphaned locallystill on the remote
  4. 4Push is rejectednon-fast-forward

The push fails because a normal push is only allowed to fast-forward the remote branch. The remote tip must be an ancestor of what you are sending. After a rebase, your tip descends from the new base, not from the old remote tip. Past the point where the two histories diverged, your branch and the remote branch share no commits at all. The server sees two unrelated lines and refuses to choose between them.

bash
git switch feature
git rebase main
git push origin feature

A routine rebase of a branch that was already pushed

output
 ! [rejected]        feature -> feature (non-fast-forward)
error: failed to push some refs to 'origin'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.

The hint wording is misleading here. Your branch is not behind, it has been rewritten. Git cannot tell the difference between a rewrite and real divergence. The usual advice, "pull and merge first", is the wrong fix after a rebase. It would merge your old commits back in next to their rewritten copies and duplicate every change.

Common mistake: pulling to fix the rejection

After a rebase, a plain git pull merges the old remote commits into your rewritten branch. Each change now appears twice, once under the old SHAs and once under the new ones. Decide first whether the rewrite was meant to be published. If it was, force-push safely. If it was not, reset to the remote tip.

Force-Push Flags: From Reckless to Safe

If the rewrite is intentional, the push has to replace the remote branch. Git offers three guards of increasing strength. git push --force overwrites the remote unconditionally. It does not check what is there, so it also destroys a commit a teammate pushed 30 seconds ago, a commit you have never fetched and never saw. Nothing warns you and nothing is left in your reflog, because those commits were never in your repository.

FlagGuardFailure it prevents
--forcenone at allnothing; silent overwrite
--force-with-leaseremote tip must equal the value you last fetchedoverwriting pushes you have not seen
--force-with-lease --force-if-includeslease, plus your local branch must have integrated that remote tipa background fetch making a stale lease look current

--force-with-lease turns a silent overwrite into an error. Git remembers the remote tip you last fetched, stored in origin/feature. At push time it tells the server: replace the branch only if it still points at that SHA. If a teammate pushed in the meantime, the remote tip differs from your recorded value and the push is refused.

bash
git push --force-with-lease origin feature

Teammate pushed after your last fetch

output
 ! [rejected]        feature -> feature (stale info)
error: failed to push some refs to 'origin'
What --force-with-lease decides

The lease has one hole. Many editors and tools run git fetch in the background. If that fetch pulls in your teammate's new commit, origin/feature is updated and now matches the remote. Your lease looks current, yet you have never looked at that commit, and your rebased branch does not contain it. The push then succeeds and deletes their work.

--force-if-includes, available from Git 2.30, closes this hole. It is used together with --force-with-lease. Before pushing, it checks the reflog of your local branch to confirm that the remote-tracking tip was actually integrated into it, by a merge, rebase or commit. A tip that arrived only through a background fetch fails that check, and the push is refused.

bash
git push --force-with-lease --force-if-includes origin feature

The safe default for publishing a rewrite

Keep the lease implicit

--force-if-includes only works when --force-with-lease has no explicit expected value. Do not write --force-with-lease=feature:abc123 if you want the extra check. Make the long form a shell alias, because the lease only protects you if you actually use it.

Common mistake: reaching for --force

--force is what most search results suggest, and it is the one flag with no guard. If a push is rejected after a rebase, pick --force-with-lease, not --force.

When Someone Else Force-Pushed, and Merge Topology

Sometimes you are the teammate on the receiving end. Someone rebased and force-pushed feature while you had local commits on top of the old version. Your local branch, here called local-work, still descends from the old commits. A normal git pull would merge those old commits back in next to the rewritten ones.

The fix is to replay only your own commits onto the new remote tip. git rebase --onto does exactly that. Its three arguments are the new base, the old upstream whose commits should be left behind, and the branch to move. The old upstream is the remote tip as it was before the rewrite. After a fetch, the remote-tracking ref's reflog still holds it as origin/feature@{1}.

bash
git fetch origin
git reflog show origin/feature      # find the pre-rewrite tip
git rebase --onto origin/feature origin/feature@{1} local-work

Move only your own commits onto the rewritten branch

If you have nothing local worth keeping, skip the replay. git fetch followed by git reset --hard origin/feature makes your branch identical to the remote one. Only do this once you are sure there are no unpushed commits or uncommitted edits, because the reset discards them.

Your situationRecovery
local commits on top of the old branchgit fetch, then git rebase --onto origin/feature <old-upstream> local-work
nothing local to keepgit fetch, then git reset --hard origin/feature
not sure what you havegit log origin/feature@{1}..local-work lists your own commits first

A second trap appears when the branch you rebase contains merge commits. By default a rebase drops them and replays only the non-merge commits in a straight line, so the structure of the branch is flattened. Any conflict resolutions recorded inside those merges are lost, and the topology disappears. Add --rebase-merges and Git recreates the merge commits on the new base, so the shape of the branch survives.

bash
git rebase --rebase-merges main

Replays the merge topology instead of flattening it

Common mistake: rebasing a branch you did not build linearly

A branch that someone merged main into several times looks tidy after a default rebase, but its merges are gone and their conflict fixes may be redone by hand. Before rebasing, look at git log --graph --oneline. If you see merge bubbles, use --rebase-merges or do not rebase.

Enforcing the Rule: Where Rebase Is Fine

A convention such as "nobody force-pushes main" works until someone is tired, in a hurry or following a bad suggestion. The only enforceable form of the golden rule is server-side protection. Shared branches like main, develop and release/* should reject non-fast-forward updates no matter who pushes. Hosting platforms offer this as a branch-protection rule that blocks force pushes and deletions. A self-hosted server can use receive.denyNonFastForwards.

bash
# on a self-hosted bare repository
git config receive.denyNonFastForwards true

Reject every non-fast-forward push, whoever sends it

Protection is deliberately blunt: it covers shared branches and leaves everything else free. Rewriting history is a normal tool on branches that nobody else builds on. Personal feature branches and unpushed work are fair game. You can rebase them, squash them and reorder their commits as often as you like, and publish the result with --force-with-lease --force-if-includes.

One exception is common in practice. A PR branch that only you work on is conventionally rebasable even after you push it, because nobody has based work on it. The risk is on the reviewer's side. Their comments are attached to the old commits, and a force-push can orphan those comments or make "changes since my last review" useless. Warn reviewers before you rewrite, and prefer adding --fixup commits during review, then rebasing once at the end.

Can I rebase this branch?
BranchRebase?Publishing the result
unpushed local workfreelynothing to publish yet
personal feature branchfreely--force-with-lease --force-if-includes
PR branch only you touchyes, after warning reviewers--force-with-lease --force-if-includes
shared feature branchonly by agreement with everyone using itteammates recover with rebase --onto
main, develop, release/*neverrejected by server-side protection
The whole rule in one line

Rebase what only you can see, and make the server refuse everything else. When the rewrite is yours to make, publish it with --force-with-lease --force-if-includes so a teammate's push becomes an error and not a loss.

Part 12 · Common Mistakes and How to Undo Them

Rewriting History and Reading Conflicts Wrongly

Most Git disasters share one root: a command did something other than what the person typing it pictured. Rebase and merge are safe when you know whose commits you are moving and which side of a conflict is which. This page covers the first four mistakes, which are about shared history and about conflict resolution. The two after that are about getting out of a mess.

Mistake 1: Rebase a shared branch, then force-push

A rebase gives every replayed commit a new SHA. If teammates already built on the old commits, your force-push replaces the remote branch with one that shares no commits with theirs. Their next pull sees two unrelated histories, and anyone who pushes afterwards can silently erase your version or theirs. Plain --force offers no protection, so a push made thirty seconds earlier by someone else simply disappears.

Common mistake

Rebasing main, develop or any branch a teammate has pulled, then running git push --force. The rebase is fine locally; the force-push is what destroys other people's work.

There are two fixes. The first is social: tell the team before you rewrite, wait until they have pushed, and use --force-with-lease so the push errors instead of overwriting. The second is to not rewrite at all. git revert adds a new commit that undoes the old one, so every existing SHA stays valid and nobody has to recover.

Rewrite (rebase + force-push)Revert
Existing SHAsAll replacedUntouched
Teammates mustFetch and rebase --ontoJust pull
Risk of lost workHighNone
Use on shared branchesOnly with coordinationAlways safe

Mistake 2: Resolving a rebase conflict backwards

During a rebase Git replays your commits onto the upstream tip, so ours is the upstream side you are building on and theirs is your own commit being replayed. In a merge it is the opposite. People who keep the wrong block here usually delete their own work or resurrect something upstream removed on purpose.

The fix is to stop guessing which block is which. Turn on the diff3 style (or zdiff3, which trims common lines) so the conflict shows the original text from the merge base between the two sides. Compare each side against the base: whichever side differs from it is the side that made a change, and that change is what you must preserve.

bash
git config --global merge.conflictStyle zdiff3

Set once, globally. Every later conflict gains a base section.

text
<<<<<<< HEAD
timeout = 30
||||||| parent of 4be2f1a (raise timeout)
timeout = 10
=======
timeout = 60
&gt;&gt;&gt;&gt;&gt;&gt;&gt; 4be2f1a (raise timeout)

During a rebase the top block is upstream. The base shows 10, so upstream moved to 30 and your commit moved to 60.

Mistake 3: Committing conflict markers

If you run git add . without reading the files, the marker lines go into a commit, and the build breaks or, worse, a config file quietly contains <<<<<<< text. Two cheap checks catch it. git diff --check reports leftover markers and whitespace errors in what you are about to commit, and a pre-commit hook can refuse the commit outright.

bash
# .git/hooks/pre-commit
git diff --cached --check || exit 1
git diff --cached | grep -n '^+<<<<<<< ' && exit 1
exit 0

The second grep looks only at added lines, so a doc that quotes markers on purpose in context lines does not trip it.

Mistake 4: Using --theirs on a modify/delete conflict

A modify/delete conflict is structural: one side edited a file, the other deleted it. The index holds the base and the surviving side but nothing for the deleting side, so there is no stage for --ours or --theirs to pick. git checkout --theirs file either errors out or leaves you with nothing useful, and no markers exist to edit. Decide the file's fate explicitly instead.

DecisionCommandResult
Accept the deletiongit rm path/to/fileFile removed, conflict resolved
Keep the edited filegit add path/to/fileSurviving version staged
Common mistake

Assuming every conflict has marker text. Check git status for 'deleted by us' or 'deleted by them' and resolve those paths with git rm or git add.

Aborting Badly and Getting Work Back

Mistake 5: reset --hard in the middle of a rebase

When a rebase gets ugly the instinct is git reset --hard. That moves the branch pointer and wipes the working files, but it does not tell Git the rebase is over. The .git/rebase-merge directory stays on disk, so git status still reports a rebase in progress, your HEAD is detached somewhere in the middle of the replay, and the next git rebase --continue can resume a half-finished operation. git rebase --abort deletes that state and restores the original branch to exactly where it was before you started.

Common mistake

Bailing out of a rebase with git reset --hard. Always use git rebase --abort, and git merge --abort for a merge.

Mistake 6: Lost commits after a bad rebase

A rebase never edits old commits. It builds new ones and moves the branch, so the originals are only unreachable, not gone. The reflog records every position of HEAD and each branch tip for about ninety days. Read it, find the entry from just before the rebase began, and point the branch back at it.

bash
git reflog
# a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature
# 9f8e7d6 HEAD@{7}: checkout: moving from main to feature
git reset --hard HEAD@{7}

Pick the entry just before the first rebase line, not the last one.

If the commits are older than the reflog, or the reflog entry was expired, they may still exist as dangling objects until garbage collection. git fsck --lost-found lists unreachable commits, and you can inspect each with git show and then create a branch on the one you want.

Mistake 7: A merge is committed and wrong

Which undo you use depends on one question: has anyone else got the merge commit? Resetting rewrites history, which is fine on your own machine and harmful once the merge is on the remote.

Undoing a wrong merge

ORIG_HEAD is where your branch pointed before the merge, so the reset puts it back. Another merge, pull or rebase overwrites ORIG_HEAD, so if you have done other things since, use the reflog. For a pushed merge, -m 1 tells revert to treat the first parent, the branch you merged into, as the mainline, which makes the revert undo what the other branch brought in.

bash
git reset --hard ORIG_HEAD      # unpushed
git revert -m 1 3c4d5e6         # already pushed

Traps After Reverts and Squashes

Mistake 8: Re-merging a branch after reverting its merge

Reverting a merge makes a new commit that removes the branch's changes from the tree, but the history still records that the branch's commits were merged. When you merge the branch again, Git computes the merge base, finds that all its commits are already ancestors, and concludes there is nothing to bring in. You get 'Already up to date' while the code is still missing. Only commits added to the branch after the original merge will come through.

Why the second merge brings back nothing
  1. 1Merge featurefeature commits become ancestors of main
  2. 2Revert the mergetree loses the changes, history keeps the ancestry
  3. 3Merge feature againmerge base already includes every feature commit
  4. 4Nothing to dothe changes stay out

The fix is to revert the revert. That commit re-applies the branch's changes as a normal new commit. Merge the branch again afterwards to pick up anything added to it since.

bash
git revert 7a8b9c0   # the earlier revert commit
git merge feature    # now only the newer work arrives

Find the revert commit's SHA with git log --oneline.

Mistake 9: Squash-merging, then continuing on the same branch

A squash merge lands one brand-new commit on the target with the branch's combined diff. Git does not record that the branch's original commits were merged, so the branch still looks unmerged. If you keep committing on it and merge again, Git sees the old commits as new work, replays their changes on top of the squashed copy, and you get duplicated hunks and conflicts in code you already shipped.

Common mistake

Reusing a feature branch after its squash merge. The branch and the squashed commit describe the same changes with different identities.

Treat a squashed branch as finished. Delete it and start the next piece of work from the updated target.

bash
git switch main && git pull
git branch -D feature
git switch -c feature-part-2

-D is required because Git cannot see that a squashed branch was merged.

Day-to-Day Friction With Rebase and Pull

Mistake 10: Rebasing with a dirty working tree

Rebase rewrites the files in your working tree commit by commit, so uncommitted edits could be overwritten. Git refuses with an error about unstaged changes. The opposite surprise happens when auto-stash is on without you remembering: Git stashes, rebases, then pops, and the pop can conflict long after you thought the rebase was clean. Either commit or stash deliberately before you start, or opt in on purpose.

bash
git stash push -m "wip before rebase"
git rebase main
git stash pop

git config --global rebase.autoStash true

The last line makes the stash and pop automatic for every rebase.

Mistake 11: Solving the same conflict on every commit

A rebase resolves conflicts once per replayed commit. If ten commits touch the same lines, you can face the same conflict ten times. Git's rerere feature (reuse recorded resolution) remembers how you resolved a conflict hunk and applies the same answer automatically when it recurs. It only helps if it is on before the first resolution, since it cannot learn from fixes you made earlier.

bash
git config --global rerere.enabled true

Turn it on before starting a long rebase.

Mistake 12: git pull creating surprise merge commits

git pull is a fetch plus a merge. When you have local commits and the remote has moved, that merge makes a commit with the message 'Merge branch main of origin' every time you sync, cluttering history with no real integration meaning. Choose what you want pull to do and configure it once.

SettingBehaviour when histories divergePick it when
pull.rebase = trueReplays your local commits on top of the remote tipYou want linear history and your unpushed commits are yours
pull.ff = onlyRefuses and stops, so you choose merge or rebase yourselfYou want no history created without a decision
bash
git config --global pull.rebase true
# or
git config --global pull.ff only

The twelve at a glance

MistakeFix
Rebased a shared branchCoordinate, or git revert instead
Ours/theirs invertedzdiff3 and read the base section
Committed conflict markerspre-commit hook and git diff --check
--theirs on modify/deletegit rm or git add explicitly
reset --hard mid-rebasegit rebase --abort
Lost commits after rebasereflog then reset --hard, or fsck --lost-found
Wrong merge committedreset --hard ORIG_HEAD, or revert -m 1 if pushed
Re-merge after revertRevert the revert, then merge
Squashed, then reused branchDelete and re-branch
Dirty tree during rebaseCommit or stash, or rebase.autoStash
Same conflict repeatedrerere.enabled before starting
Pull invents merge commitspull.rebase or pull.ff = only
Recovery reflex

Before you panic, run git reflog. Committed work is rarely lost within ninety days; only uncommitted changes can be destroyed for good.

Part 13 · Decision Rules & Cheat Sheet

Choose by Who Can See the Commits

Every integration question reduces to one test: can anyone else already see these commits? If they can, you add history and never rewrite it. If they cannot, the history is still yours, and you can tidy it before it is published. The flowchart shows the first cut. The numbered table below it is the full rulebook, and the rest of this section refers to its rows as Rule 1 to Rule 11.

First cut: shared or private?
RuleSituationReach for
1Not diverged, you want it ingit merge --ff-only
2Diverged, branch is shared or pushedgit merge
3Local branch behind main, nothing pushedgit rebase main
4Messy WIP commits before a PRgit rebase -i
5Review feedback on an old commitgit commit --fixup=<sha> then git rebase -i --autosquash
6Need one commit from another branchgit cherry-pick -x <sha>
7Undo something already publicgit revert, never rebase
8Branch built on the wrong basegit rebase --onto correct-base wrong-base branch
9Same conflict on many replayed commitsrerere.enabled true before you start
10Unsure which side is whichmerge.conflictStyle zdiff3, set once
11Something went wronggit reflog, then git reset --hard <good-sha>

Rule 1: nothing to reconcile, so move the pointer

When your current tip is an ancestor of the branch you are merging, there is no divergence. Git only moves the branch ref forward. No new object is created, no conflict is possible, and history stays a straight line. Use --ff-only so that the command fails loudly instead of quietly inventing a merge commit if the branch turns out to have diverged.

bash
git switch main
git merge --ff-only feature   # main jumps to feature's tip, zero new objects

Rule 2: both sides have commits and others can see them

If both branches gained commits and the branch is shared or already pushed, run a plain git merge. It builds a merge commit with two parents and never touches an existing SHA, so nobody who already fetched the branch is disturbed. This is the always-safe default for any history other people depend on.

Recipes for Rules 3 to 8

The next group of rules applies to commits that are still private, or to cases where you need one specific change instead of a whole branch. Most of them are a single command, so the flow below shows the order they usually come in on a feature branch.

Typical private-branch lifecycle
  1. 1Rule 4squash WIP with rebase -i
  2. 2Rule 5fixup + autosquash after review
  3. 3Rule 3rebase main for a linear PR
  4. 4Rule 1maintainer fast-forwards it in

Rule 3: local branch behind main, nothing pushed

Nobody else has your commits, so replaying them onto the latest main costs nothing and gives reviewers a clean, linear PR. The replayed commits get new SHAs, which is harmless while they exist only on your machine.

bash
git switch feature
git rebase main

Rule 4: tidy messy WIP before opening the PR

Run an interactive rebase over your own commits. Mark noise as fixup (fold in, discard the message) or squash (fold in, keep the message), and use reword to rewrite the commit message of a commit you are keeping.

bash
git rebase -i main
# pick   a1b2 add parser
# fixup  c3d4 typo
# reword e5f6 bad subject

Rule 5: review feedback on a specific old commit

Make the fix, record it as a fixup that points at the commit it belongs to, then let --autosquash move it under its target. You skip the manual reordering in the todo list.

bash
git commit --fixup=a1b2c3
git rebase -i --autosquash main

Rule 6: you need one commit, not a branch

Cherry-pick copies just that change as a new commit. The -x flag appends the original SHA to the message, so a backport can be traced later. Do not merge a whole branch to get one fix: you would pull in everything else on it.

bash
git cherry-pick -x 9f2a1c

Rule 7: undo something that is already public

A revert adds a new commit that applies the inverse diff. History only grows, so it is safe on a shared branch. Rebasing the public branch to delete the commit would rewrite SHAs that other people have already built on.

bash
git revert 4c1b0e
git revert -m 1 7d8e9f   # reverting a merge commit: keep parent 1

Rule 8: the branch was built on the wrong base

Suppose feat was cut from spike by mistake and must sit on main with only its own commits. The three-argument form of --onto handles that. The arguments read as the correct base, the wrong base to exclude, and the branch being moved. In template form it is git rebase --onto correct-base wrong-base branch.

ArgumentIn this exampleRole
correct-basemainnew parent for the replayed commits
wrong-basespikeold base, its commits are excluded
branchfeatthe branch being moved
bash
git log --oneline spike..feat           # confirm: exactly your 3 commits
git rebase --onto main spike feat
git push --force-with-lease             # only if feat was already pushed

Every replayed commit gets a new SHA, so check the range first.

Configure Once, Recover Fast, Remember One Line

The last rules are about settings and escape hatches. Set them one time and the awkward moments get much less frequent.

Rule 9: repeated conflicts across replayed commits

A rebase resolves conflicts once per replayed commit, so a ten-commit branch can show you the same conflict ten times. Turn on rerere (reuse recorded resolution) before you start, and Git replays your earlier answer for each repeat of the same hunk.

Rule 10: which side is which

During a rebase the labels invert: upstream is ours and your own commit is theirs. Instead of reasoning about that each time, ask Git to print the common ancestor inside the markers. With the base visible, you can see what each side changed.

bash
git config --global rerere.enabled true
git config --global merge.conflictStyle zdiff3
git config --global rebase.autoStash true
git config --global pull.rebase true

Configure once, globally.

Rule 11: the recovery reflex

When a rebase, reset or merge goes wrong, do not panic and do not start retyping. The reflog records every move of HEAD and of branch tips, so the old tip is still there. Look first, then reset to the good SHA. Anything committed in roughly the last 90 days can be found this way. Only uncommitted work can be truly destroyed.

bash
git reflog                         # find the entry, e.g. HEAD@{5}
git reset --hard HEAD@{5}          # second, never first
Common mistake

Running git reset --hard before reading the reflog, or in the middle of a rebase. Use git rebase --abort to leave a rebase, and look at git reflog before any hard reset.

Common mistake

Using Rule 3 on a branch that is already pushed. The rebased tip no longer descends from the remote tip, so you are pushed into a force-push. Use Rule 2 instead, or tell everyone who has the branch.

Rule 12: the whole handbook in one line

One-line summary

Merge records what happened; rebase edits the story; fast-forward means there was no story to reconcile. Choose by who else can already see the commits.

ToolWhat it does to historySafe on shared commits?
merge --ff-onlymoves a pointer, creates nothingyes
mergeadds a merge commityes
revertadds an inverse commityes
rebase, rebase -irewrites every replayed SHAno, private commits only

Part 14 · Check yourself

Quiz

Each question describes a situation you could meet on a real branch. Work out the answer before you open it, then compare your reasoning with the explanation.

You are on feature and run git rebase main. It stops with a conflict in app.py. You decide to keep your own version of the file and run git checkout --theirs app.py. Which version do you end up with, and why does it surprise people?
  • You get the version from your own commit being replayed, which is what you wanted here.
  • During a rebase the labels invert. OURS is the upstream (main) you are replaying onto, and THEIRS is your own commit.
  • In a plain git merge the same flag would give you the incoming branch, so the same flag means the opposite thing.
  • Set merge.conflictStyle = zdiff3 and read the base section instead of guessing which side is which.
git switch feature
git rebase main
# CONFLICT in app.py
git checkout --theirs app.py   # your commit's version
git add app.py
git rebase --continue
main is at C2. feature is C2, C3, C4. A teammate then pushes C5 to main, so main is C2, C5. You run git checkout main && git merge --ff-only feature. What happens?
  • It fails with an error and changes nothing, and no merge commit is created.
  • A fast-forward needs the current tip (C5) to be an ancestor of feature. It is not, because the histories diverged at C2.
  • git merge-base --is-ancestor main feature would exit non-zero, which is the test that decides this.
  • Plain git merge feature would have made a three-way merge commit with BASE = C2, OURS = C5 and THEIRS = C4.
  • To keep history linear you would first run git rebase main on feature, then fast-forward.
You squash-merged feature into main on Monday and kept committing on feature. On Friday you merge feature into main again and get conflicts in files you only touched on Monday. What went wrong, and what should you have done?
  • The squash commit on main has only one parent, and feature was never recorded as merged. The merge base is still the old fork point.
  • Git therefore replays all of the Monday changes against the squashed copy of the same lines, and both sides changed those regions, so they conflict.
  • The squashed commit's patch-id does not match any of the individual commits, so Git cannot recognise them as already applied.
  • Fix: delete the branch right after a squash merge and cut a fresh branch from main for the next piece of work.
  • If it has already happened, rebase feature onto the new main with --onto, excluding the old commits, or cherry-pick only the new ones.
You run git rebase -i HEAD~5 and mark only the third commit (counting oldest first) as edit. You amend it and continue. How many of the five commits have a new SHA afterwards, and what does the answer tell you about the cost of old edits?
  • Three commits change: the edited one and the two after it.
  • The first two are replayed unchanged on the same parents, so they keep their SHAs (Git can even fast-forward over them).
  • Commits four and five are untouched in content, but their parent SHA changed, so their hashes change too.
  • Rewrite depth is measured from the earliest edit to the tip, so editing an old commit rewrites everything after it.
  • If another branch was stacked on this one, use --update-refs (Git 2.38+) so its pointer follows the rewritten commits.
A bad merge M was pushed to main, so you ran git revert -m 1 M. A week later the feature is fixed and you run git merge feature again. The merge reports Already up to date or brings back only the new fixes. Why is the original feature code missing?
  • The merge base now counts every commit from feature that M brought in as already merged.
  • The revert was a new commit that removed the changes, but it did not remove the ancestry. Git sees no work to bring over.
  • Fix: revert the revert first, which restores the original changes, and then merge the fixed branch.
  • The alternative is to rebase or recreate the feature commits as new commits with new SHAs and merge those.

Summary

  • Integration is graph surgery plus tree comparison: a commit is a full snapshot with parents, so changing its parent always changes its SHA.
  • Fast-forward is pure pointer movement when your tip is an ancestor of theirs, so it can never conflict. Use --ff-only to refuse anything else.
  • A three-way merge compares BASE, OURS and THEIRS per hunk and makes a two-parent commit, so it never rewrites history.
  • Rebase and interactive rebase replay commits as new ones, so use them only on commits that nobody else has built on, and push with --force-with-lease.
  • During a rebase OURS and THEIRS swap, so set zdiff3, enable rerere, and remember that tests are the only defense against semantic conflicts.
  • Delete a branch after squashing it, and use git revert instead of rewriting anything already public.
  • When something goes wrong, read git reflog first, then git reset --hard to the good SHA. Merge records what happened, and rebase edits the story.