← All posts

Git you can reason about

A working mental model of Git — snapshots, pointers, three trees — then the daily commands and how to escape the five situations that scare people.

Three facts that make the rest of Git obvious

People usually learn Git as a list of spells. add, commit, push, and when something goes sideways, delete the folder and clone again. That works until the day it doesn’t, and then you are guessing under pressure.

Three facts remove most of the guessing.

A commit is a snapshot, not a diff. Git stores the whole tree of files as it looked at that moment, addressed by content hash, plus a pointer to its parent commit. The diffs you see in git log -p are computed on demand by comparing a commit to its parent. Consequence: a commit is immutable. “Editing” one — amend, rebase, squash — always produces a new commit with a new hash, and the old one still exists until garbage collection eats it.

A branch is a pointer. Not a copy, not a folder. It is a file containing forty hex characters:

$ cat .git/refs/heads/main
9f1c0e2a4b8d3f6017c5aa9e2d4b7c81f0a3e5d9

That is why creating a branch is instant and free even in a 5 GB repo. Committing on a branch means “write a new commit, then move this pointer to it”. HEAD is one more pointer, normally pointing at a branch name rather than a commit. When it points straight at a commit instead, you are in detached HEAD — more on that below.

There are three trees, and git status is a report about the gaps between them. Your working directory (real files on disk), the index or staging area (what your next commit will contain), and HEAD (what your last commit contained).

Working dir files on disk Staging area next commit Repository HEAD and history git add git commit git restore / git reset
The three trees. Every everyday command is a move between two of them.

One more structural fact: your clone holds the entire history. That is what “distributed” means in practice — log, diff, branch, blame and committing all run at local-disk speed with the network unplugged, and only fetch, pull and push touch a server. Coming from SVN, that is the difference you actually feel: not philosophy, latency.

Setup and the daily loop

Two settings decide how your name appears in history forever, so get them right on a new machine:

git config --global user.name "Bac Nguyen"
git config --global user.email "bac@example.com"
git config --global pull.ff only     # refuse surprise merge commits on pull

Then either git init in an existing folder or git clone <url>. A clone already has its remote wired up as origin; an init does not:

git remote add origin git@github.com:team/service.git
git push -u origin main   # -u sets the upstream so later `git push` needs no args

The loop itself is small:

git pull --ff-only                 # start the session on current code
# ...edit...
git status                         # which tree is each change sitting in?
git add src/planner.cpp            # stage exactly what belongs in this commit
git commit -m "Clamp lateral accel to vehicle limit"
git log --oneline --graph --decorate -10
git push

A widely believed thing that is wrong: that pull is the safe, conservative way to sync. Default pull is fetch plus merge, so on a diverged branch it silently creates a merge commit nobody asked for. git fetch is the conservative one — it updates origin/main and touches nothing you are working on. Look, then decide:

git fetch
git log --oneline HEAD..origin/main   # what they have that I don't
git log --oneline origin/main..HEAD   # what I have that they don't

Tags are the other thing people get wrong. An annotated tag is a real object with author, date and message; a lightweight tag is just another pointer. Use annotated for anything a release note or a flashed ECU build will ever reference. And tags are not pushed by your normal git push:

git tag -a v2.4.0 -m "Release 2.4.0"
git push origin v2.4.0        # one tag
git push origin --tags        # all of them

Renaming a branch is three operations, because the remote branch is a separate ref:

git branch -m feat/parkig feat/parking
git push -u origin feat/parking
git push origin --delete feat/parkig

Branching strategies, compared honestly

git branch, git switch -c name, git merge are five minutes of learning. Choosing a branching model is the part that actually costs teams money. The real variable is not the diagram — it is how long a change lives outside the mainline, because integration pain grows superlinearly with that time. Martin Fowler’s branching patterns is the best long-form treatment; the short version:

ModelShapeFits whenCost you pay
Trunk-basedEveryone commits to main, branches live hoursStrong CI, feature flags, one deployableNeeds discipline and fast tests; no branch to hide in
GitHub FlowShort branch → PR → merge to main → deployWeb services, continuous deliveryReview latency becomes the bottleneck
Release branchesmain plus release/2.4 cut per release, fixes cherry-picked backFirmware, embedded, anything shipped to a customer that cannot be redeployedCherry-pick drift; every fix is applied N times
Git Flow (develop + release + hotfix)Long-lived develop, formal release branchesScheduled releases, several teams, heavy QA gatesMost ceremony; long-lived branches mean big merges
Integration branch per teamEach team merges into a shared branch, that branch into mainMany teams touching one product lineConflicts surface late, at the worst level

Concrete case: an automotive stack shipping to a customer program cannot be trunk-only, because a frozen build keeps receiving fixes for eighteen months while main moves on. release/* branches are right there. The same repo’s tooling and simulation code has no reason to leave trunk. Different lifetimes, different models, one repository — better than forcing one diagram on everything.

Whatever you pick, write down which branch is deployable and who may force-push where.

The five situations that scare people

SymptomWhat actually happenedWay out
”HEAD detached at 9f1c0e2”HEAD points at a commit, not a branch. New commits belong to nothing.git switch -c rescue/wip to keep them, or git switch main to leave
Merge went wrong, not pushedThe merge commit is localgit merge --abort mid-conflict, or git reset --hard ORIG_HEAD after
Bad merge already pushedHistory is public; rewriting it breaks everyonegit revert -m 1 <merge-sha> — a new commit that undoes it
Someone force-pushed over your branchRemote ref moved backwards; your commits are still in your refloggit reflog, then git branch salvage <sha>
Commit “disappeared” after reset/rebaseUnreachable, not deleted, for ~90 daysgit reflog first; git fsck --lost-found if the reflog is gone

git reflog is the single most valuable command in this list. It is a local log of everywhere HEAD has been, including states no branch points at any more:

$ git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~3
7e8f9a0 HEAD@{1}: commit: Add curvature filter     # <- the "lost" work
...
$ git branch salvage 7e8f9a0

Conflicts deserve one clear statement: a conflict is not an error, it is Git declining to guess. When two branches change the same lines, you get markers in the file, and your job is to produce the final text — not to keep either side by ritual.

git status                  # "both modified" = the list of files to fix
# edit each file, delete every <<<<<<<, =======, >>>>>>> marker
git add src/planner.cpp
git commit                  # message is pre-filled
git merge --abort           # or: back out entirely, nothing lost

Two rules keep this rare and cheap: pull before you start, and keep branches short. A branch that lives three days conflicts on a few lines. A branch that lives three weeks conflicts on a design.

On rewriting history: rebase and amend are fine on branches only you have. On a shared branch they announce that everyone else’s clone is now wrong. If you must force-push something others may have fetched, use git push --force-with-lease — it refuses when the remote holds commits you have not seen. Plain --force will delete a colleague’s work without asking.

What to do differently on Monday

  • Set git config --global pull.ff only. Let Git refuse instead of inventing merge commits.
  • Use git fetch plus git log HEAD..origin/main when you are unsure what changed upstream. It costs four seconds.
  • Run git reflog once today on a real repo, so it is familiar the day you need it.
  • Alias git push --force-with-lease and stop typing plain --force.
  • Find your team’s oldest open branch. More than a week old is your next merge conflict — split it or land it this week.