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.
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.
$ 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.
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.
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.
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.
$ cat .git/refs/heads/main
a1b2c3d4e5f60718293a4b5c6d7e8f9012345678
$ cat .git/HEAD
ref: refs/heads/main
$ git switch --detach HEAD~2
$ cat .git/HEAD
4c1b0e7d93a2f5586b1c0d4e7a9f3b26d8c15e70Attached HEAD names a branch; detached HEAD holds a SHA directly.
| Branch ref | HEAD (attached) | HEAD (detached) | |
|---|---|---|---|
| Lives in | .git/refs/heads/<name> | .git/HEAD | .git/HEAD |
| Contains | One commit SHA | ref: refs/heads/<name> | One commit SHA |
| Moves when you commit | Yes, if HEAD names it | No, the branch moves | Yes, HEAD itself moves |
| Cost to create | O(1), one tiny file | Not created | Not 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.
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.
| Parents | Kind | Notes |
|---|---|---|
| 0 | Root commit | The first commit of a history. A repository can have several. |
| 1 | Normal commit | Linear history, one step from its parent. |
| 2 or more | Merge commit | Joins histories. HEAD^1 is the branch you were on, HEAD^2 the one merged in. |
* 9c1e4d2 (HEAD -> main) Merge branch 'feature'
|\
| * 5b7a3f0 (feature) add retry
| * 2d8c611 add parser
* | 3f6e2b8 update docs
|/
* e41f0aa fix typo
* 7a90b3c initFrom 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.
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.
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.
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.
| Syntax | Meaning | Example use |
|---|---|---|
| main..feature | Reachable from feature but not from main | git log main..feature lists exactly what feature adds |
| feature..main | Reachable from main but not from feature | What you are missing, i.e. how far behind you are |
| main...feature | Symmetric difference: reachable from either, but not both | Both sides' divergences, e.g. git log --left-right main...feature |
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.
$ 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 parserEach 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.
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.
- 1C1root
- 2C2main points here
- 3C3feature work
- 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.
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.
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))Fast-forward
C4 4The ref moved from C2 to C4 and the object count stayed at four. Nothing was created, which is what a fast-forward means.
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.
| Flag | Behaviour | Use for |
|---|---|---|
| default | fast-forwards when possible, merge commit otherwise | everyday syncing |
--ff-only | fast-forwards or fails; never creates a commit | scripts and CI gates |
--no-ff | always creates a merge commit, even when a fast-forward is possible | keeping 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.
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
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.
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 history | one straight line | line with a visible topic-branch bubble |
| Branch boundary | lost | kept as a merge commit with two parents |
| Extra commits | none | one per merge |
| Best when | small, linear changes | a branch is a unit you may want to revert or review as a whole |
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.
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 button | Produces | Linear? |
|---|---|---|
| Rebase and merge | the branch's commits, replayed | yes |
| Squash and merge | one commit | yes |
| Create a merge commit | a commit with two parents | no |
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.
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.
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.
| Input | Tree comes from | Meaning |
|---|---|---|
| BASE | git merge-base main feature | Where the two lines of work last agreed |
| OURS | HEAD | The branch you are on |
| THEIRS | MERGE_HEAD | The 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.
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 BASE | Result |
|---|---|
| Only ours | Take ours |
| Only theirs | Take theirs |
| Both, identically | Take it once |
| Both, differently | Conflict |
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.
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
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 OURS | Line in THEIRS | Without BASE | With BASE containing the line |
|---|---|---|---|
| present | absent | Could be our addition or their deletion | Their deletion, so the line is removed |
| present | absent | Same picture | With BASE lacking the line: our addition, so the line stays |
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.
- 1On maintip becomes parent 1
- 2git merge featuretip becomes parent 2
- 3Merge commit Mnew tree, two parents
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.
git log --oneline --first-parent maine41c9d0 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.
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.
git merge -X find-renames=40% featureAccept 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.
| Option | Scope | Effect |
|---|---|---|
-X ours | Conflicting hunks only | Prefer our side, still merge the rest |
-X theirs | Conflicting hunks only | Prefer their side, still merge the rest |
-s ours | The whole merge | Keep our tree, drop their content, record the parent |
-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.
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.
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)
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
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.
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.
| Argument | Role | In the example |
|---|---|---|
| NEWBASE | Where the replayed commits land | main |
| UPSTREAM | Excluded: only commits after it are moved | spike |
| BRANCH | The branch whose ref is moved | feat |
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.
| Command | Use it when | What happens |
|---|---|---|
git rebase --continue | Files fixed and staged | Commits the pick, moves to the next one |
git rebase --skip | The current commit is not wanted | Drops that commit from the replay |
git rebase --abort | You want out | Restores 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.
git config --global rerere.enabled trueTurn it on before the rebase that you expect to conflict repeatedly.
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))
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.
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.
| Situation | Command |
|---|---|
| Rebase finished, you want the old branch back | git reset --hard ORIG_HEAD |
| ORIG_HEAD was overwritten by a later command | git reflog, then git reset --hard HEAD@{n} |
| Rebase is still stopped on a conflict | git rebase --abort |
git reflog # find the entry just before the rebase git reset --hard HEAD@{5} # the pre-rebase tip
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.
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.
- 1Pick Cany commit, on any branch
- 2Diff C^ to Cthe change C introduced
- 3Apply onto HEADa three-way merge
- 4New commit C'new parent, new SHA
git switch release/1.4
git cherry-pick 9f2a1cCopy one fix from another branch onto the release branch
[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.
| Role | Tree used | Meaning |
|---|---|---|
| BASE | C's parent | The state C was written against |
| OURS | HEAD | Where you are replaying onto |
| THEIRS | C | The 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.
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.
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
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.
git cherry-pick -x 9f2a1c git log -1 --format=%B
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.
| Flag | What it does | Reach for it when |
|---|---|---|
| (none) | Pick and commit, original message | A quick local copy |
-n | Stage only, no commit | Combining picks into one commit |
-x | Append the source SHA to the message | Backports 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 -.
git cherry -v main feature
git log --oneline --cherry-mark main...feature- 9f2a1c Fix null check in parser
+ 3d8e5b Add retry to the fetcher
+ c07a41 Log the retry countHere 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 commit | Counts toward patch-id? |
|---|---|
| Changed lines and their context | Yes |
| Line numbers in hunk headers | No |
| Whitespace | No |
| Parent, author, date, message | No |
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.
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.
git revert 3d8e5b git revert -m 1 b7c9d2 # undo a merge commit, keep parent 1's side
| Cherry-pick | Revert | |
|---|---|---|
| Applies | The change C introduced | The opposite of that change |
| Creates | A new commit | A new commit |
| Rewrites existing history | No | No |
| Typical use | Backport one fix | Undo a public commit safely |
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.
pick a1b2c3d add parser
pick c3d4e5f typo in parser
pick e5f6a7b fix retry bug
pick 1f2e3d4 wip
pick 9a8b7c6 add tokenizer testsThe 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.
| Verb | What happens | Reach for it when |
|---|---|---|
pick | Keep the commit as it is | Default; also the way to reorder |
reword | Keep the change, open an editor for a new message | A typo in a subject line |
edit | Apply the commit, then stop and hand control back | You need to amend, split or test the commit |
drop | Remove the commit entirely | The 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.
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.
| Verb | Folds into previous commit? | Message |
|---|---|---|
squash | Yes | Editor opens with both messages to combine |
fixup | Yes | This commit's message is discarded |
exec cmd | No, it is not a commit | Runs a shell command at that point |
break | No, it is not a commit | Stops 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.
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 testsA 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.
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.
- 1Mark it editchange the verb in the todo list
- 2git reset HEAD^commit undone, files stay changed
- 3git add -p, git commitstage the first piece and commit it
- 4Repeat until cleanone commit per logical change
- 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.
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 list | Before | After the edit at C |
|---|---|---|
| A | a1b2 | a1b2 (unchanged) |
| B | c3d4 | c3d4 (unchanged) |
| C | e5f6 | 7a8b (edited) |
| D | 1f2e | 9c0d (parent changed) |
| E | 9a8b | 3e4f (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.
git switch feat-b git rebase -i --update-refs main git config --global rebase.updateRefs true
The last line makes --update-refs the default
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.
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.
100644 3b18e5c0a1f4 1 src/app.ts 100644 7c9d02aa41be 2 src/app.ts 100644 e04a9917cd52 3 src/app.ts
| Stage | Holds | Comes from |
|---|---|---|
| 0 | Normal, resolved entry | A clean path, or a conflict you have staged |
| 1 | BASE | The merge base, the common ancestor |
| 2 | OURS | The side you are standing on |
| 3 | THEIRS | The 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.
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
['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.
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
def total(a, b): <<<<<<< HEAD return a + b + TAX ||||||| base return a + b ======= return round(a + b) >>>>>>> feature
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.
| Operation | OURS is | THEIRS is |
|---|---|---|
| merge | Your current branch | The incoming branch |
| rebase | The upstream you replay onto | Your own commit being replayed |
| cherry-pick | HEAD, where you are picking | The commit being picked |
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.
| Conflict | What happened | How you decide |
|---|---|---|
| modify/delete | One side edited the file, the other deleted it | Keep it with git add file, or accept the deletion with git rm file |
| rename/rename | Both sides renamed the same file to different names | Pick one name, git rm the other path, then git add the survivor |
| rename/modify | One side renamed the file, the other edited it | Git often carries the edit to the new name; check the result, then git add |
| add/add | Both sides created the same path with different content | Edit the merged file by hand (it has markers) or choose a side, then git add |
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.
| Command | What it does |
|---|---|
git checkout --ours file | Overwrites the working file with stage 2, the whole ours side |
git checkout --theirs file | Overwrites the working file with stage 3, the whole theirs side |
git checkout --merge file | Recreates the conflict markers if you overwrote or mangled them |
git diff | Combined diff against both parents; only still-conflicted hunks appear |
git diff --ours / --theirs / --base | Compares the working file against a single stage |
git diff --check | Warns 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.
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.
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.
# 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
broken after clean merge: name 'calc_total' is not definedGit 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.
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 .gitattributes | Effect |
|---|---|
*.png binary | Never attempt a text merge; a conflict means pick one whole file |
*.lock merge=ours | Use the driver named ours, which keeps the current side without conflict |
package-lock.json merge=ours | Same, 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.
| Branch | Commits (oldest first) |
|---|---|
| main | A, B, C |
| feature | A, B, D, E |
| Branch | Commits |
|---|---|
| main | A, B, C, D, E, plus M with parents C and E |
| Branch | Commits |
|---|---|
| feature | A, 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.
git log --oneline feature # before rebase git rebase main git log --oneline feature # after rebase
Same messages, same diffs, different hashes
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.
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}
3f9a1c2d8e7b6a5409c1d2e3f4a5b6c7d8e9f001 3f9a1c2d8e7b6a5409c1d2e3f4a5b6c7d8e9f001
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.
git rebase main
Three commits on feature all edit config.yml
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.
| Merge | Rebase | |
|---|---|---|
| Existing commits on the branch | Untouched | All replayed with new SHAs |
| Push afterwards | Normal fast-forward push | Force-push required |
| Teammates with clones | Unaffected | Their local history diverges |
| Conflicts | Resolved once, at the merge | Possibly once per replayed commit |
| Work for a 30-commit branch | One operation | 30 cherry-picks |
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.
git revert -m 1 9a8b7c6 # undo the merge git merge feature # try to bring it back later
[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.
git log --first-parent --oneline mainf1e2d3c 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.
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.
| Axis | Merge | Rebase |
|---|---|---|
| History shape | True topology with the fork visible | Straight line that never happened |
| Existing SHAs | Untouched | Every replayed commit rewritten |
| Shared branch | Always safe to push | Needs force-push, breaks clones |
| Conflicts | Once, at the merge point | Up to once per commit |
| Traceability | Who and when integrated | Per-commit author only |
| Bisect | Can land on a merge with mixed causes | Unambiguous on linear history |
| Revert | -m 1, and re-merge is poisoned | Plain revert per commit |
--first-parent log | Clean and useful if used consistently | Not applicable |
| Effort | One operation | Scales with commits replayed |
| Final tree | Identical | Identical |
Deciding between them
The first question is always whether anyone else can already see these commits. That one answer settles most cases.
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.
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.
- 1feature: F1, F2, F3WIP, typo fix, review feedback
- 2Take the combined diffmain's tip versus feature's tip
- 3Write one commit S on mainparent is main's old tip only
- 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.
git switch main git merge --squash feature git commit -m "Add CSV export for reports" git log --oneline --graph -3
Squash merge by hand
* 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.
| Gain | Cost | |
|---|---|---|
| Revert | One atomic unit: reverting S removes the whole feature | None |
| Main history | Clean, one readable line per feature | None |
| Messy WIP commits | Hidden: 'fix', 'oops', 'wip 2' never reach main | None |
| Authorship | None | Co-authors inside the branch collapse into one author |
| Bisect | None | git 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.
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
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.
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.
| Policy | Fits | History shape | Per-commit value |
|---|---|---|---|
| Merge commit | Long-lived teams, release branches | True topology with visible bubbles | Kept, plus a record of the integration |
| Rebase then fast-forward | Small teams, patch series | One straight line | Every commit must stand alone |
| Squash merge | PR-per-feature, messy WIP | One line, one commit per feature | Discarded; the PR is the unit |
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-based | Git Flow | |
|---|---|---|
| Branch lifetime | Hours to a couple of days | Weeks to months for develop and release |
| Natural integration | Rebase or squash | Merge commits between branches |
| Main history | Linear | Topology is the record |
| Why | Small steps, always releasable | Batches 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 branch | Conflict cost | Side effect |
|---|---|---|
| Frequent rebases onto main | Same conflicts, repeatedly (rerere helps) | Every commit gets a new SHA, so force-pushes |
| Periodic merges from main | Each conflict resolved once, amortised | Merge commits clutter the branch |
| Short branches | Few conflicts to begin with | None 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.
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.
| Policy | Server-side setting | Local or CI setting |
|---|---|---|
| Rebase or squash (linear main) | Branch protection with 'require linear history'; allow only squash or rebase buttons | git config pull.ff only, CI check that main has no merge commits |
| Merge commits | Allow only 'Create merge commit' on develop and release branches | git config merge.ff false on integration branches |
| Any policy | Protect main and release branches from force-push and direct pushes | Required status checks before merging |
# 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.
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.
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.
- 1You push featureorigin/feature = C3
- 2You rebase onto mainC1' C2' C3' are new SHAs
- 3Old C1 C2 C3 are orphaned locallystill on the remote
- 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.
git switch feature git rebase main git push origin feature
A routine rebase of a branch that was already pushed
! [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.
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.
| Flag | Guard | Failure it prevents |
|---|---|---|
--force | none at all | nothing; silent overwrite |
--force-with-lease | remote tip must equal the value you last fetched | overwriting pushes you have not seen |
--force-with-lease --force-if-includes | lease, plus your local branch must have integrated that remote tip | a 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.
git push --force-with-lease origin featureTeammate pushed after your last fetch
! [rejected] feature -> feature (stale info)
error: failed to push some refs to 'origin'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.
git push --force-with-lease --force-if-includes origin featureThe safe default for publishing a rewrite
--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.
--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}.
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 situation | Recovery |
|---|---|
| local commits on top of the old branch | git fetch, then git rebase --onto origin/feature <old-upstream> local-work |
| nothing local to keep | git fetch, then git reset --hard origin/feature |
| not sure what you have | git 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.
git rebase --rebase-merges mainReplays the merge topology instead of flattening it
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.
# on a self-hosted bare repository
git config receive.denyNonFastForwards trueReject 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.
| Branch | Rebase? | Publishing the result |
|---|---|---|
| unpushed local work | freely | nothing to publish yet |
| personal feature branch | freely | --force-with-lease --force-if-includes |
| PR branch only you touch | yes, after warning reviewers | --force-with-lease --force-if-includes |
| shared feature branch | only by agreement with everyone using it | teammates recover with rebase --onto |
| main, develop, release/* | never | rejected by server-side protection |
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.
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 SHAs | All replaced | Untouched |
| Teammates must | Fetch and rebase --onto | Just pull |
| Risk of lost work | High | None |
| Use on shared branches | Only with coordination | Always 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.
git config --global merge.conflictStyle zdiff3Set once, globally. Every later conflict gains a base section.
<<<<<<< HEAD timeout = 30 ||||||| parent of 4be2f1a (raise timeout) timeout = 10 ======= timeout = 60 >>>>>>> 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.
# .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.
| Decision | Command | Result |
|---|---|---|
| Accept the deletion | git rm path/to/file | File removed, conflict resolved |
| Keep the edited file | git add path/to/file | Surviving version staged |
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.
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.
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.
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.
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.
- 1Merge featurefeature commits become ancestors of main
- 2Revert the mergetree loses the changes, history keeps the ancestry
- 3Merge feature againmerge base already includes every feature commit
- 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.
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.
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.
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.
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.
git config --global rerere.enabled trueTurn 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.
| Setting | Behaviour when histories diverge | Pick it when |
|---|---|---|
| pull.rebase = true | Replays your local commits on top of the remote tip | You want linear history and your unpushed commits are yours |
| pull.ff = only | Refuses and stops, so you choose merge or rebase yourself | You want no history created without a decision |
git config --global pull.rebase true # or git config --global pull.ff only
The twelve at a glance
| Mistake | Fix |
|---|---|
| Rebased a shared branch | Coordinate, or git revert instead |
| Ours/theirs inverted | zdiff3 and read the base section |
| Committed conflict markers | pre-commit hook and git diff --check |
| --theirs on modify/delete | git rm or git add explicitly |
| reset --hard mid-rebase | git rebase --abort |
| Lost commits after rebase | reflog then reset --hard, or fsck --lost-found |
| Wrong merge committed | reset --hard ORIG_HEAD, or revert -m 1 if pushed |
| Re-merge after revert | Revert the revert, then merge |
| Squashed, then reused branch | Delete and re-branch |
| Dirty tree during rebase | Commit or stash, or rebase.autoStash |
| Same conflict repeated | rerere.enabled before starting |
| Pull invents merge commits | pull.rebase or pull.ff = only |
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.
| Rule | Situation | Reach for |
|---|---|---|
| 1 | Not diverged, you want it in | git merge --ff-only |
| 2 | Diverged, branch is shared or pushed | git merge |
| 3 | Local branch behind main, nothing pushed | git rebase main |
| 4 | Messy WIP commits before a PR | git rebase -i |
| 5 | Review feedback on an old commit | git commit --fixup=<sha> then git rebase -i --autosquash |
| 6 | Need one commit from another branch | git cherry-pick -x <sha> |
| 7 | Undo something already public | git revert, never rebase |
| 8 | Branch built on the wrong base | git rebase --onto correct-base wrong-base branch |
| 9 | Same conflict on many replayed commits | rerere.enabled true before you start |
| 10 | Unsure which side is which | merge.conflictStyle zdiff3, set once |
| 11 | Something went wrong | git 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.
git switch main
git merge --ff-only feature # main jumps to feature's tip, zero new objectsRule 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.
- 1Rule 4squash WIP with rebase -i
- 2Rule 5fixup + autosquash after review
- 3Rule 3rebase main for a linear PR
- 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.
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.
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.
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.
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.
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.
| Argument | In this example | Role |
|---|---|---|
| correct-base | main | new parent for the replayed commits |
| wrong-base | spike | old base, its commits are excluded |
| branch | feat | the branch being moved |
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.
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.
git reflog # find the entry, e.g. HEAD@{5} git reset --hard HEAD@{5} # second, never first
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.
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
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.
| Tool | What it does to history | Safe on shared commits? |
|---|---|---|
merge --ff-only | moves a pointer, creates nothing | yes |
merge | adds a merge commit | yes |
revert | adds an inverse commit | yes |
rebase, rebase -i | rewrites every replayed SHA | no, 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 mergethe same flag would give you the incoming branch, so the same flag means the opposite thing. - Set
merge.conflictStyle = zdiff3and 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 featurewould exit non-zero, which is the test that decides this.- Plain
git merge featurewould 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 mainonfeature, 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
mainhas only one parent, andfeaturewas 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
mainfor the next piece of work. - If it has already happened, rebase
featureonto the newmainwith--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
featurethatMbrought 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-onlyto 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, enablererere, and remember that tests are the only defense against semantic conflicts. - Delete a branch after squashing it, and use
git revertinstead of rewriting anything already public. - When something goes wrong, read
git reflogfirst, thengit reset --hardto the good SHA. Merge records what happened, and rebase edits the story.