Version control intricacies
Branching strategies (trunk-based vs GitHub Flow vs Git Flow), rebase vs merge (and when NOT to rebase shared branches), cherry-pick, and conflicts.
Git fluency at the senior level is two things: choosing a branching strategy that matches your team’s release cadence, and being comfortable rewriting history with rebase, cherry-pick, and conflict resolution — while knowing the one rule you must never break. The mental model that unlocks all of it: a commit is an immutable snapshot identified by a hash, a branch is just a movable pointer to a commit, and “rewriting history” means making new commits and moving the pointer — the old commits don’t change, they’re abandoned.
Branching strategies
The right strategy is a function of how often you release and how many versions you support at once.
| Strategy | Branches | Best for | Tradeoff |
|---|---|---|---|
| Trunk-based | just main (+ tiny short-lived) | high-velocity teams doing CI/CD | needs feature flags and strong CI to hide unfinished work |
| GitHub Flow | main + short feature branches | web apps shipping continuously | no formal release branch — assumes you deploy from main |
| Git Flow | main + develop + feature/release/hotfix | versioned/scheduled releases (e.g. installed software) | heavy; many long-lived branches and merge overhead — overkill for continuous delivery |
The trend is decisively toward trunk-based and GitHub Flow, because long-lived branches are where merge pain accumulates — the longer a branch lives, the more it diverges and the worse its eventual merge. Git Flow earns its complexity only when you genuinely maintain multiple released versions in parallel.
Rebase vs merge
Both integrate one branch’s work into another; the difference is what they do to history. The figure makes the shape difference concrete: merge weaves the branches together with a join commit; rebase straightens them into one line.
You branched feature off main, and main then got two new commits. Here’s how each option
records it.
MERGE — preserves history, adds a merge commit (M):
main: A───B───C───────M
\ /
feature: D───E──┘ (D, E keep their original hashes)
REBASE — replays D,E onto C for a straight line:
main: A───B───C───D'──E' (D', E' are NEW commits; D, E are gone)Merge keeps an honest record that the branch existed and was integrated at point M. Rebase
produces a clean, linear history that reads as if you’d written the feature on top of the latest
main all along — but it rewrote D and E into new commits D' and E' with new hashes.
# You're on your local feature branch; main has moved ahead.
git checkout feature
git fetch origin
git rebase origin/main # replay your commits on top of latest main
# Git stops at the first conflicting commit:
# CONFLICT (content): Merge conflict in app.js
# Edit app.js to the correct final state, remove the <<<< ==== >>>> markers, then:
git add app.js
git rebase --continue # apply the next commit in the sequence
# (repeat edit → add → --continue for each conflicting commit)
# Bailed out? Put everything back exactly as it was:
git rebase --abort
# After a clean rebase your local branch has NEW hashes, so the first push
# to your OWN remote feature branch needs a force — safely:
git push --force-with-lease # refuses if someone else pushed in the meantimeThe key beats: rebase replays commits one at a time, so conflicts are resolved per-commit
(add then --continue), --abort is always your safety hatch, and pushing a rebased branch
needs --force-with-lease — which, unlike a bare --force, refuses to clobber commits you haven’t
seen.
| Merge | Rebase | |
|---|---|---|
| History | preserved — branch shape + a merge commit | rewritten — linear, no merge commit |
| Commit hashes | unchanged | new (D → D') |
| Reads like | "these branches were joined here" | "written on top of latest main all along" |
| Safe on shared branches? | Yes | No — only local/unshared commits |
| Use it to | integrate shared work | clean up your own work before a PR |
The common workflow is to rebase your local feature branch onto main before opening a PR (to
get a clean, linear, easy-to-review series), and merge the PR into main (sometimes with a
merge commit, sometimes squashed). Rebase to clean up your own work; merge to integrate shared
work.
Cherry-pick and conflicts
Cherry-pick copies a single commit’s change onto your current branch — the standard tool for
backporting a hotfix from main onto a release branch without dragging along everything else on
main. It re-applies the diff as a new commit, so the original stays put.
# Backport just one fix onto the maintenance branch:
git checkout release/2.3
git cherry-pick a1b2c3d # re-applies that commit's diff as a NEW commit here
01 Learning objectives
0 / 2 done02 Curated reading
03 Knowledge check
- 01easy
cherry-pick is used to:
- 02medium
The golden rule of rebasing is:
04 Interview questions
browse all ↗What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.
-
Compare trunk-based development, GitHub Flow, and Git Flow — when does each fit?
Trunk-based: everyone commits to (or merges tiny, short-lived branches into)
mainat least daily; unfinished work hides behind feature flags. Optimizes for continuous integration and fast delivery; demands strong tests and CI. The modern default for teams shipping continuously.GitHub Flow: one long-lived
mainplus short feature branches via pull request; merge and deploy on approval. A lightweight middle ground, great for web apps with continuous deployment.Git Flow: heavyweight model with long-lived
develop,release, andhotfixbranches alongsidemain. Suits versioned/installed software with scheduled releases — but for fast web delivery its long-lived branches cause painful merges and slow integration.The trend is toward trunk-based; Git Flow is increasingly considered overkill outside release-train products.
Follow-ups they push on- Why do long-lived branches hurt continuous integration?
- How do feature flags make trunk-based development possible?
Red flag Defaulting to Git Flow for a continuously-deployed web app — its long-lived branches fight CI and cause merge hell.
source: Atlassian — Trunk-based development ↗ -
Rebase vs merge — what's the difference, and when should you NOT rebase?
Merge ties two branches together with a merge commit, preserving the true, non-linear history (and the context of when work diverged).
Rebase replays your commits on top of the target branch, producing a *linear* history as if you'd branched from the latest
main— cleaner log, no merge bubbles. But it rewrites commit hashes.The golden rule of rebasing: never rebase commits that exist outside your local repo / that others have based work on. Rewriting a shared/public branch changes its history out from under teammates, causing divergence and painful re-syncs. Rebase your *private* feature branch onto
mainbefore opening the PR; use merge for integrating shared branches.Follow-ups they push on- What does `git pull --rebase` do, and why might a team standardize on it?
- If you must change a pushed branch, what makes force-pushing 'safer'?
Red flag Rebasing a branch other people have already pulled — it rewrites shared history and forces everyone into messy recovery.
source: Atlassian — Merging vs Rebasing (the golden rule) ↗ -
What does git cherry-pick do, and what's a legitimate use case?
git cherry-pick <sha>applies the *changes introduced by one specific commit* onto your current branch, creating a new commit (new hash) with the same diff.Legitimate uses: backporting a hotfix from
mainonto a release/maintenance branch without dragging along everything else; recovering one commit from an abandoned branch; pulling a single fix forward.Use it sparingly: cherry-picking the same change into multiple branches duplicates commits, which can cause confusing 'phantom' conflicts later when the branches eventually merge. Prefer normal merge/rebase flow when you actually want all of a branch.
Follow-ups they push on- Why can repeated cherry-picks create duplicate-commit merge conflicts down the line?
- How is cherry-pick different from a partial merge?
Red flag Using cherry-pick as a routine integration strategy — it scatters duplicated commits and breaks the clean ancestry that merge/rebase preserve.
source: Atlassian — git cherry-pick ↗ -
Walk me through resolving a merge conflict. What is Git actually asking you to do?
A conflict happens when two branches changed the *same lines* (or one edited what the other deleted) and Git can't auto-pick a winner. It pauses and marks the file with
<<<<<<< HEAD(your side),=======, and>>>>>>> other-branch(incoming side).To resolve: open each conflicted file, decide the correct final content (it is rarely 'pick one blindly' — often you keep parts of both), delete the conflict markers, then
git addthe file to mark it resolved andgit commit(orgit rebase --continue).Good practice: understand *why* both sides changed it, run the tests after resolving, and keep branches short-lived so conflicts stay small.
git merge --abortbacks out if you want to start over.Follow-ups they push on- How does keeping PRs small reduce conflict pain?
- What is `git rerere` and when does it help?
Red flag Blindly accepting one side ('keep mine'/'keep theirs') to make the conflict go away — that silently drops the other side's legitimate change.
source: Atlassian — Merge conflicts ↗ -
You pushed a commit with a leaked API key. Is deleting the file in a new commit enough? How do you fix it?
No — a new commit that removes the file leaves the secret in history; anyone can
git log/git checkoutthe old commit and read it. The secret is effectively public the moment it was pushed.Correct response, in order:
1. Rotate/revoke the key immediately — assume it is already compromised. This is the only step that truly protects you.
2. Purge it from history (git filter-repo, or BFG Repo-Cleaner) and force-push, coordinating with the team since it rewrites shared history.
3. Add a pre-commit/secret-scanning hook and a.gitignoreso it can't recur.The key insight: history rewriting is cleanup, but rotation is the real fix — caches, forks, and clones may still hold the old blob.
Follow-ups they push on- Why is rotating the secret more important than scrubbing it from git?
- Why does rewriting history here require a coordinated force-push?
Red flag Thinking 'I deleted the file and committed, we're fine' — the secret persists in history and must be rotated regardless.
source: GitHub Docs — Removing sensitive data from a repository ↗ -
A bug appeared somewhere in the last 200 commits. How do you find which commit introduced it?
Use
git bisect— a binary search over history. You mark a known-bad commit and a known-good one; Git checks out the midpoint, you test it and markgoodorbad, and it halves the range each step. Over ~200 commits that is roughly 8 tests instead of 200.If you can script the check (a test that exits non-zero on the bug),
git bisect run <script>automates the whole thing. When done,git bisect resetreturns you to where you started, and you have the exact offending SHA — thengit showit to understand the change.This is why small, atomic commits matter: bisect lands you on a tiny diff, not a 2,000-line mega-commit.
Follow-ups they push on- Why do large, mixed-purpose commits make bisect less useful?
- How does `git bisect run` automate the search?
Red flag Manually checking out commits at random instead of bisecting — it is O(n) guessing versus O(log n) binary search.
source: Git — git-bisect documentation ↗ -
You ran a bad reset/rebase and 'lost' commits that aren't in any branch. How do you get them back?
Use
git reflog. The reflog records whereHEAD(and each branch ref) has pointed over time — every commit, checkout, reset, rebase, and merge — even commits no branch points at anymore. Areset --hardor a botched rebase doesn't delete the old commits; it just moves the ref, leaving the originals 'dangling' but still reachable via reflog.Recovery:
git reflogto find the SHA from *before* the bad operation (e.g.HEAD@{3}), thengit reset --hard <sha>to move the branch back, orgit checkout -b recover <sha>/git cherry-pick <sha>to salvage specific commits.The key insight: in Git, work you've committed is almost never truly lost — those objects survive until garbage collection (default ~30–90 days) and the reflog is the map to them. (Uncommitted working-tree changes, by contrast, *are* gone — reflog only tracks committed history.)
What a strong answer coversgit refloglogs every position of HEAD/branch refs — including commits no branch references.reset --hard/rebase move refs, leaving old commits dangling but recoverable, not deleted.Recover with
git reset --hard <sha>orgit checkout -b/cherry-pickthe SHA found in the reflog.Committed work survives until GC (~30–90 days); only uncommitted changes are truly unrecoverable.
Follow-ups they push on- Why can reflog recover a committed change but not uncommitted working-tree edits?
- How long do dangling commits survive before garbage collection removes them?
Red flag Panicking and re-doing work after a bad reset/rebase — the old commits are almost always recoverable via reflog; only uncommitted changes are genuinely lost.
source: Atlassian — git reflog ↗ -
What's the difference between git reset, git revert, and git checkout/restore?
They operate at different levels and have very different safety profiles.
git revert <sha>creates a *new* commit that undoes the changes of an earlier one — history is preserved and moves forward. It's the safe way to undo a commit that's already been pushed/shared, because it doesn't rewrite history.git resetmoves the current branch pointer to another commit, rewriting history.--softkeeps changes staged,--mixed(default) unstages them,--harddiscards working-tree changes too. Reset is for local, unpushed history — using it on shared history is the rebase-style hazard.git checkout/git restore(modern Git split checkout's jobs intoswitchfor branches andrestorefor files) operate on the working tree / specific files — discarding local file changes or restoring a file to a given version, without moving the branch.Rule of thumb: undo *public* history with
revert; rewrite *local* history withreset; restore *files/working tree* withrestore.What a strong answer coversrevert= new commit that undoes another; safe on pushed/shared history (no rewrite).reset= move the branch ref, rewriting history;--soft/--mixed/--harddiffer in what they keep. Local-only.restore/checkout= operate on files/working tree, not the branch pointer.Rule: revert public, reset local, restore files.
Quick self-checkA buggy commit is already pushed and others have pulled it. What's the safe way to undo it?
-
Correct — revert is non-destructive and safe for commits others already have.
-
This rewrites shared history out from under teammates — the exact hazard to avoid on a pushed branch.
-
That just moves HEAD into a detached state on your machine; it doesn't undo the commit for anyone.
-
restore touches working-tree files, not the published commit history everyone has pulled.
Follow-ups they push on- Why is revert the correct tool for undoing a commit on a shared branch?
- What exactly do --soft, --mixed, and --hard each preserve?
Red flag Using `git reset --hard` to undo a commit that's already pushed — it rewrites shared history (and `--hard` also destroys uncommitted work); use `revert` for anything public.
source: Atlassian — Resetting, checking out & reverting ↗ -
What is an interactive rebase (squash/fixup/reword) for, and what's the risk?
git rebase -ilets you rewrite a series of your own commits before sharing them: reorder them, squash/fixupseveral WIP commits into one logical change, reword messages, edit a commit's content, or drop a commit. The point is to turn a messy local history ('wip', 'fix typo', 'oops') into a clean, reviewable sequence of atomic commits — which makes review,git bisect, andgit revertfar more useful later.The risk is the same golden rule of rebasing: it rewrites commit hashes, so you must only do it to commits that haven't been shared. Interactive-rebasing commits others have already based work on rewrites public history and forces everyone into painful re-syncs. Do it on your local feature branch before opening (or updating) the PR; never on shared
main.What a strong answer coversInteractive rebase curates your own unshared commits: squash/fixup, reorder, reword, edit, drop.
Goal: a clean, atomic, reviewable history — which makes bisect and revert more effective.
It rewrites hashes, so obey the golden rule: only on commits not yet shared.
Use it on your local feature branch pre-PR, never on shared
main.
Follow-ups they push on- How does `git commit --fixup` plus `rebase --autosquash` streamline cleanup?
- Why does squashing make `git bisect` and `git revert` more useful afterward?
Red flag Interactive-rebasing commits that are already pushed/shared — it rewrites public history (new hashes) and forces collaborators into messy recovery; keep it to local, unshared work.
source: Atlassian — Rewriting history (interactive rebase) ↗ -
What makes a good commit message and a good atomic commit, and why does it matter downstream?
An atomic commit captures one logical change — it does exactly one thing and leaves the codebase building/passing. A good message has a concise imperative summary line ('Add retry to S3 upload', ~50 chars), a blank line, then a body explaining the why (and any tradeoffs), not the *what* — the diff already shows what changed.
Why it matters is entirely downstream: clean atomic commits make
git bisectland on a tiny diff, makegit revertundo exactly one change without collateral, make code review comprehensible commit-by-commit, and makegit blame/log a usable history rather than a wall of 'misc fixes'. A commit that bundles a refactor, a feature, and a formatting sweep is impossible to bisect, revert, or review cleanly.The summary is for scanning
git log; the body is for the engineer (often future-you) who needs to understand *why* a line exists.What a strong answer coversAtomic = one logical change that builds/passes on its own.
Message = imperative summary line + blank line + body explaining why, not what.
Pays off in bisect (tiny diff), revert (no collateral), review (commit-by-commit), and blame/log.
Bundled commits (feature + refactor + reformat) are un-bisectable, un-revertable, and unreviewable.
Follow-ups they push on- Why explain the *why* in the body when the diff already shows the *what*?
- How does a clean commit history make `git revert` safer than a bundled one?
Red flag Bundling unrelated changes into one commit (and writing 'fixes'/'updates' as the message) — it destroys the downstream value of bisect, revert, blame, and review.
source: Git — Commit Guidelines (Pro Git book) ↗ -
When is force-pushing acceptable, and what makes --force-with-lease safer than --force?
Force-pushing is needed after you rewrite history on a branch (rebase, amend, interactive-rebase cleanup) — the remote ref no longer fast-forwards, so a normal push is rejected. It's acceptable on a branch you own that others aren't building on: typically your own feature/PR branch. It is *not* acceptable on shared branches like
main.Plain
git push --forceoverwrites the remote ref unconditionally — if a teammate pushed in the meantime, you silently destroy their commits.git push --force-with-leaseadds a safety check: it only overwrites if the remote is still at the commit you *last saw*. If someone else pushed since your last fetch, the lease check fails and the push is rejected, so you can't clobber work you didn't know about.So: rewrite only unshared history, and when you must force-push, use
--force-with-leaseso a surprise upstream change aborts the push instead of being overwritten.What a strong answer coversForce-push is required after history rewrites (rebase/amend); only OK on branches you own, never shared
main.--forceoverwrites the remote unconditionally — it can silently erase teammates' new commits.--force-with-leaseonly pushes if the remote still matches what you last fetched — else it aborts.The lease turns 'I might clobber unseen work' into a safe failure you can investigate.
Follow-ups they push on- How can --force-with-lease still bite you if a tool runs `git fetch` in the background?
- Why does an interactive rebase on a PR branch require a force-push at all?
Red flag Using plain `--force` on a branch others might have pushed to — it overwrites their commits with no warning; `--force-with-lease` aborts instead, so it should be the default.
source: Atlassian — git push (force pushing) ↗ -
What's the point of a pull request beyond merging code? Why squash-merge vs merge-commit vs rebase-merge?
A pull request is the collaboration unit, not just a merge button: it's where review, CI gates, discussion, and an audit trail of *why* a change was made all attach to a proposed change before it lands. The merge is the smallest part.
The three merge modes shape your
mainhistory differently. Merge commit preserves every commit on the branch plus a merge node — full history, butmaingets noisy with WIP commits. Squash-merge collapses the whole PR into one commit onmain— clean, atomic, one-PR-one-commit history that's easy to bisect/revert, at the cost of losing the branch's intermediate commits. Rebase-merge replays the branch's commits linearly ontomainwith no merge node — linear history that keeps individual commits, but rewrites their hashes.Many teams default to squash-merge for a tidy, revertable trunk; rebase-merge when individual commits are each meaningful; merge-commit when preserving exact branch topology matters.
What a strong answer coversA PR bundles review, CI gates, discussion, and audit trail — merging is its smallest function.
Merge commit: keeps all branch commits + a merge node — full history, noisier trunk.
Squash-merge: one commit per PR — clean, atomic, easy to bisect/revert; loses intermediate commits.
Rebase-merge: linear history keeping individual commits, but rewrites their hashes (no merge node).
Follow-ups they push on- Why does squash-merge make `git revert` of a whole feature trivial?
- When would preserving the branch's individual commits (rebase/merge) be worth the noise?
Red flag Thinking a PR is just a merge mechanism — its real value is the review/CI/discussion gate; and picking a merge strategy without considering how bisect/revert/readability of `main` are affected.
source: GitHub Docs — About merge methods on GitHub ↗