Branching, rebasing and conflicts

Merge versus rebase, interactive rebase, cherry-pick, conflict resolution, and the force-push rule.

8 min read🐙 Git, Maven and Gradle

Merge and rebase both end with your work on top of everyone else's. That is why the argument about them never resolves: people compare the result, where there is little to choose, instead of comparing what each one does, where they are not alike at all.

This lesson is the difference, the conflict markers both of them produce, and the one rule about force-pushing that separates a safe workflow from an incident.

Merge and rebase answer different questions

Take the smallest possible disagreement. Two people change the same line of the same file:

plaintext
$ git merge feature
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
Automatic merge failed; fix conflicts and then commit the result.

A merge keeps both histories exactly as they happened and adds one new commit with two parents. Nothing is rewritten. The graph gains a fork and a join, and the record shows that two lines of work existed at the same time — because they did.

A rebase does something different in kind. It takes your commits, sets them aside, moves your branch to the tip of the other one, and replays each of your changes there. The result is a straight line. But the commits at the end are not the commits you started with: same changes, same author, new parents, therefore new hashes.

Both leave the same files on disk. One preserves what happened; the other tells a tidier story about it. That is the whole trade, and it is why the rule below matters so much.

A conflict is smaller than it looks

Open the file after that failed merge and Git has written both versions into it:

plaintext
<<<<<<< HEAD
timeout = 10
=======
timeout = 60
>>>>>>> feature
retries = 3

Notice retries = 3. Both branches have it, both agree, and it sits calmly outside the markers. Git only conflicts the lines that actually disagree. A "conflicted file" is never the whole file, and the fix is almost never as large as the red text suggests.

git status marks the state with two letters rather than one:

plaintext
$ git status --short
UU config.txt

UU is unmerged on both sides. It is not M, because there is no single version to compare against yet — that is precisely what you are being asked to supply.

Resolving means: edit the file until it says what it should say, delete the three marker lines, then git add it. The add is the part people forget, and it is what tells Git the conflict is settled — which is consistent with everything in the previous lesson: you are putting the resolved content into the index so the next commit can contain it.

If it is going badly, nothing is stuck. git merge --abort puts everything back exactly as it was.

During a rebase, HEAD is not your branch

Now the same conflict, produced by rebasing instead of merging. This is where a lot of otherwise confident people pick the wrong side, and it is worth seeing exactly why.

plaintext
$ git switch feature
$ git rebase main
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
Could not apply 27a6467... Raise the timeout

And the markers:

plaintext
<<<<<<< HEAD
timeout = 10
=======
timeout = 60
>>>>>>> 27a6467 (Raise the timeout)
retries = 3

Read those carefully against the merge above. In the merge, you were on main and HEAD meant your side. Here you are on feature, and HEAD is timeout = 10, which is main's value. Your own change is the bottom half, labelled with a commit hash rather than a branch name.

The reason is visible if you ask where you are:

plaintext
$ git status --short --branch
## HEAD (no branch)
UU config.txt

A rebase checks out the target commit detached — the state from the previous lesson — and replays your commits onto it one at a time. So during a rebase, HEAD is the thing you are rebasing onto, and "yours" is whatever is being applied.

This is why "keep HEAD" is not a strategy. It means opposite things in the two operations, and a habit of reaching for --ours without thinking will silently throw away your own work in exactly one of them.

rebase beginsreplay yoursyou resolvecontinuebranch moves
HEADdetached at mainyour commit's hash27a6467 (unchanged)conflictnot yet

rebase begins. Git checks out main's tip, detached. Your branch pointer has not moved yet and your commits still exist, untouched, under their original hashes.

1 / 5

Interactive rebase is for the history you are about to show someone

git rebase -i main opens a list of your commits with a verb beside each one, and you edit the verbs:

plaintext
pick   27a6467 Raise the timeout
squash 9c1e4f2 fix typo
reword 4b30d81 Add retry config
drop   a17e9c0 debugging printlns
  • squash and fixup fold a commit into the one above it — fixup discards its message, squash lets you combine them. This is how "fix typo", "fix typo again", "actually fix it" become one honest commit.
  • reword changes a message without changing the change.
  • edit stops the rebase at that commit so you can amend it.
  • drop removes it entirely.

The reason to bother is not tidiness for its own sake. A reviewer reads commits; a bisect searches them; a future colleague runs git log on a line of code to find out why it is the way it is. Four commits that each do one thing are worth more than eleven that record your afternoon.

git cherry-pick is the same machinery aimed at one commit instead of a range: it takes the change a commit makes and applies it somewhere else, producing a new commit with a new hash. It is the right tool for backporting one fix to a release branch, and the wrong tool for moving a series of commits — that is what rebase is. Used carelessly it leaves the same change committed twice under two hashes, and a later merge between those branches will conflict on work that is, as far as the graph can tell, unrelated.

The force-push rule

Once you rewrite commits that you have already pushed, your branch and the remote's branch disagree, and a normal push is refused. The temptation is to force it.

The rule that keeps teams out of trouble is short:

Rewrite only what is yours. Your own feature branch, before anyone builds on it — fine. A shared branch, main, or anything a colleague has pulled — never.

When you do need it on your own branch, prefer the safer form:

bash
git push --force-with-lease origin my-branch

A plain force says make the remote look like me, whatever is there. The lease version says make the remote look like me, but only if it still points where I last saw it — so if a colleague pushed in the meantime, the push is refused instead of erasing their commit.

And if the worst happens, it is the previous lesson that saves you: the overwritten commits are still objects, and whoever had them still has them in their reflog. The recovery is somebody running git reflog on the machine that had the work — which is why "everyone re-clone" is the wrong first instruction.

Branching strategies, and what actually decides

Three patterns cover nearly every team:

  • Trunk-based. Everyone commits to main behind short-lived branches, merged within a day or two. Incomplete features hide behind feature flags. Needs good tests and the ability to deploy often.
  • GitHub flow. A branch per change, a pull request, merge to main, deploy from main. The common default, and a reasonable one.
  • GitFlow. Long-lived develop and release branches beside main, with hotfix branches. Built for software that ships in versioned releases people install.

The choice is usually not about Git at all. How often can you deploy, and do you support old versions? A team deploying several times a day gains nothing from release branches and pays for them in merge pain. A team shipping a versioned product that customers run on their own servers cannot function without them.

The failure mode is the same in all three, and it is not a Git problem: branches that live for weeks. A branch that has not merged in a fortnight is not a branch, it is a fork, and the conflict you eventually face is proportional to how long you waited.

Progress is saved on this device and to your account when signed in.