Chapter 13 · How teams work
Git for Real Teams
Not a command reference. Git has hundreds of commands and a working developer uses about fifteen of them all week — so this chapter teaches those fifteen properly, plus the mental model that makes merge conflicts stop being frightening and the review workflow nobody ever actually gets taught.
[!] What this chapter deliberately leaves out
There is no section on git bisect, submodules, worktrees, filter-branch, or the plumbing commands. They're real, they're occasionally useful, and including them would pad the chapter while burying the fifteen commands you'll genuinely use every week under fifty you won't.
Every behaviour described here was verified by running it against git 2.39 while writing — particularly the conflict-marker output in Part C and the ours/theirs inversion in 13.11, which is the single most misexplained thing in Git and is demonstrated rather than asserted.
13.1 Why Git confuses people who are good at everything else
Git is not hard because the commands are complicated. It's hard because most people learn it as a list of incantations to type when something happens, without ever being shown the model those incantations operate on.
[def] The core idea: a commit is a snapshot, and a branch is a pointer to one
Every commit records the complete state of your project at a moment, plus a link to the commit that came before it. That chain of commits is the history. A branch is not a copy of anything — it's a movable label pointing at one commit, and it moves forward automatically as you add new ones. Almost every confusing Git situation gets much less confusing once you can picture which label is pointing where.
[!] The memorisation trap
Learning "when there's a conflict, type these four commands" works until the day the situation differs slightly from the one you memorised — and then there's no basis to reason from. This chapter front-loads the model in Part A precisely so that Parts C, D and E are things you can work out rather than things you have to recall.
13.2 Four places your work can be
Nearly every "wait, where did my change go" moment comes from not knowing which of four places a change is currently sitting in. They're worth learning by name.
| Place | What it means | How it got there |
|---|---|---|
| Working directory | The files as they exist on disk right now, edited but not recorded | You edited a file |
| Staging area (the index) | Changes you've marked as belonging in the next commit | git add |
| Local repository | Committed history on your machine, safe from accidental edits | git commit |
| Remote repository | The shared copy your team pulls from — GitHub, GitLab, or similar | git push |
[+] The staging area is a feature, not bureaucracy
Its purpose is letting you commit some of your current changes rather than all of them — you fixed a bug and also renamed a variable in an unrelated file, and those belong in separate commits. git add on just the bug fix, commit it, then add and commit the rename. That's 13.6's "commits someone can review" made mechanically possible.
git status # the full, chatty version - read it, it suggests next steps
git status --short # the compact version once you know what you're looking at
# M file.txt modified, NOT staged (working directory)
# M file.txt modified AND staged (staging area)
# ?? file.txt Git has never seen this file (untracked)
13.3 A branch is a sticky note, not a copy
This one sentence resolves more confusion than any other in the chapter: creating a branch copies nothing, costs nothing, and takes no time regardless of how large the repository is.
[def] What actually happens when you create a branch
Git writes a small file containing the ID of the commit you're currently on. That's the entire operation. When you commit on that branch, the label moves forward to the new commit. Switching branches means "make my working directory match what that other label points at" — which is why switching is fast, and why uncommitted changes can get in the way of switching (13.5 covers what to do about that).
[+] HEAD is just "the label for where you are right now"
HEAD points at the branch you currently have checked out. That's why HEAD~1 means "one commit before wherever I am" — a notation that appears throughout Part E's undo commands, and which is much easier to use confidently once you know it's just relative addressing from your current position.
[retail] This is why branch-per-task is the normal workflow, not an advanced technique
Because branches are free, the sensible default is one branch per piece of work, even a ten-minute fix. It keeps unrelated changes from getting tangled together, makes the eventual pull request reviewable (Part D), and means abandoning a bad idea costs nothing more than deleting a label.
13.4 The commands that carry a whole working week
Git ships well over a hundred commands. This is the actual working set — if you know these cold, you can do essentially everything a normal week requires and reason your way through the rest.
| When | Command | What it does |
|---|---|---|
| Orienting | git status | Where is everything right now |
| git log --oneline --graph | What happened, and how the branches relate | |
| git diff | What exactly changed, line by line | |
| Doing the work | git switch -c <name> | Create a branch and move onto it |
| git add <path> | Stage specific changes for the next commit | |
| git commit -m "..." | Record the staged changes permanently | |
| git push | Send your commits to the shared remote | |
| Staying current | git fetch | Download what others did, change nothing locally |
| git pull | Fetch, then integrate it into your branch | |
| git switch <name> | Move to an existing branch | |
| Fixing things | git restore <path> | Throw away uncommitted edits to a file |
| git restore --staged <path> | Unstage, keeping the edit | |
| git stash | Park uncommitted work temporarily | |
| git revert <commit> | Undo a commit by adding a new one | |
| git reflog | Find work you think you destroyed (13.19) |
[!] Prefer switch and restore over checkout
git checkout still works and you'll see it everywhere in older documentation, but it does several unrelated jobs depending on its arguments — changing branches, discarding file changes, and more — which is exactly why it's confusing. git switch (branches) and git restore (files) split those jobs apart, and using them makes it much harder to accidentally destroy work by typing a slightly wrong command.
13.5 Starting work: branch from current main
Almost every messy pull request traces back to a branch that was created from a stale copy of main. Thirty seconds at the start saves an hour of conflict resolution later.
git switch main # get onto the main branch
git pull # make sure it's current with the remote
git switch -c fix/cart-total-rounding # branch from that current state
[!] "I have uncommitted changes and can't switch branches"
Git refuses to switch when doing so would overwrite work you haven't recorded. You have three honest options: commit it (if it's a sensible unit of work), stash it with git stash and bring it back later with git stash pop, or throw it away with git restore if it was scratch work. There's no fourth option where the changes survive being ignored, and hunting for one is how people lose work.
[+] Branch names are communication, and cost nothing to get right
fix/cart-total-rounding tells a teammate what the branch is for before they open a single file. my-branch, test2, and temp-fix-final tell them nothing and age badly. Most teams settle on a type/short-description convention — fix/, feat/, chore/ — which is worth following simply because a consistent convention makes a branch list scannable.
13.6 Committing in units someone can review
A commit is the unit your reviewer reads and the unit you can later undo. Both of those jobs get harder when a commit contains four unrelated changes.
One commit, four unrelated things
git add .
git commit -m "fixes"
# Contains: the actual bug fix, a
# variable rename, a new dependency,
# and some commented-out debugging.
# Reviewing it means untangling all
# four. Reverting the bug fix means
# reverting the other three too.
Separate commits, each one coherent
git add src/cart.py
git commit -m "Fix rounding error in cart total
Totals ending in .005 rounded down due to
float comparison. Use Decimal instead."
git add src/utils.py
git commit -m "Rename calc() to calculate_tax()"
[def] What a good commit message actually contains
A short summary line in the imperative mood ("Fix rounding error", not "Fixed" or "Fixes"), under about fifty characters, so it reads well in git log --oneline. Then, if the change isn't self-explanatory, a blank line and a paragraph explaining why — not what, since the diff already shows what. The message that saves someone's afternoon six months from now is the one explaining why the obvious simpler approach didn't work.
[!] git add . is how secrets and junk get committed
Staging everything indiscriminately is how .env files, API keys, large binaries, and debugging leftovers end up in permanent history — and a secret committed once is compromised even if a later commit removes it, since it remains in the history. Run git status before staging, add paths deliberately, and keep a .gitignore that excludes dependencies, virtual environments, build output, and anything containing credentials.
13.7 Staying current with main while you work
Main keeps moving while your branch is open. Integrating those changes regularly, rather than once at the end, is the single biggest factor in whether merging hurts.
[def] fetch and pull are not the same thing, and the difference matters
- git fetch
- Downloads what's new on the remote and updates your view of it. Changes nothing about your branch or working files — completely safe to run at any time.
- git pull
- Runs fetch, then immediately integrates those changes into your current branch. This is the part that can produce a conflict, because it actually modifies your work.
# See what's changed on the remote without touching your work
git fetch
# How far behind main am I, and what did I add?
git log --oneline main..HEAD # commits only on my branch
git log --oneline HEAD..main # commits on main I don't have yet
# Integrate main's new work into my branch
git switch main && git pull && git switch -
git merge main # (or: git rebase main - see 12.8)
[+] Little and often beats one enormous reconciliation
Integrating main into your branch daily means any conflict involves one day's worth of other people's changes, in code you still remember writing. Leaving a branch untouched for three weeks means resolving three weeks of divergence at once, under time pressure, in code you've half forgotten. The work is the same total amount either way; doing it in small pieces is dramatically easier and less error-prone.
13.8 Merge or rebase: picking one deliberately
This is treated as a religious argument far more often than it deserves. Both integrate changes from one branch into another; they differ in what they do to the history, and that difference has one hard safety rule attached.
git merge main
Creates a merge commit joining
the two histories together.
* your commits stay exactly as
they were, untouched
* history shows the branch really
existed and when it rejoined
* graph is busier to read
ALWAYS SAFE on shared branches
git rebase main
Replays your commits on top of
main, as if you'd started today.
* your commits get NEW hashes -
they are rewritten, not moved
* history is linear and easy to read
* conflicts can surface per-commit
ONLY on branches nobody else uses
[!] The one rule: never rebase a branch other people have based work on
Rebasing rewrites commits, giving them new IDs. If a colleague has pulled your branch and built on it, their copy now shares no common history with yours, and reconciling that is genuinely unpleasant for both of you. Rebase freely on your own unshared feature branch; use merge once a branch is shared. That distinction — is anyone else standing on this — is the whole rule, and it's the one part of this debate that isn't a matter of taste.
[retail] Most teams settle this with a repository setting, not a debate
A common arrangement: developers rebase their own feature branches to stay current (keeping history tidy), and the platform squash-merges each pull request into main, so main gets exactly one clean commit per merged change. What matters far more than which convention a team picks is that everyone follows the same one, since mixed conventions produce a history nobody can read confidently.
13.9 What a conflict actually is
A merge conflict is not an error, a failure, or a sign you did something wrong. It is Git declining to guess, which is exactly the behaviour you want from a tool holding your team's source code.
[def] Git resolves everything it can, and stops only where two changes overlap
When merging, Git compares both branches against the commit they last shared. If you changed one file and a colleague changed a different one, it takes both silently. If you both changed the same file in different places, it usually still takes both. A conflict appears in exactly one situation: you both changed the same lines, and Git has no basis for deciding which version is correct. Someone who knows the intent has to choose, and that someone is you.
[+] Conflicts are frightening mainly because of when they arrive
They tend to appear at the end of a long-lived branch, under deadline pressure, in unfamiliar code — all of which is a consequence of the workflow, not of Git. 13.7's habit of integrating main daily converts one terrifying conflict into a few trivial ones spread over the week, each in code you were recently working in.
13.10 Reading the markers, resolving, finishing
The conflict markers look alarming and are completely mechanical once you know what the three parts mean. This is real output, produced by running the merge.
line1
<<<<<<< HEAD
MAIN CHANGE
=======
FEATURE CHANGE
>>>>>>> feature
line3
[def] Three markers, three meanings
- <<<<<<< HEAD
- Everything below this line, up to the divider, is the version already on the branch you are currently sitting on.
- =======
- The divider. It separates the two competing versions and is not part of either.
- >>>>>>> feature
- Everything above this, back to the divider, is the version coming from the branch named at the end — here, feature.
git status --short # UU file.txt = both sides modified this file
# 1. Open the file. Edit it until it reads how the code SHOULD read.
# Delete all three marker lines. Keep one version, the other, or a blend.
# 2. Tell Git this file is settled
git add f.txt
# 3. Finish the operation you were in the middle of
git commit # for a merge (the message is pre-filled)
git rebase --continue # if you were rebasing instead
[!] The resolution is code, so it has to be read as code
Picking one side wholesale is often right, but not always — when both sides added a different necessary change to the same function, the correct resolution contains both, merged by hand. And a resolved file must still be run: deleting the markers makes Git happy without making the code correct. Committing a conflict resolution without running the tests is one of the most common ways broken code reaches main.
[+] Left a marker behind by accident? Search for it
Stray <<<<<<< markers committed into source files are common enough that it's worth grepping before you commit: git diff --cached | grep -n '^+<<<<' catches them in staged changes. Most linters and CI pipelines also fail on them, which is a good reason to let CI run before asking anyone for a review.
13.11 "ours" and "theirs" invert during a rebase
This is the single most misexplained thing in Git, and it causes people to discard the wrong version of their own work. The behaviour below was verified by running it, not recalled.
[!] During a rebase, "ours" is the branch you are rebasing ONTO
In a merge, --ours means your current branch and --theirs means the branch being merged in. Intuitive. In a rebase, Git replays your commits on top of the target branch, so from Git's point of view the target is the thing being built on ("ours") and each of your own commits is the thing being applied ("theirs"). Your work is theirs. This surprises nearly everyone, once, expensively.
| Operation | "ours" refers to | "theirs" refers to |
|---|---|---|
| git merge feature (while on main) | main — the branch you're on | feature — the branch coming in |
| git rebase main (while on feature) | main — the branch you're replaying onto | feature — your own commits being replayed |
[+] The safe habit: don't rely on the words at all
Rather than reasoning about which label means what under which operation, just look at the actual content. git show :2:<file> prints the "ours" version and git show :3:<file> prints the "theirs" version, so you can read both and decide based on what the code says rather than what you assume the label means. Reading beats remembering.
13.12 The escape hatch: abort and try again
Every conflicting operation in Git has an undo that returns you exactly to where you started. Knowing this makes the whole category of problem much less stressful.
git merge --abort # cancel the merge, restore the pre-merge state
git rebase --abort # cancel the rebase, put the branch back as it was
git cherry-pick --abort # same idea for a cherry-pick
# Verified: after --abort, the working tree is byte-for-byte what it was before.
[+] Aborting is not defeat, it's a legitimate first move
Aborting a confusing merge, pulling main into a fresh copy of your branch, and trying again with a clear head is a perfectly professional response — often faster than untangling a resolution you've half-finished and no longer trust. The one thing to avoid is pushing a resolution you don't actually understand, because that's how a colleague's committed work silently disappears from main.
[retail] When a conflict is genuinely someone else's to resolve
If the conflicting change came from a teammate and you can't tell which version is correct, the right move is to ask them, not to guess. Thirty seconds of "hey, we both changed this function — which behaviour did you need?" beats silently choosing a side and quietly reverting work someone did on purpose. Conflicts are frequently a signal that two people were solving overlapping problems and should talk.
13.13 What a reviewer is actually being asked
"Can you review my PR?" is a request most developers are never taught how to fulfil, so it often degrades into skimming a diff in a browser and clicking approve. Here's what the job actually is.
[def] Four questions a review is supposed to answer
- Does it do what it claims?
- The PR description says it fixes rounding on cart totals. Does the code actually do that, and only that?
- Will it break anything else?
- Does it change shared behaviour, an API contract, a database schema, or something another team depends on?
- Can the next person understand it?
- In six months, with the author gone, is this readable and is the non-obvious part explained?
- Does it actually run?
- Tests pass in CI, and for anything non-trivial, it behaves correctly when you run it yourself (13.15).
[!] Approving without understanding is worse than declining to review
An approval is a statement that a second person has checked this. A rubber-stamp approval creates the appearance of that safety net without any of the substance, and if something breaks later, the process looks like it worked when it didn't. Saying "I don't know this area well enough — can someone from the payments team look at the tax logic?" is a genuinely more useful review than a fast approve.
13.14 Reading the diff: two dots vs. three
If you review from the command line, this distinction matters enormously — get it wrong and the diff shows you changes the author never made, including files they appear to have deleted but didn't.
$ git diff --stat main feature # TWO dots - misleading for review
feature_only.txt | 1 +
main_only.txt | 1 - # <-- WRONG: the branch never deleted this
2 files changed, 1 insertion(+), 1 deletion(-)
$ git diff --stat main...feature # THREE dots - what the PR actually changes
feature_only.txt | 1 +
1 file changed, 1 insertion(+)
[def] Why two dots lies to you
Two dots compares the two branch tips directly: "what would turn main into feature." Since main has commits the feature branch never saw, those show up as things the branch removed — which the author never did. Three dots compares the feature branch against the point where the two branches diverged, which is precisely the set of changes the author is asking you to approve. It's also exactly what GitHub and GitLab show in their web UI, which is why the browser diff and a careless two-dot local diff can disagree.
git fetch # make sure you have the branch
git log --oneline main..feature # the commits being proposed
git diff --stat main...feature # which files, how much churn
git diff main...feature # the actual line-by-line change
git merge-base main feature # where they diverged, if you're curious
13.15 Running the branch locally before approving
For anything beyond a typo fix, the honest answer to "did you check it works" should be yes. This is the workflow, and it's shorter than most people expect.
# 0. Park your own uncommitted work first
git stash
# 1. Get the latest state of the remote, including their branch
git fetch origin
# 2. Check out their branch locally
git switch fix/cart-total-rounding
# (if it's the first time, this creates a local branch tracking theirs)
# 3. Install whatever changed, then actually run it
# - dependencies may have changed: reinstall
# - migrations may exist: check before running against real data
pytest # or the project's test command
# then exercise the actual feature by hand
# 4. Go back to your own work
git switch - # "-" means the branch you were on before
git stash pop
[+] Test their branch merged with current main, not in isolation
A branch can pass all its tests alone and still break once combined with what landed on main this morning. To check the state that will actually exist after merging, create a throwaway branch and merge main into it: git switch -c review-tmp fix/cart-total-rounding, then git merge main, then run the tests. Delete it afterwards with git branch -D review-tmp. Nothing you do on a throwaway branch can affect the author's work, because you never push it.
[!] Things worth checking that a diff cannot show you
Whether the app still starts. Whether a migration is reversible. Whether a new dependency dragged in something unexpected. Whether an added .env variable is documented for everyone else. Whether the change is fast enough on realistic data rather than the three rows in a test fixture. None of these are visible in a diff, and all of them are cheap to check once the branch is running locally.
13.16 Leaving comments people can act on
A review comment's job is to get a change made or a question answered, without making the author defensive. Small wording choices carry most of that weight.
Comments that generate friction
"This is wrong."
"Why would you do it this way?"
"Nit: naming."
"I would have used a dict here."
Comments that get resolved quickly
"This breaks when items is empty -
line 34 indexes [0] unconditionally.
Worth a guard?"
"What happens if the API times out
here? I might be missing it."
"nit (non-blocking): `total_cents`
would match the naming elsewhere."
[+] Label the severity, so the author knows what blocks the merge
Prefixing with nit: (cosmetic, non-blocking), question: (I want to understand, not necessarily change), or blocking: (this must change before merge) removes the guessing entirely. Without it, authors either treat every comment as mandatory and slow to a crawl, or treat everything as optional and ship the real bug you spotted.
[retail] If a comment thread reaches three replies, stop typing and talk
Long written back-and-forth in a PR thread is usually a sign of a design disagreement that text is a poor medium for. A five-minute call resolves what fifteen comments won't, and posting a one-line summary of the outcome back in the thread keeps the decision recorded for whoever reads the PR later.
13.17 Getting your own PR reviewed faster
Review latency is mostly under the author's control, and most of it comes down to respecting the reviewer's time before asking for it.
[+] Five things that reliably speed up a review
- Keep it small
- A 200-line PR gets a careful review today. A 2,000-line PR gets a superficial one next week, because nobody has an uninterrupted hour. Split large work into sequential PRs.
- Write the description the reviewer needs
- What changed, why, how you tested it, and anything you're unsure about. "Fixes #421" alone makes the reviewer reconstruct all of that themselves.
- Review it yourself first
- Read your own diff in the web UI before requesting a review. You will find the leftover debug print and the commented-out block, and it takes two minutes.
- Let CI finish first
- Asking for review while tests are failing wastes a reviewer's context switch on something you already know isn't done.
- Point at the hard part
- "The retry logic in service.py is the part I'd most like eyes on" directs limited attention to where it's actually valuable.
[!] Don't force-push mid-review without saying so
Rewriting history on a branch someone is actively reviewing can invalidate the comments they've already left and lose the thread of what they'd checked. If you need to rebase mid-review, say so in a comment first. Adding fixup commits during review and squashing at merge time is usually kinder — and 13.20 covers doing the force push safely when it genuinely is needed.
13.18 Undo: restore, reset, and revert
Three commands with similar names that do genuinely different things. Picking the wrong one is how people either lose work or rewrite history they shouldn't have touched.
| Situation | Command | What it does |
|---|---|---|
| Edited a file, want the committed version back | git restore <file> | Discards the uncommitted edit. Unrecoverable — it was never committed. |
| Staged something by mistake | git restore --staged <file> | Unstages it, keeping your edit intact |
| Last commit message was wrong | git commit --amend | Replaces the last commit — new hash, so only safe if unpushed |
| Want to undo local commits, keep the code | git reset --soft HEAD~1 | Removes the commit, leaves changes staged |
| Want to undo local commits and the code | git reset --hard HEAD~1 | Removes both. Recoverable via reflog (13.19), but alarming. |
| Undo something already pushed and shared | git revert <commit> | Adds a new commit undoing it. Safe on shared branches. |
[def] Verified: revert adds history, reset removes it
Running git revert HEAD on a three-commit branch produced a fourth commit named Revert "...", with the original commit still present in the log. That's the whole distinction: revert is an honest public record that something was undone, which is why it's the correct tool for anything already pushed. Reset pretends the commit never happened, which only works when nobody else has seen it.
[!] The one genuinely dangerous command in daily use
git reset --hard discards uncommitted work with no confirmation and no reflog entry for the uncommitted part — committed history it moves past is recoverable (13.19), but edits you never committed are simply gone. Use git stash instead: it takes the same second to type and is completely reversible.
13.19 Recovering work you think you destroyed
Almost anything that was committed can be recovered, even after a hard reset, even when the commit no longer appears in the log. This section is worth remembering purely for the bad afternoon it will eventually save.
$ git log --oneline -1
6253d59 feature edit
$ git reset --hard HEAD~1 # the commit is now "gone"
$ git log --oneline -1
5cb1ccd init
$ git reflog -3 # but Git remembered where HEAD has been
5cb1ccd HEAD@{0}: reset: moving to HEAD~1
6253d59 HEAD@{1}: checkout: moving from feature to feature
6253d59 HEAD@{2}: rebase (abort): returning to refs/heads/feature
$ git reset --hard 'HEAD@{1}' # go back to where HEAD was one step ago
$ git log --oneline -1
6253d59 feature edit # recovered
[def] The reflog is a local diary of everywhere HEAD has been
Every checkout, commit, reset, merge and rebase appends an entry. It's local to your machine, not shared, and entries expire after roughly ninety days by default. That window is more than enough for the situation it exists for: realising twenty minutes later that you reset the wrong branch. If a commit existed on your machine at any point, git reflog is where you look first.
[+] The habit that makes recovery unnecessary
Commit early and often on your own branch, even in rough shape — commits are cheap, private until pushed, and tidy-able later. The work that's genuinely unrecoverable is work that was never committed at all, so the single most protective habit is simply committing more frequently than feels necessary.
13.20 Force pushing without hurting anyone
Rewriting history means your local branch and the remote branch disagree, and a normal push is refused. Force pushing resolves that, and there is a safe version and an unsafe one.
[def] Verified: amending changes the hash, which is why the push is refused
Amending a commit produced a different hash (078ad7f became 3e97da4). The remote still holds the old one, so Git can no longer fast-forward and correctly refuses rather than silently discarding whatever the remote had. The refusal is a safety feature, not an obstacle to route around thoughtlessly.
git push --force
Overwrites the remote branch with
your version, unconditionally.
If a teammate pushed to that branch
in the last ten minutes, their work
is gone, with no warning.
git push --force-with-lease
Overwrites ONLY if the remote is
still where you last saw it.
If someone else pushed meanwhile,
the push is rejected and you get
to look before deciding.
[+] Make the safe one your default
git config --global alias.pushf 'push --force-with-lease' gives you a short command that's safe by construction, so the dangerous version requires deliberately typing more. It's also worth knowing that --force-with-lease only protects you if your view of the remote is current, so run git fetch before relying on it.
[!] Never force push to main or a shared release branch
On your own feature branch, force pushing after a rebase is routine and harmless. On a branch other people work from, it rewrites history under their feet and every colleague gets a confusing divergence on their next pull. Most teams protect main at the platform level for exactly this reason, and if yours hasn't, that's worth raising as a five-minute repository setting rather than a matter of individual discipline.
13.21 Six confusions worth clearing up permanently
The recurring questions that come up over and over, answered directly.
[def] The six
- "Do I need to pull before I push?"
- If the remote branch has commits you don't have, yes — Git will refuse the push and tell you so. Pull, resolve anything that conflicts, then push. On your own feature branch that nobody else touches, this rarely comes up at all.
- "What's the difference between fetch and pull?"
- Fetch downloads and changes nothing about your files. Pull downloads and then integrates. Fetch is always safe to run; pull is the one that can produce a conflict.
- "Why does my PR show files I never touched?"
- Almost always either a two-dot diff (13.14) or a branch created from a stale main. Merging current main into your branch usually makes the phantom changes disappear.
- "Should I commit the lock file?"
- Yes, for applications — it's what makes an install reproducible for everyone else and in CI. Libraries are the exception, where it's usually omitted so consumers can resolve their own versions.
- "I committed to main by accident."
- If you haven't pushed: create a branch at your current position with git switch -c fix/whatever, then move main back with git switch main and git reset --hard origin/main. Your work is safe on the new branch.
- "My branch says 'diverged' — what now?"
- You and the remote both have commits the other lacks, usually after someone rebased. Run git fetch, then git log --oneline HEAD..origin/<branch> and the reverse, to see exactly what each side has before deciding whether to merge, rebase, or ask the person who rewrote it.
[retail] The meta-answer to all six
Every one of these becomes obvious once you can picture which label points at which commit, and which of the four places from 13.2 your change is currently sitting in. When something confusing happens, git status and git log --oneline --graph --all answer nearly all of it in two commands — reading the current state beats recalling a memorised remedy.
13.22 Key takeaways
The twelve things worth remembering
- A commit is a snapshot and a branch is a movable label pointing at one. Creating a branch copies nothing. Almost every confusing Git situation clarifies once you can picture which label points where.
- Your work lives in one of four places: working directory, staging area, local repository, remote. "Where did my change go" is nearly always a question about which of those four it is in, and git status answers it.
- Prefer switch and restore over checkout. Checkout does several unrelated jobs depending on its arguments, which is exactly why it is confusing and occasionally destructive.
- Branch from a freshly pulled main, every time. Thirty seconds at the start prevents most of the phantom-conflict pain later.
- Commit in units a reviewer can follow, and explain why in the message. The diff already shows what changed; the message that saves someone's afternoon explains why the obvious simpler approach did not work.
- Integrate main into your branch daily, not once at the end. The total conflict work is the same; doing it in small pieces, in code you still remember, is dramatically easier.
- Rebase for a tidy history on branches nobody else uses; merge once a branch is shared. That single question, is anyone else standing on this, is the whole rule and the only part of the merge-versus-rebase debate that is not taste.
- A conflict is Git declining to guess, not a failure. Edit the file until the code is right, delete all three markers, stage it, then finish the operation, and run the tests, because deleting markers satisfies Git without making the code correct.
- The words "ours" and "theirs" invert during a rebase. Your own commits become "theirs". Rather than memorising that, read both versions directly out of the index and decide from the actual content.
- Review with three dots, not two. Three dots shows what the branch actually changed; two dots falsely reports the branch deleting whatever main added since the two diverged.
- For anything non-trivial, run the branch before approving it. Stash your work, check out their branch, merge main into a throwaway copy, run it. A diff cannot tell you whether the application still starts.
- Almost anything committed is recoverable through the reflog. The genuinely unrecoverable work is work that was never committed at all, which makes committing early and often the single most protective habit available.
[def] The one-sentence version
Learn the model rather than the incantations: commits are snapshots, branches are labels, and your change is always in one of four places. Branch small, commit often, integrate daily, review by actually running the code, and remember that the reflog means almost nothing you commit is ever really lost.
13.23 Interview drills
Git questions in interviews are usually scenarios, not definitions. What's being assessed is whether you reason about the state of the repository or reach for a memorised sequence.
1. What's the difference between merge and rebase, and how do you decide?
Merge creates a commit joining two histories, leaving both branches' commits exactly as they were. Rebase replays your commits on top of the target branch, giving them new hashes, which produces a linear history that's easier to read.
The deciding question is whether anyone else has based work on the branch. Rebase rewrites commits, so if a colleague has pulled your branch and built on it, their history no longer matches yours and reconciling it is genuinely painful. So: rebase freely on my own unshared feature branch to stay current with main, merge once a branch is shared, and never rebase main itself. Beyond that safety rule it's largely team convention, and consistency matters more than which convention.
2. A teammate asks you to review their PR. Walk me through what you actually do.
First I read the description to know what it claims to do, then I look at the diff as a three-dot comparison against main, because that's what the branch actually changed. A two-dot diff would also show me main's own recent commits as if the branch had deleted them, which is misleading.
For anything beyond trivial, I run it. Stash my own work, fetch, check out their branch, and ideally create a throwaway branch and merge current main into it, so I'm testing the state that will exist after the merge rather than the branch in isolation. Then run the test suite and exercise the feature by hand. I'm looking for things a diff can't show: does the app still start, does a migration reverse, is a new env var documented, is it fast on realistic data. Then I leave comments labelled by severity so the author knows what actually blocks the merge.
3. You're mid-rebase, there's a conflict, and you need to keep your version. Which side is "theirs"?
During a rebase, my own commits are "theirs" — which trips up almost everyone. Rebase replays my commits onto the target branch, so from Git's perspective the branch being built on — main — is "ours", and each commit being applied on top, which is mine, is "theirs". It's the opposite of the merge case, where "ours" is the branch I'm sitting on.
Honestly though, I wouldn't rely on the labels. I'll inspect the actual content of both sides out of the index — stage 2 is "ours" and stage 3 is "theirs" — and decide by reading the code. Reading beats remembering on this one, because getting it backwards means discarding real work.
4. You committed a change to main by accident and haven't pushed. How do you fix it?
Since nothing's pushed, this is entirely local and safe. I'd create a branch at my current position, which captures the commit on a proper feature branch, then move main back to where the remote has it — switch to main and reset hard to origin/main. The work survives on the new branch and main is clean again.
If it had already been pushed to a shared main, the answer changes completely: then I'd use revert, which adds a new commit undoing the change rather than rewriting history other people have already pulled. Force-pushing main to hide a mistake is how you break everyone else's clone.
5. When would you use revert instead of reset?
Reset moves the branch label backwards, effectively removing commits from that branch's history. Revert leaves history intact and adds a new commit that applies the inverse of an old one. The choice is decided by whether the commit has been shared.
Anything already pushed to a branch other people pull from gets reverted, because rewriting shared history forces everyone else into a messy divergence. Local, unpushed commits can be reset freely. Revert also has the advantage of being an honest record: the log shows that something was introduced and then deliberately undone, which is genuinely useful context later.
6. You ran git reset --hard and lost a commit. Is it gone?
Almost certainly not, if it was committed. Git keeps a reflog — a local record of every position HEAD has occupied, including ones no longer reachable from any branch. I'd run git reflog, find the entry from just before the reset, and reset hard back to it, or create a branch at that commit to be safer.
The important caveat is that this only covers work that was committed. Uncommitted edits destroyed by a hard reset are genuinely unrecoverable, since Git never had them. That's the real argument for committing frequently on a feature branch and for reaching for stash rather than reset when you just need a clean tree.
7. What's the difference between git fetch and git pull?
Fetch downloads new commits from the remote and updates my local view of the remote branches, but doesn't touch my working files or my branch at all. It's always safe to run. Pull is fetch followed immediately by integrating those changes into my current branch, which is the part that can produce a conflict.
In practice I fetch first when I want to see what's changed before deciding what to do — comparing my branch against the updated remote to see exactly what each side has. That's particularly useful when a branch shows as diverged, because it lets me look before choosing between merging, rebasing, or asking whoever rewrote it what they intended.
8. When is force pushing acceptable, and how do you do it safely?
It's routine on my own feature branch after a rebase or an amend, because rewriting a commit changes its hash and the remote can no longer fast-forward. It's not acceptable on main or any shared release branch, where it rewrites history under other people's feet.
The safe form is --force-with-lease, which only overwrites if the remote is still where I last saw it. If someone else pushed to that branch meanwhile, the push is rejected instead of silently destroying their work. Plain --force has no such check. I'd also fetch first, since the lease is only as current as my last fetch, and warn a reviewer before rewriting a branch they're mid-review on.
9. Your PR shows changes to files you never touched. What happened?
Usually one of two things. Either I'm looking at a two-dot diff, which compares branch tips and therefore shows main's newer commits as changes my branch made, or my branch was created from a stale main and genuinely hasn't seen recent work.
The fix for the first is comparing with three dots, which diffs against the point where the branches diverged. The fix for the second is merging current main into my branch, after which the phantom changes disappear because my branch now contains them. If files still show up unexpectedly after that, I'd check for something environmental — line-ending settings or a formatter reformatting files on save are the usual culprits.
10. How do you keep merge conflicts from becoming a recurring problem on a team?
Mostly by changing the workflow rather than getting better at resolving them. Short lived branches are the biggest factor — a branch open for two days conflicts far less than one open for three weeks. Pulling main into an active branch daily turns one large reconciliation into several trivial ones, in code the author still remembers writing.
Beyond that: keeping PRs small so they merge quickly, splitting large files that everyone edits simultaneously, and agreeing on formatting via an automated formatter so nobody generates conflicts purely from whitespace. And when a conflict does involve someone else's deliberate change, asking them rather than guessing — a conflict is often a signal that two people were solving overlapping problems and should talk.
Where this leaves you
You have the model, not just the commands: commits as snapshots, branches as labels, and a change always sitting in exactly one of four places. That model is what turns a merge conflict from an emergency into a two-minute edit, and what lets you work out an unfamiliar situation instead of searching for someone else's memorised remedy.
The review workflow in Part D is the part most developers are never taught and the part that most affects a team. Reading a diff with three dots rather than two, actually running a colleague's branch merged with current main before approving it, and labelling comments by severity are all small habits, and together they're most of the difference between a review process that catches real problems and one that produces approvals.
The next chapter takes everything built across this course and gets it running somewhere other than a laptop: containers, orchestration, deployment pipelines, and the operational concerns that are specific to serving models on hardware expensive enough that idle capacity is a real cost.