Rebase, bisect & getting out of trouble
Questions in this set 10
- 01Merge or rebase? Give me an actual policy.
- 02A bug appeared somewhere in the last 200 commits. Find it.
- 03Someone force-pushed over my branch / I deleted my work. Recover it.
- 04reset --soft vs --mixed vs --hard, and when do you use revert instead?
- 05Explain interactive rebase, and what you use it for before opening a PR.
- 06How do you resolve a conflict properly?
- 07Compare trunk-based development with GitFlow. Which would you pick?
- 08What makes a good commit message and a good pull request?
- 09What do cherry-pick, stash, worktree and submodule do, and when have you needed them?
- 10Someone committed a secret. Walk me through the response.
Git questions are asked in almost every loop and are usually answered badly — people recite command definitions instead of describing when they reach for each one. The differentiator is being the person who can recover a colleague's "lost" three days of work.
Merge or rebase? Give me an actual policy.
They produce different histories from the same changes. Merge creates a commit with two parents, preserving exactly what happened. Rebase replays your commits on top of another branch, producing a linear history — and creating new commits with new hashes, because a commit's hash includes its parent.
A policy you can defend:
- Rebase your own unpushed feature branch onto the latest
mainbefore opening a pull request. You get a clean, reviewable, linear series and you resolve conflicts once, privately, instead of inflicting a merge commit on everyone. - Never rebase a shared branch. Rewriting commits that others have pulled forces everyone to reconcile two divergent histories; the recovery is manual and error-prone. The rule is: rewrite history that only exists on your machine.
- Merge into
main— usually a squash merge for a feature branch (one logical change, one commit, trivially revertible) or a merge commit when the individual commits are genuinely worth keeping. - Use
git pull --rebase(or setpull.rebase = true) so routine syncing does not litter history with "Merge branch 'main' into main" commits.
A bug appeared somewhere in the last 200 commits. Find it.
git bisect — a binary search over history, so 200 commits takes about 8 checkouts rather than 200.
git bisect start
git bisect bad # current commit is broken
git bisect good v2.14.0 # this release was fine
# git checks out the midpoint; test it, then:
git bisect good # or: git bisect bad
# … repeat ~8 times …
git bisect resetThe part that impresses is automating it:
git bisect start HEAD v2.14.0
git bisect run npm test -- --testPathPattern=checkout
# exit 0 = good, 1-124 = bad, 125 = skip (untestable commit)Now it finds the commit unattended. Prerequisites worth mentioning: a reliable, fast test for the specific symptom (write it first — that is usually the real work), and git bisect skip for commits that do not build. If history has merge commits with broken intermediate states, --first-parent bisects only the mainline.
Someone force-pushed over my branch / I deleted my work. Recover it.
git reflog — a local log of everywhere HEAD and your branches have pointed, retained for 90 days by default. Almost nothing committed in Git is genuinely lost, and knowing this is the most practically useful Git fact there is.
git reflog # find the SHA before the mistake
# 7d9f3a2 HEAD@{4}: commit: the work I thought I lost
git reset --hard HEAD@{4} # or: git branch rescue 7d9f3a2
git reflog show feature-x # per-branch reflog, for a force-push overwrite
git checkout -b recovered feature-x@{1}For work that was staged but never committed, git fsck --lost-found can surface dangling blobs. What is genuinely unrecoverable: changes never staged or committed, and anything discarded by git clean -fd. That is the argument for committing early and often on your own branch — commits are cheap and history can be tidied later with an interactive rebase.
reset --soft vs --mixed vs --hard, and when do you use revert instead?
Three levels, moving the branch pointer plus optionally the index and working tree:
| branch pointer | index (staged) | working tree | typical use | |
|---|---|---|---|---|
--soft |
moves | unchanged | unchanged | squash the last N commits into one |
--mixed (default) |
moves | reset | unchanged | unstage, keep edits |
--hard |
moves | reset | discarded | throw the work away — the only destructive one |
git reset --soft HEAD~3 && git commit # collapse three commits into one
git restore --staged file.py # modern, clearer than `reset HEAD file`
git restore file.py # discard working changes to one filerevert is different in kind: it creates a new commit that undoes an earlier one, leaving history intact. That is what you use on a shared branch, because it does not rewrite anything. "Undo it on main" is always revert; "clean up my local commits" is reset or an interactive rebase.
Explain interactive rebase, and what you use it for before opening a PR.
git rebase -i main
# pick a1b2c3 Add order validation
# squash d4e5f6 fix typo <- fold into the commit above
# reword 7g8h9i Add tests <- edit the message
# edit j1k2l3 Refactor pricing <- stop here to split or amend
# drop m4n5o6 debug logging <- remove entirelyThe purpose is that the history you publish is a communication artefact, not a transcript. Nobody benefits from "wip", "fix", "fix again", "actually fix". A reviewer benefits enormously from three commits that each do one comprehensible thing and pass tests independently — it makes review incremental, git bisect meaningful, and reverts surgical.
Related tools: git commit --amend for the last commit, git commit --fixup <sha> plus git rebase -i --autosquash to mark fixes that fold into a specific earlier commit automatically, and git rerere (rerere.enabled = true), which records how you resolved a conflict and replays that resolution when the same conflict recurs — invaluable during a long rebase.
How do you resolve a conflict properly?
A conflict means two branches changed the same region. Git shows both sides; the resolution is a judgement about intent, not a mechanical choice.
git config merge.conflictstyle zdiff3 # shows the ORIGINAL too — much easier to judge
git status # what is unresolved
git checkout --ours file / --theirs file # take one side wholesale (careful during rebase)
git add file && git rebase --continue
git rebase --abort # always available; nothing is committed toTwo things worth stating:
- During a rebase, "ours" and "theirs" are inverted relative to a merge, because your commits are being replayed onto the other branch. This reliably catches people out and causes accidentally discarded work.
- Run the tests after resolving. A conflict resolution that compiles can still be semantically wrong: both sides changed the same function for different reasons, and taking one silently drops the other's behaviour. Semantic conflicts — where two files change compatibly at the text level but break each other — will not be flagged by Git at all, which is the argument for CI on every merge result rather than on the branch alone.
The structural fix is prevention: small, short-lived branches merged frequently. A branch open for three weeks will conflict; that is a workflow problem, not a Git problem.
Compare trunk-based development with GitFlow. Which would you pick?
GitFlow has long-lived develop, release, feature and hotfix branches. It suits versioned software with supported releases — installed desktop apps, libraries with several supported major versions, anything with a QA gate.
Trunk-based development: short-lived branches (hours to two days) merged into main constantly, with incomplete work hidden behind feature flags, and main always releasable.
For a web service, trunk-based, and the reasoning matters more than the label: long-lived branches produce large merges, large merges produce conflicts and risky releases, and risky releases produce infrequent releases — which makes each release larger still. Trunk-based breaks that loop, and it is a precondition for continuous delivery. The cost is that it requires good automated tests and feature flags; without those, "always releasable" is a claim rather than a fact.
Follow-up worth preparing: "How do you ship a half-finished feature to main?" Feature flags, and where the change is structural, expand/contract — add the new path alongside the old, migrate, then remove. This is also the answer that connects Git strategy to deployment strategy, which is what a senior interviewer is really probing.
What makes a good commit message and a good pull request?
A commit message answers why, since the diff already shows what:
Cap Redis reconnect backoff at 30s
Under a Redis failover the client retried with unbounded exponential
backoff and reached 40-minute intervals, so pods stayed disconnected
long after Redis recovered. Cap the delay and add jitter.
Fixes #4821Conventions worth having an opinion on: imperative mood in the subject ("Add", not "Added"), subject under ~72 characters, a blank line, then the body. Conventional Commits (feat:, fix:) if the team generates changelogs or versions automatically — useful, and not worth a religious argument.
A good pull request is small (under ~400 lines is the usual research-backed threshold before review quality collapses), does one thing, has a description covering why, how to verify, and what was deliberately left out, and separates refactors from behaviour changes into different commits or different PRs. The single most useful habit: review your own diff before requesting review — you will catch the debug print, the commented-out block, and the accidental file every time.
What do cherry-pick, stash, worktree and submodule do, and when have you needed them?
cherry-pick <sha>applies one commit elsewhere — the standard tool for backporting a hotfix frommainto a release branch. Note it creates a new commit (new hash) and duplicates the change if the branches later merge;-xrecords the original SHA in the message, which future-you will appreciate.stashshelves uncommitted work:git stash push -m "wip",git stash list,git stash pop. Useful for a quick context switch, but untracked files need-uand stashes are easy to forget — a throwaway commit on a scratch branch is often better.worktreechecks out a second branch into a separate directory sharing one repository. This is the genuinely underused one: reviewing a colleague's branch or running a long test suite without disturbing your working tree or rebuilding your dependencies.submodulepins another repository at a specific commit. Powerful and widely disliked — every clone needs--recursive, updates are a two-step dance, and detached-HEAD confusion is routine. Prefer a package manager; use submodules when you genuinely need source-level vendoring.
Someone committed a secret. Walk me through the response.
Order matters, and the first step is the one people get wrong:
- Rotate the credential immediately. Before touching Git. It is compromised the moment it was pushed — it is in clones, forks, CI caches, and quite possibly in a scraper's database already. Removing it from history does not un-leak it.
- Check for use. Audit logs for that key: was it used, from where, when?
- Then purge from history with
git filter-repo(BFG is the older tool;filter-branchis deprecated and slow), force-push, and have everyone re-clone. Note this rewrites every subsequent SHA. - Ask GitHub support to expire cached views, since old commits remain reachable by SHA through the API even after a force-push.
- Prevent recurrence:
gitleaks/trufflehogas a pre-commit hook and in CI (hooks are bypassable), secret scanning with push protection enabled on the remote,.gitignorefor.env, and a secret manager so nobody has a reason to paste a key into a file in the first place.
The judgement being tested is whether you know that history rewriting is the least important step. A candidate who starts with filter-repo and never mentions rotation has failed the question.