Appearance
14.1.2 — Merging, Rebasing and Getting Out of Trouble
Everything here is the model from Chapter 14.1.1 applied: objects are immutable, a branch is a pointer, and the reflog remembers where the pointer was. Once those three hold, the commands stop needing memorisation.
1. Merging
A three-way merge uses three commits: the tip of each branch, and the merge base — their most recent common ancestor.
A---B---C feature
/
D---E---F---G main
↑
merge baseFor each file, Git compares both tips against the base. Changed on one side only — take that change. Changed on both sides in different places — take both. Changed on both sides in the same place — conflict.
A fast-forward happens when one branch is strictly ahead: nothing to merge, so Git moves the pointer. No merge commit exists, so the branch leaves no trace in history — which is sometimes what you want and sometimes loses the information that a set of commits belonged together. --no-ff forces a merge commit.
The strategy, and what ort is. Git's default merge strategy is ort — "Ostensibly Recursive's Twin" — which replaced recursive as the default in Git 2.34 (2021). It is a rewrite rather than a new algorithm:
- Much faster, especially on large repositories and on rebases with many commits, partly because it can merge without touching the working directory.
- Better rename handling. Because renames are detected rather than recorded (Chapter 14.1.1), a rename on one side and an edit on the other is exactly the case that used to go wrong.
- Fewer edge-case bugs, particularly around directory renames and file-to-directory changes.
You will rarely name it explicitly, and knowing what it is answers the question when it appears in output. The other strategies: ours (take our side entirely — useful for recording that a branch was considered and rejected) and octopus (several branches at once, and it refuses conflicts).
Resolving a conflict:
<<<<<<< HEAD
const timeout = 5000;
||||||| merge base ← only with conflictstyle = zdiff3
const timeout = 3000;
=======
const timeout = 10000;
>>>>>>> featureThe middle section is the reason to set zdiff3 (Chapter 14.1.1). Without it you see two versions and must guess who changed what; with it you see the original, so "they raised it from 3000, we raised it further" is immediately visible and the correct resolution is usually obvious.
Then: edit, remove the markers, git add, git commit. git merge --abort returns to before you started. And enable rerere so a conflict resolved once is replayed on the next rebase of the same branch.
2. Rebasing
Rebase replays your commits onto a new base, creating new commits with new hashes — the originals are unchanged and simply unreferenced.
before: A---B---C feature after: A'--B'--C' feature
/ /
D---E---F---G main D---E---F---G mainRead what that means: C and C' have the same content and different identities. Nothing was modified; new objects were written and the branch pointer moved.
Interactive rebase is where the real value is:
bash
git rebase -i mainpick a1b2c3 Add refund endpoint
squash d4e5f6 Fix typo # merge into the previous commit
fixup 789abc Fix typo again # same, but discard this message
reword 0f1e2d Add refund handling # change the message
edit 3c4d5e Add validation # stop here to amend the commit
drop 6f7a8b Debug logging # remove itReordering the lines reorders the commits.
--autosquash makes this routine. Commit a fix as git commit --fixup <hash>, and git rebase -i --autosquash places it under its target already marked fixup. With rebase.autosquash = true set, cleaning a branch before review is two commands.
The golden rule, and its real boundary. "Never rebase public history" is usually stated too broadly. The accurate version: rebasing rewrites hashes, so anyone who has those commits now has a divergent history.
- Your own feature branch, before or during review — rebase freely. Force-push with
--force-with-lease, which refuses if the remote moved since you last fetched, so you cannot overwrite a colleague's push. - A shared main branch — never. Everyone's history diverges and the recovery is manual for each person.
- A branch someone else is actively working on — coordinate first.
3. Merge, rebase or squash
| History | Bisect | Conflicts | Trace to PR | |
|---|---|---|---|---|
| Merge commit | Exact, branchy | Good | Once | Yes |
| Rebase | Linear | Best | Possibly per commit | Weaker |
| Squash merge | One commit per PR | Coarser | Once | Excellent |
Merge preserves what happened, including the branching. Main becomes hard to read on a busy repository.
Rebase gives a linear history that reads as a sequence of intentional changes, and it is the best case for bisect (section 6) because every commit is a real state. The cost: conflicts may recur per commit, and the shape of the original work is lost.
Squash merge collapses a pull request into one commit. Excellent when branches are short-lived and each pull request is one logical change, which is exactly trunk-based development (Chapter 13.7). Its costs are real: a 40-file commit is harder to bisect into, and authorship of individual commits is folded away.
The pragmatic default most teams land on: rebase your branch onto main to keep it current, then squash-merge the pull request. Linear history, one commit per reviewed change, and no merge-commit noise.
4. Undoing things
revert is the safe one. It creates a new commit that undoes an earlier one. History is unchanged, so it is the only correct choice for anything already pushed.
bash
git revert <hash>
git revert -m 1 <merge-hash> # (1)(1) Reverting a merge needs -m to say which parent is "mainline" — usually 1, the branch you merged into. And there is a trap worth knowing: after reverting a merge, re-merging that branch brings in nothing, because Git considers those commits already merged. You must revert the revert before merging again. This surprises people badly during a release rollback.
reset moves the branch pointer, per Chapter 14.1.1's three modes. Fine locally, and a rewrite if the commits were pushed.
amend rewrites the last commit — new hash, so the same push rules apply.
cherry-pick copies a commit onto the current branch, creating a new commit with the same change. Right for backporting a fix to a release branch, and -x records the original hash in the message, which is what lets someone later see where it came from.
5. The everyday operations
bash
# Branches
git switch -c feature/refunds # create and switch (modern; checkout -b still works)
git switch - # previous branch
git branch -m old new # rename locally
git push origin :old new # delete old on the remote, push new
git branch -d done # delete merged
git branch -D abandoned # delete regardless ← reflog still has it
# Files
git restore file.js # discard working changes (modern; checkout -- )
git restore --staged file.js # unstage, keep changes
git restore --source=HEAD~2 file.js # this file as it was two commits ago
# Stash
git stash push -u -m "wip refunds" # -u includes untracked files
git stash list
git stash apply stash@{0} # keep it in the list
git stash pop # apply and drop
# Remotes
git fetch --all --prune
git remote -v
git push -u origin HEAD # push current branch and set upstream
git push --force-with-lease # the only acceptable force
# Finding things
git log -S "chargeback" --oneline # commits that added or removed this string
git log -L 40,60:server.js # the history of these lines
git blame -w -C server.js # ignore whitespace, follow moved code
git worktree add ../hotfix main # a second working directory, same repositoryFour of these deserve a note.
switch and restore exist because checkout did too many things — changing branches and discarding file changes are unrelated operations with very different consequences. The newer commands separate them, and the split is worth adopting.
stash -u matters because a plain stash leaves untracked files behind, which is how a "clean" stash still breaks the build after switching branches.
git log -S (the pickaxe) is the underused one. "When did this string appear or disappear" answers most archaeology questions in seconds — far better than scrolling a log.
git worktree gives a second checked-out directory sharing one repository, so an urgent fix does not require stashing everything and switching branches. It is the correct answer to "I need to look at main without losing my place."
6. bisect
A binary search for the commit that introduced a bug — and the most powerful debugging tool in Git.
bash
git bisect start
git bisect bad # current commit is broken
git bisect good v1.4.0 # this release was fine
# Git checks out a midpoint; test it; then:
git bisect good | git bisect bad
# … about log₂(n) steps …
git bisect resetOver a thousand commits that is about ten tests.
And it automates:
bash
git bisect run ./check.sh # exit 0 = good, non-zero = badWrite the check as a script and walk away. This is the single highest-leverage Git command for a regression whose cause is not obvious, and it works on performance regressions too — make the script assert a threshold. Chapter 14.6 places it in the wider debugging method.
Its one prerequisite: commits must individually build and run. That is a real argument for the linear, atomic history that rebasing produces.
7. When it has gone wrong
| Situation | Fix |
|---|---|
Bad reset --hard | git reflog, then git reset --hard HEAD@{n} |
| Deleted a branch | git reflog, then git branch name <hash> |
| Committed to the wrong branch | git reset --soft HEAD~1, switch, commit |
| Committed a secret | Revoke it first (Chapter 8.6.1), then rewrite |
| Rebase went wrong | git rebase --abort, or reflog to before it |
| Pushed a bad commit | git revert — do not rewrite shared history |
| Need to remove a file from all history | git filter-repo, then everyone re-clones |
| Lost commits, no reflog entry | git fsck --lost-found |
| Detached HEAD with commits on it | git branch rescue <hash> before switching away |
| Merge conflict chaos | git merge --abort and start again |
The two rules that cover almost every case: reflog first, and revert rather than rewrite for anything pushed.
8. Commit hygiene
Atomic commits — one logical change each. Not "morning's work", and not a commit that cannot build. This is what makes review, revert and bisect all work.
Messages that help. A short imperative subject under about 50 characters, a blank line, then why — the code already says what.
Reject refunds outside the 30-day window
The refund endpoint accepted any order id, so support could
issue refunds on year-old orders by pasting a stale link.
Fixes #482Conventional commits — feat:, fix:, docs:, refactor:, chore:, with ! or a BREAKING CHANGE footer — are a machine-readable convention that lets tooling derive versions and changelogs automatically. Adopt it if you want that automation; it is overhead otherwise, and a team that adopts the format without the tooling has taken the cost and left the benefit.
And the point behind all of it: you write history for the person debugging at 3am, who is often you. git blame leading to a commit that says "fix stuff" is a dead end; one that explains the incident is an answer.
9. Workflows and large repositories
Trunk-based — short branches, merged within a day or two, feature flags for incomplete work. The right default, and Chapter 13.7 makes the delivery case.
GitHub flow — branch, pull request, review, merge, deploy. Trunk-based with a review step, and what most teams actually run.
GitFlow — develop, release/*, hotfix/*. Designed for versioned software with scheduled releases, and a poor fit for continuous deployment, where it produces large risky merges.
For large repositories, three features matter:
--depth 1— shallow clone, the right default in CI.- Partial clone (
--filter=blob:none) — fetch file contents on demand, which makes a huge repository usable interactively. - Sparse checkout — check out only some directories, which is how monorepos stay workable.
Recall
- A three-way merge compares both tips against the merge base; a conflict is only when both sides changed the same place.
ortis the default strategy since Git 2.34 — faster, and notably better at rename-plus-edit cases. - Set
conflictstyle = zdiff3so a conflict shows the original text too, andrerereso a conflict resolved once replays on later rebases. - Rebase writes new commits with new hashes. Rebase your own branch freely and force-push with
--force-with-lease; never rebase a shared main. Interactive rebase pluscommit --fixupand--autosquashis how a branch gets cleaned before review. - Rebase onto main, then squash-merge the pull request is the common default: linear history, one commit per reviewed change.
revertfor anything pushed. Reverting a merge needs-m 1, and re-merging that branch later requires reverting the revert — a real release-rollback trap.switchandrestoresplit whatcheckoutconflated.stash -uincludes untracked files.git log -Sanswers "when did this string appear" in seconds.git worktreegives a second directory instead of stashing everything.git bisect run ./check.shfinds a regression in about log₂(n) tests, unattended — and it works for performance regressions. Its prerequisite is that every commit builds.- Reflog first; revert rather than rewrite for anything pushed. A committed secret is revoked before it is rewritten. Atomic commits with messages that explain why are what make review, revert and bisect all work.
Self-test: What are the three commits in a three-way merge? · What does zdiff3 add and why does it help? · Why is --force-with-lease safe where --force is not? · What must you do before re-merging a branch whose merge was reverted? · Which command finds when a string was introduced? · What does bisect require of your commit history?
Next: 14.2 covers the rest of the toolbox — how command-line tools work internally and how to build one worth using, diagrams as code, and the automation that removes the tasks nobody should be doing by hand.