Contents
Chapter 01Starter kit
The commands you actually use every day. Learn these twenty and you can work on any team; everything after this chapter is depth, not prerequisites.
Set up, once per machine
Do this before your first commit.
- git config --global user.name "…"
- Your name on every commit
- git config --global user.email "…"
- Must match your account to be linked
- git config --global init.defaultBranch main
- Name new repositories' first branch
- git config --global pull.ff only
- Refuse to guess when histories diverge
Start working
Getting a repository onto your disk.
- git clone URL
- Copy a remote repository, full history
- git init
- Turn the current folder into a repository
- git status
- What changed, what is staged, which branch. Run it constantly
The commit loop
The five commands that make up 80% of your day.
- git diff
- What you changed and have not staged yet
- git add -p
- Stage hunk by hunk, so commits stay focused
- git add file
- Stage a whole file
- git commit -m "…"
- Record what is staged
- git log --oneline --graph
- See the history you just added to
Branch and share
Never commit straight onto main on a team.
- git switch -c feat/my-thing
- Create a branch and move to it
- git switch main
- Move to an existing branch
- git push -u origin HEAD
- Publish the branch and track it
- git push
- Every push after the first one
Stay in sync
Run the first two every morning. It is the single best habit for avoiding painful conflicts.
- git fetch origin
- Download what others pushed, change nothing locally
- git rebase origin/main
- Replay your work on top of the latest
main - git pull
- Fetch and integrate in one step
Undo
Four situations, four different commands. Mixing them up is the most expensive beginner mistake.
- git restore file
- Throw away your edits to that file
- git restore --staged file
- Unstage it, keep the edits
- git commit --amend
- Fix the commit you just made
- git reset --soft HEAD~1
- Undo the last commit, keep the work staged
- git revert SHA
- Undo a commit that is already shared
When a merge stops
Git is asking you a question, not reporting a bug. Chapter 08 covers this properly.
- git status
- Lists the conflicted files
- git diff
- Shows the conflicting hunks
- git add file
- Marks it resolved, after you edit it
- git rebase --continue
- Resume (or
merge --continue) - git rebase --abort
- Back out entirely, nothing lost
Get out of trouble
Two commands worth memorising before you need them.
- git stash
- Park uncommitted work, restore with
git stash pop - git reflog
- Every position HEAD has held. Almost nothing is truly lost
The whole day, as one script
# Morning: start from the real, current main git switch main git fetch origin git pull # Open a branch for the ticket git switch -c feat/4821-loyalty-discount # Work, then review your own changes before staging anything git diff git add -p git commit -m "feat(discount): add loyalty tier calculation" # Publish early, even unfinished git push -u origin HEAD # Next morning: pick up what the team pushed git fetch origin git rebase origin/main git push --force-with-lease # the branch is yours, rewriting is fine # After the pull request is merged, clean up git switch main git pull git branch -d feat/4821-loyalty-discount
git status before every commit. Read git diff --staged before every push. Keep branches shorter than two days. Those three do more for your Git life than memorising forty flags.
git push --force to a branch other people use — use --force-with-lease. Never commit a secret, not even temporarily: once pushed it is compromised, and the only real fix is rotating the key.
Chapter 02The mental model
Almost every Git disaster comes from a wrong mental model. Once you know what Git actually stores, the commands become obvious.
Git does not store diffs, it stores states
Many version control tools store "line 42 changed". Git does not: every commit is a complete snapshot of the tree. Unchanged files are not copied, they are simply referenced again. The diffs you see are computed on demand, not stored.
Four object types, that is all
| Object | Contains | Analogy |
|---|---|---|
blob | Raw file content, with no name and no permissions | The text on a page |
tree | A list of names mapping to blobs or other trees, with modes | A folder |
commit | A root tree, 0..n parents, author, committer, date, message | A timestamped, signed photograph |
annotated tag | A named pointer to an object, with a message and a signature | A label stuck on a box |
Every object is identified by the hash of its own content (SHA-1, or SHA-256 on newer repositories). Two identical files in two unrelated projects get the same hash. This is what makes Git tamper-evident: change one byte in an old commit and every hash after it changes too.
# The hash of the current commit
git rev-parse HEAD
# What type of object is this?
git cat-file -t HEAD
# Its contents: root tree, parent, author, message
git cat-file -p HEAD
# The root tree: the project's file list at that commit
git cat-file -p HEAD^{tree}
# A file's contents as it was three commits ago
git show HEAD~3:src/index.js
Branches are just sticky notes
A branch is a 41-byte file containing a commit hash. Creating one costs nothing. Deleting one deletes no commits. HEAD is a pointer to the current branch — or straight to a commit, which is the "detached HEAD" state.
cat .git/HEAD # ref: refs/heads/main cat .git/refs/heads/main # 9f3c1ad... git symbolic-ref HEAD # refs/heads/main git for-each-ref --sort=-committerdate refs/heads --format='%(refname:short) %(committerdate:relative)'
The graph is directed and acyclic
Each commit points at its parent or parents — never at its children. So Git has no idea "what comes next": it walks backwards. That is why git log starts at HEAD and descends, and why a commit with no reference leading to it becomes unreachable and is eventually garbage collected.
A merge commit has two parents. The first is the branch you were on, the second is the branch you brought in. That ordering has very concrete consequences: HEAD^1 versus HEAD^2, the meaning of --ours and --theirs, and what git log --first-parent shows you.
The four areas
.git. Offline and complete.git commit / git logThe index is the area beginners underrate. It is what lets you build clean commits out of messy work: you do not commit "what you did", you commit "what you chose".
mkdir /tmp/lab && cd /tmp/lab && git init. Everything in this course can be tried there. Nothing here is learned until you have typed it.
Chapter 03Configuring Git once and for all
Ten minutes of configuration saves you hours. This is the baseline I put on every machine.
Identity and core settings
git config --global user.name "First Last" git config --global user.email "me@example.com" # Default branch name for new repositories git config --global init.defaultBranch main # Editor for commit messages and interactive rebase git config --global core.editor "code --wait" # Never guess: pull refuses when the intent is ambiguous git config --global pull.ff only # Push the current branch to its remote namesake git config --global push.default simple git config --global push.autoSetupRemote true # Drop remote-tracking branches that no longer exist, on every fetch git config --global fetch.prune true # More readable diffs: detect moved blocks and renames git config --global diff.colorMoved zebra git config --global diff.renames copies # Conflicts: also show the common ancestor (see chapter 08) git config --global merge.conflictStyle zdiff3 # Remember how you resolved a recurring conflict git config --global rerere.enabled true
Aliases that actually survive
Resist the urge to alias forty commands: you will only remember the ones you type. These are the ones that last.
git config --global alias.st "status -sb" git config --global alias.lg "log --graph --oneline --decorate --all" git config --global alias.last "log -1 --stat" git config --global alias.unstage "restore --staged" git config --global alias.amend "commit --amend --no-edit" git config --global alias.wip "!git add -A && git commit -m 'wip'" git config --global alias.pushf "push --force-with-lease"
Line endings: the number one source of noise
A repository shared between Windows and Unix always ends up producing 400-line diffs where nothing changed. The fix is not core.autocrlf on each workstation, it is a committed .gitattributes that applies to everyone.
# Normalise everything to LF in the repository, auto-detect binaries * text=auto # Explicit overrides *.sh text eol=lf *.bat text eol=crlf *.png binary *.pdf binary # Keep generated files out of diffs and pull requests package-lock.json -diff linguist-generated dist/** -diff # Files that should not ship in archives .github/ export-ignore tests/ export-ignore
The .gitignore
Three levels: the project file (committed, shared), .git/info/exclude (local, not shared), and a global one for your personal tooling.
git config --global core.excludesfile ~/.gitignore_global printf '.DS_Store\n.idea/\n*.swp\n' >> ~/.gitignore_global # Why is this file ignored? Which rule, which file, which line? git check-ignore -v build/app.js # An already-tracked file is never ignored: untrack it first git rm --cached .env echo '.env' >> .gitignore
.gitignore has no effect on a file that is already tracked. And ignoring a secret after pushing it does not remove it from history: see chapter 12.
Signing your commits
A signature proves the commit really came from you. The author field is plain text: anyone can commit under your name. With an SSH key this has become trivial.
git config --global gpg.format ssh git config --global user.signingkey ~/.ssh/id_ed25519.pub git config --global commit.gpgsign true git config --global tag.gpgsign true # Check it git log --show-signature -1
Per-directory configuration
A work address on company repositories and a personal one everywhere else, without ever thinking about it:
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
# ~/.gitconfig-work
[user]
email = first.last@company.com
Chapter 04The daily cycle, in depth
Ninety percent of the time you use six commands. Worth using them well.
Getting started
git init # new repository in the current folder git clone git@github.com:org/project.git # full copy, history included # Lighter clones for a huge repository or for CI git clone --depth 1 --single-branch --branch main URL # last commit only git clone --filter=blob:none URL # fetch objects on demand # Get the full history later git fetch --unshallow
Staging precisely
git add . is the gateway to junk-drawer commits. The decisive tool is -p: Git shows you each changed hunk and you decide whether to stage it. That is what lets you separate a bug fix from the variable rename you did along the way.
git add -p # hunk by hunk: y / n / s (split) / e (edit) / q git add -p src/api.ts # limited to one file git add -u # only files already tracked git add -A # everything, deletions included git diff # working tree ←→ index git diff --staged # index ←→ last commit git diff HEAD # working tree ←→ last commit git diff --word-diff # word-level diff: perfect for prose git diff --stat # just the volume per file
git add -p, press s to split a hunk that is too big, and e to hand-edit which lines to keep. That is the only way to stage a single line in the middle of a hunk.
Committing
git commit # opens the editor: subject + body (recommended) git commit -m "subject" # short message git commit -am "subject" # stage tracked files, then commit git commit --amend # fix the last commit (message and/or content) git commit --amend --no-edit # add the index to the last commit, keep the message git commit --fixup=a1b2c3d # a commit marked to be absorbed into a1b2c3d git commit --squash=a1b2c3d # same, concatenating the messages git commit --allow-empty -m "trigger CI" # commit with no changes
--amend does not modify a commit: it creates a new one and moves the branch. The hash changes. On a commit that has been pushed and shared, that is history rewriting — see chapter 07.
Reading history
git log --oneline --graph --decorate --all # the reference view git log --first-parent # branch story, without merge internals git log -5 --stat # last 5 commits + files touched git log -p src/auth.ts # successive diffs of one file git log --follow src/auth.ts # follow it across renames # Filters git log --since="2 weeks ago" --until=yesterday git log --author="Name" git log --grep="payment" -i # in commit messages git log -S"getUserToken" --pickaxe-regex # commits adding/removing that code git log -G"TODO" # commits whose diff matches git log main..feature/cart # in feature but not in main git log --merges / --no-merges # Custom format git log --pretty=format:'%C(auto)%h %ad %an%d %s' --date=short
-S is the most underused command in Git. Looking for when a function disappeared? git log -S"functionName" --oneline hands you the exact commit in a second, where a message search would have failed.
Undoing: the decision table
Three commands, three scopes. This is the most expensive confusion for beginners.
| I want to… | Command | Effect |
|---|---|---|
| Throw away my edits to a file | git restore file | Uncommitted work is gone for good |
| Unstage without losing work | git restore --staged file | Leaves the index, keeps the disk |
| Take a file from another commit | git restore -s HEAD~2 file | Overwrites the local version |
| Undo the last commit, keep it staged | git reset --soft HEAD~1 | Changes stay ready to recommit |
| Undo the last commit, keep the work | git reset HEAD~1 | --mixed mode: index cleared, disk intact |
| Undo the last commit and drop everything | git reset --hard HEAD~1 | Destructive. Recoverable via the reflog |
| Undo a commit that is already shared | git revert a1b2c3d | Creates the inverse commit. History preserved |
| Undo a merge that is already pushed | git revert -m 1 <merge> | -m 1 = keep the first-parent line |
| Drop everything, untracked files too | git clean -fd | Always dry-run with -n first |
The reflex Before any destructive command, a git stash or a backup branch: git branch backup/before-reset. Costs one second, has saved entire days.
Chapter 05Branches and integration
Creating a branch is trivial. What takes judgement is how you bring it back in.
Working with branches
git switch -c feature/cart # create and move to it (modern) git switch main # move git switch - # previous branch git switch --detach a1b2c3d # inspect a commit with no branch git branch -vv # local branches + tracking + ahead/behind git branch --merged main # already integrated: safe to delete git branch --no-merged main # work not yet integrated git branch -m old new # rename git branch -d feature/cart # delete if merged git branch -D feature/cart # force delete git switch -c hotfix/vat main # branch explicitly from main, not from HEAD
checkout still does all of this, but it also does three other jobs, which is where the mistakes come from. switch changes branch, restore restores files: use them.
Three ways to integrate a branch
Pick a button — the graph and the explanation update together.
# 1. Merge commit: keeps the topology git switch main && git merge --no-ff feature/cart # 2. Rebase then fast-forward: linear history git switch feature/cart && git rebase main git switch main && git merge --ff-only feature/cart # 3. Squash: the branch becomes a single commit git switch main && git merge --squash feature/cart && git commit
| Criterion | merge --no-ff | rebase + ff | squash |
|---|---|---|---|
| History shape | Branched, faithful | Linear | Linear, one commit per batch |
| Original hashes | Preserved | Rewritten | Lost |
git bisect | Works | Ideal | Coarse granularity |
| Conflicts | Once, at merge time | Possibly on every replayed commit | Once |
| Traceability of the batch | The merge commit | Weak without convention | Excellent |
| Best for | Release merges, large batches | Short single-author branches | Messy branches, simple fixes |
main, develop, or a shared release branch.
Why --no-ff matters
If main has not moved since you branched, git merge fast-forwards by default: it just moves the pointer, and your branch vanishes from the story. With --no-ff, Git creates a merge commit that records "this is the batch of work that landed here". Many teams use --no-ff for integrations into long-lived branches and --ff-only everywhere else.
Chapter 06Working with remotes
Git is distributed: your repository is complete. Nothing is synchronised unless you ask.
Managing remotes
git remote -v git remote add upstream git@github.com:org/project.git # original repo (fork workflow) git remote set-url origin git@github.com:me/project.git git remote show origin # tracked branches, state git remote prune origin --dry-run # remote branches that vanished
fetch is not pull
fetch downloads and updates remote-tracking refs (origin/main). It does not touch your work. pull is fetch plus immediate integration. Get into the habit of fetching, then looking, then integrating.
git fetch --all --prune git log --oneline HEAD..origin/main # what landed that I do not have git log --oneline origin/main..HEAD # what I have that is not pushed git diff origin/main # the whole delta # Then, knowing what you are doing: git merge --ff-only origin/main # refuses if histories diverged: healthy git rebase origin/main # replay my work on top git pull --rebase # both, in one command
Pushing
git push -u origin feature/cart # create the remote branch and track it git push # from then on, just this git push --force-with-lease # after a rebase: refuses if the remote moved git push --force # blind overwrite. Avoid. git push origin --delete feature/cart # delete the remote branch git push --tags # push tags git push origin v1.8.0 # push one tag
--force-with-lease
--force overwrites the remote even if a colleague pushed in the meantime: their work disappears. --force-with-lease first checks the remote is still where you last saw it, and refuses otherwise. Alias it and forget --force exists.
Fetching a colleague's branch
git fetch origin git switch -c review/cart origin/feature/cart # On GitHub, fetch a pull request by number git fetch origin pull/412/head:pr-412 git switch pr-412 # See only what the PR changes relative to the common ancestor git diff main...pr-412
Note the three dots: a..b means "commits in b that are not in a", a...b compares b against the common ancestor. For reviewing a PR you want ... — otherwise you also see everything that landed on main in the meantime.
Chapter 07Rewriting history
History is like prose: you draft messily and publish cleanly. Here is the tooling.
Interactive rebase
The most powerful command in Git. It opens the list of commits to replay and lets you reorder, combine, reword, or drop them.
git rebase -i HEAD~5 # the last five commits git rebase -i main # all my commits since main git rebase -i --root # all the way to the first commit
pick a1b2c3d add cart total calculation squash e4f5g6h oops typo # fold into the previous one, edit the message fixup h7i8j9k oops again # fold in, discard this message reword k1l2m3n fix VAT rate # keep the content, rewrite the message edit n4o5p6q rework the service # pause here so you can change the content drop q7r8s9t debug console.log # remove the commit exec npm test # run a command at this point in history break # deliberate pause # Reordering = moving lines. Deleting a line = drop.
The fixup + autosquash workflow
This is the routine that changes code review. A reviewer asks for a change to one specific commit: instead of appending "review fixes" at the end, you attach the change to the right commit, then squash automatically.
# 1. Find the commit in question git log --oneline -8 # 2. Fix the code, then commit targeting that commit git add -p git commit --fixup=a1b2c3d # Variant: let Git pick the commit that last touched those lines git commit --fixup=amend:$(git log -1 --format=%h -- src/vat.ts) # 3. Squash automatically, without editing the todo list git rebase -i --autosquash main # Make --autosquash implicit: git config --global rebase.autosquash true git config --global rebase.autostash true # park work in progress
The reflog: your safety net
Git journals every movement of HEAD and of your branches, for 90 days by default. As long as a hash is in the reflog, nothing is lost — including after a reset --hard or a catastrophic rebase.
git reflog # history of HEAD
git reflog show feature/cart # history of one branch
# Go back to the state before the mistake
git reset --hard HEAD@{1}
git switch -c recovery abc1234 # or branch off the commit you found
# Orphaned commits that are not in the reflog
git fsck --lost-found --no-reflogs
Parking work: stash
git stash push -m "cart in progress"
git stash push -u # include untracked files
git stash push -p # choose which hunks to park
git stash push src/api.ts # limited to certain paths
git stash list
git stash show -p stash@{1}
git stash pop # apply and remove from the stack
git stash apply stash@{2} # apply, keep it on the stack
git stash branch fixwork # turn a stash into a branch
git stash drop stash@{0}
git log. For work that lasts more than an hour, a real wip commit on a branch is better: visible, pushable, and squashable later.
cherry-pick: replaying a commit
git cherry-pick a1b2c3d # replay that commit here git cherry-pick a1b2c3d..f9e8d7c # a range (excludes a1b2c3d) git cherry-pick -x a1b2c3d # append "cherry picked from…" to the message git cherry-pick -n a1b2c3d # apply without committing git cherry-pick --continue / --abort / --skip # Which of my commits are not in main? git cherry -v main feature/cart # + missing, - already there
The classic use case: a fix validated on main that has to be ported to the release branch in production. Always use -x: in six months, knowing where a commit came from is worth a lot.
bisect: finding the bad commit
A binary search through history. On a thousand commits, ten tests are enough to isolate the culprit.
git bisect start git bisect bad # HEAD is broken git bisect good v1.7.0 # this version worked # Git checks out the midpoint: you test, then git bisect good # or git bisect bad # … until the verdict git bisect reset # Automated: a script exiting 0 when fine, 1 when broken git bisect start HEAD v1.7.0 git bisect run npm test -- tests/cart.spec.ts
blame and archaeology
git blame -L 40,60 src/vat.ts # author per line git blame -w -C -C src/vat.ts # ignore whitespace, follow moved code git log -L 40,60:src/vat.ts # the history of exactly those lines # Hide a repo-wide reformatting commit from every blame echo "a1b2c3d4e5f6..." >> .git-blame-ignore-revs git config blame.ignoreRevsFile .git-blame-ignore-revs
worktree: several branches on disk
An urgent fix while you have a build running and uncommitted work? Instead of stashing, mount a second working directory on the same repository.
git worktree add ../project-hotfix -b hotfix/vat origin/main cd ../project-hotfix # separate folder, same repository, same objects git worktree list git worktree remove ../project-hotfix
Tags
git tag v1.8.0 # lightweight: just a pointer git tag -a v1.8.0 -m "Release 1.8.0" # annotated: dated, signable object git tag -s v1.8.0 -m "Release 1.8.0" # annotated and signed git tag -a v1.7.3 a1b2c3d # tag after the fact git push origin v1.8.0 git describe --tags # v1.8.0-14-ga1b2c3d: version, distance, hash git tag --sort=-v:refname | head
For anything you ship, always an annotated tag: it carries a date, an author, a message, and can be signed. Lightweight tags are for personal scratch work.
Wholesale rewrites
# Permanently remove a file from all of history git filter-repo --invert-paths --path config/secrets.yml # Extract a subdirectory into a standalone repository git filter-repo --subdirectory-filter packages/ui # Fix an email address throughout history git filter-repo --mailmap mailmap.txt
Chapter 08Conflicts: anatomy, prevention, resolution
A conflict is not an error. It is Git refusing to guess on your behalf, and handing you a decision about the domain.
Why they happen
Git merges by comparing three versions: the common ancestor, yours, theirs. If a region changed on only one side, it applies it. If it changed differently on both sides, it stops. The usual triggers:
- Two edits to the same lines — the textbook case.
- Edited on one side, deleted on the other — a modify/delete conflict, often the most annoying.
- Divergent renames — each side moved the file somewhere else.
- Generated files —
package-lock.json, migrations,.pbxproj: they conflict every time. - Repo-wide reformatting — one formatter run touches 900 files and makes every open branch conflict.
- A branch left open for three weeks — the root cause of nearly every painful conflict.
Reading the markers
By default Git shows you two versions. Configure zdiff3 and it also shows the common ancestor: now you can see what each side changed, instead of comparing two unknowns.
function totalPrice(cart) {
<<<<<<< HEAD
return cart.total * 1.20;
=======
return cart.total + shipping(cart);
>>>>>>> feature/shipping
}
function totalPrice(cart) {
<<<<<<< HEAD
return cart.total * 1.20;
||||||| common ancestor
return cart.total;
=======
return cart.total + shipping(cart);
>>>>>>> feature/shipping
}
With the ancestor visible, the reading is obvious: one side added VAT, the other added shipping. Neither version is correct on its own — you need both. That is exactly the information the default style hides from you.
merge, HEAD/--ours is your branch and --theirs is the one you are bringing in. During a rebase it is inverted: Git replays your commits on top of the other branch, so --ours is the target branch you are rebasing onto and --theirs is your commit being replayed. Always check git status before reaching for --ours.
Resolving, step by step
# 1. Survey the damage git status # "both modified", "deleted by us"… git diff --name-only --diff-filter=U # just the list of conflicted files # 2. Understand what each side did git log --merge -p path/file.ts # the commits from both sides on that file git diff --base path/file.ts # what changed relative to the ancestor git show :1:file.ts # ancestor version git show :2:file.ts # "ours" version git show :3:file.ts # "theirs" version # 3. Edit by hand, or take one side wholesale git checkout --ours config/app.yml # keep my version git checkout --theirs package-lock.json # take theirs git checkout --merge file.ts # restore the conflict markers and start over # Three-pane graphical tool git mergetool # 4. Mark resolved, then check it still builds git add path/file.ts npm test # 5. Finish git merge --continue # or: git rebase --continue / git cherry-pick --continue # At any point, go back to before git merge --abort git rebase --abort
git add checks nothing: leave a >>>>>>> in the file and Git commits it without complaint. Before continuing: git diff --check and git grep -n '^<<<<<<<'. A pre-commit hook that rejects markers takes three lines.
Your turn: resolve this conflict
Two developers touched the same function
On main, Lea added VAT. On feature/shipping, Karim added shipping costs. What should you produce?
<<<<<<< HEAD return cart.total * 1.20; ||||||| common ancestor return cart.total; ======= return cart.total + shipping(cart); >>>>>>> feature/shipping
Avoiding conflicts: what actually works
On cadence
- Branches shorter than two days. This is lever number one, far ahead of everything else.
- A
git fetch && git rebase origin/mainevery morning: you absorb change in small doses instead of one shock at the end. - Push early, even incomplete, as a draft PR: the team can see what you are touching.
- Atomic commits: a conflict on a small commit takes ten seconds to read.
On the team
- Announce big refactors and land them first, alone, in a short window.
- Split the code by domain: two teams editing the same 3,000-line file will always conflict.
- Automatic formatting enforced by a hook and never discussed in review: no more style conflicts.
- Generated files: regenerate instead of merging, and document that.
# Automatically reuse a resolution you already made (repeated rebases) git config --global rerere.enabled true git rerere status / git rerere diff / git rerere forget path # Dry-run a merge without changing anything git merge --no-commit --no-ff origin/main && git merge --abort # Declare a file unmergeable: we regenerate it echo 'package-lock.json merge=ours' >> .gitattributes git config merge.ours.driver true # CHANGELOG: concatenate both sides instead of conflicting echo 'CHANGELOG.md merge=union' >> .gitattributes
rerere, the hidden win
"Reuse recorded resolution". On a long branch rebased five times you resolve the same conflict five times — unless rerere reapplies your decision for you. Turn it on globally today.
The conflicts that do not look like conflicts
Git merges text, not meaning. A merge can succeed with no conflict and still produce broken code: Lea renames getUser() to fetchUser(), Karim adds three calls to getUser() in another file. Zero markers, red build. That is a semantic conflict, and only CI that runs the tests on the merge result catches it. It is the whole reason for "branch must be up to date before merging" rules and merge queues.
Chapter 09Good practices
History is executable documentation. In two years someone — probably you — will need to know why this line exists.
The atomic commit
One commit = one coherent change that builds and passes tests on its own. Practical test: if you have to write "and" in the subject, it is two commits. An atomic commit can be reviewed, reverted, cherry-picked, and bisected. A 40-file commit mixing refactor, fix, and reformatting can do none of those.
Writing a commit message
The most widespread convention in industry is Conventional Commits. It is readable by humans and by machines (changelog generation, semantic version bumps).
fix(cart): apply VAT before the loyalty discount The calculation applied the discount to the gross amount, producing a 0.4% error on orders using a promo code. Accounting policy requires the discount on the net amount, then VAT. The order of operations is now pinned by a regression test covering the three applicable rates. Refs: TICKET-4821 Co-authored-by: Lea Fontaine <lea@example.com>
- Subject — 50 characters, imperative mood, no full stop. "add", not "added" or "adding". The subject completes the sentence "This commit will…".
- Type —
feat,fix,refactor,perf,test,docs,build,ci,chore. A!or aBREAKING CHANGE:footer flags an incompatible change. - Body — wrapped at 72 columns, it explains the why and the context. The what is already in the diff.
- Footers — ticket reference, co-authors,
Reviewed-by.
# A pre-filled template in your editor git config --global commit.template ~/.gitmessage # Client-side validation npx --yes commitlint --edit "$1" # inside a commit-msg hook
Naming branches
| Pattern | Example | Use |
|---|---|---|
feat/… | feat/4821-loyalty-discount | New feature, ticket number first |
fix/… | fix/4902-vat-rounding | Non-urgent bug fix |
hotfix/… | hotfix/1.8.1-payment-down | Production fix |
release/… | release/1.9 | Stabilising a release |
chore/… | chore/bump-node-22 | Maintenance, tooling |
A ticket in the name lets CI, the issue tracker, and the colleague reading git branch -a connect the branch to its context instantly. Lowercase, hyphens, ASCII only.
The non-negotiables
On the repository
mainprotected: no direct push, no force, no deletion.- Pull request required, at least one approval, CI green.
- Linear history enforced, or merges only through the platform.
- Automatic branch deletion after merge.
- Blocking secret scanning on the server side.
On your machine
- Never a secret in a commit, not even "temporarily".
--force-with-leaseinstead of--force.git statusbefore every commit,git diff --stagedbefore every push.- Large binaries go to Git LFS, never into the object store.
- Never commit a conflict resolution without re-running the tests.
Hooks
Local hooks (in .git/hooks) are not versioned and can be bypassed with --no-verify: they are for convenience, not for guarantees. Guarantees live on the server.
# Version the hooks and point Git at them (Git ≥ 2.9)
git config core.hooksPath .githooks
# .githooks/pre-commit
#!/bin/sh
git diff --cached --check || exit 1 # stray whitespace
git diff --cached --name-only -z | xargs -0 grep -lE '^<{7} ' && {
echo "Conflict markers in the index"; exit 1; }
npx lint-staged
| Hook | When | Typical use |
|---|---|---|
pre-commit | Before the message is written | Format and lint the staged files |
commit-msg | Message written | Validate Conventional Commits |
pre-push | Before sending | Fast unit tests |
pre-receive | Server side, before accepting | Reject force-push, scan for secrets |
Large files
git lfs install git lfs track "*.psd" "*.mp4" "assets/**/*.png" git add .gitattributes # LFS tracking is versioned here git lfs ls-files git lfs migrate import --include="*.mp4" # convert existing history
Without LFS, a 50 MB binary modified twenty times weighs a gigabyte in the repository, permanently, for everyone. Put the rule in place before the first asset import, not after.
Monorepo or many repositories
- Submodules — a pointer to one exact commit of another repository. Clean isolation, but every clone and update needs extra commands (
git clone --recurse-submodules,git submodule update --remote), and detached-HEAD traps abound. - Subtree — the external repository's code is copied into your tree. Transparent for the team, heavier to contribute back upstream.
- Monorepo — one repository, atomic cross-cutting changes, and dedicated tooling (partial clone,
sparse-checkout) that becomes mandatory past a certain size.
git clone --filter=blob:none --sparse URL cd project git sparse-checkout set apps/web libs/ui git config core.fsmonitor true # filesystem monitor git maintenance start # scheduled optimisation
Chapter 10Choosing a team workflow
There is no best workflow, only a fit between your release cadence and your branch topology.
| GitHub Flow | Git Flow | Trunk-based | |
|---|---|---|---|
| Long-lived branches | main | main + develop | main only |
| Branch lifetime | 1 to 5 days | Days to weeks | Hours to a day |
| Release cadence | Continuous | Scheduled releases | Several times a day |
| Multiple supported versions | No | Yes, natively | Via release branches |
| Cost of conflicts | Low | High | Very low |
| Prerequisites | CI and review | Process discipline | Strong tests, feature flags |
| Fits | SaaS, web teams | Installed software, supported versions | Mature teams, continuous delivery |
The deciding factor: feature flags
Trunk-based only works if you can merge incomplete code without making it visible. An application-level switch replaces the long branch: the code ships disabled, you enable it for 1% of users, then for everyone. Without that mechanism, a team trying trunk-based ends up stacking long branches in disguise.
In practice, a hybrid
The most common industry setup today: main always shippable, very short feature branches, a release/x.y branch cut at freeze time for stabilisation, and hotfix/ branches starting from the production tag and cherry-picked back onto main. That is the model the next chapter walks through, from ticket to production fix.
Chapter 11A full cycle in industry
Cotidal, a billing SaaS vendor. Eight developers, two releases a week, one supported version at installed customers. Here are two real weeks, command by command.
main always shippable, short feature branches integrated by squash, a frozen release branch, and an urgent fix started from the production tag and cherry-picked back onto main.The repository rules
Branches main (protected) · release/x.y (protected) · feat|fix|hotfix|chore/<ticket>-<slug> Integration squash into main · merge --no-ff into release/* Messages Conventional Commits, ticket footer required Merging green CI + 1 approval + branch up to date Releases annotated signed tag vX.Y.Z on release/x.y Forbidden direct push to main · --force on a shared branch · plaintext secrets
-
1
Monday — starting a ticket
Karim · TICKET-4821 "loyalty discount"Always start from the up-to-date remote state, never from a local
mainthat is four days old.opening a branchgit switch main git fetch origin --prune git merge --ff-only origin/main git switch -c feat/4821-loyalty-discount
-
2
Monday afternoon — atomic commits and a draft PR
KarimThe pull request opens on the first push, as a draft: CI runs, the team sees the scope, nobody starts on the same files.
first commitsgit add -p src/domain/discount.ts git commit -m "feat(discount): add loyalty tier calculation Refs: TICKET-4821" git add -p tests/discount.spec.ts git commit -m "test(discount): cover the three tiers Refs: TICKET-4821" git push -u origin feat/4821-loyalty-discount gh pr create --draft --fill --base main
-
3
Tuesday morning — resynchronise
KarimA daily ritual. Absorbing
mainin small doses turns a future 200-line conflict into three five-line ones.daily rebasegit fetch origin git rebase origin/main # rerere and autostash are enabled npm test git push --force-with-lease # the branch is mine: rewriting is legitimate
-
4
Tuesday — a conflict in the invoicing service
Karim · conflicting with Lea's workLea extracted the VAT calculation into its own module while Karim was adding the discount in the same place. Diagnose, then resolve in a way that keeps both intentions.
resolutiongit status # both modified: src/domain/invoice.ts git log --merge --oneline -- src/domain/invoice.ts git diff --base src/domain/invoice.ts # Careful: during a rebase, --theirs means MY replayed commit $EDITOR src/domain/invoice.ts # call computeVat() on the discounted total git grep -n '^<<<<<<<' ; git diff --check npm test -- tests/invoice.spec.ts git add src/domain/invoice.ts git rebase --continue git push --force-with-lease
-
5
Wednesday — code review
Lea reviews, Karim fixesTwo comments concern the second commit. Rather than a "review feedback" commit at the tip, the fixes are attached to the commit they belong to.
targeted fixesgit log --oneline -4 # b7c9d21 test(discount): cover the three tiers # a1b2c3d feat(discount): add loyalty tier calculation git add -p git commit --fixup=a1b2c3d git commit --fixup=b7c9d21 git rebase -i --autosquash origin/main # the fixups get absorbed git push --force-with-lease gh pr ready
-
6
Thursday — merging into
Automated, through the platformmainThe merge happens on the platform, not locally: it checks the branch is up to date, that CI is green on the merge result, and deletes the branch afterwards. Squashing gives one commit per unit of work on
main.merge and clean upgh pr merge --squash --delete-branch # Locally git switch main git fetch origin --prune git merge --ff-only origin/main git branch --merged main | grep -v '\*\|main' | xargs -r git branch -d
-
7
Friday — freeze and release branch
Release managermainkeeps moving while 1.9 stabilises. No feature enters the release branch any more: only fixes, merged with--no-ffso every batch stays identifiable.release freezegit switch -c release/1.9 origin/main git push -u origin release/1.9 # Pre-release tag for QA git tag -as v1.9.0-rc.1 -m "Release candidate 1.9.0" git push origin v1.9.0-rc.1 # A fix found in QA git switch -c fix/4950-rounding release/1.9 # … commits, PR into release/1.9, merged with --no-ff # Ship it git switch release/1.9 git tag -as v1.9.0 -m "Release 1.9.0" git push origin v1.9.0 # the tag triggers the production pipeline
-
8
Sunday, 2 a.m. — production incident
On call · payments failingStart from the exact tag that is deployed, not from
main, which already carries ten unvalidated commits. Aworktreeavoids disturbing whatever is in progress on the machine.urgent fixgit fetch origin --tags git worktree add ../cotidal-hotfix -b hotfix/1.9.1-payment v1.9.0 cd ../cotidal-hotfix # One commit, as small as possible git commit -am "fix(payment): restore the idempotency header Calls to the provider have been sent without an idempotency key since 1.9.0, causing one order in five to be rejected. Refs: INC-118" git push -u origin hotfix/1.9.1-payment gh pr create --base release/1.9 --title "hotfix 1.9.1 — payment" --fill
-
9
Sunday, 3 a.m. — ship and port back
On callThe step teams forget: a fix applied only to the release branch reappears in the next version. It has to go back onto
main.ship, then port to main# Merge into the release branch and ship gh pr merge --merge # --no-ff: the batch stays identifiable git switch release/1.9 && git pull --ff-only git tag -as v1.9.1 -m "Release 1.9.1 — payment fix" git push origin v1.9.1 # Port to main, keeping provenance git switch main && git pull --ff-only git switch -c chore/port-hotfix-1.9.1 git cherry-pick -x a1b2c3d npm test git push -u origin chore/port-hotfix-1.9.1 gh pr create --base main --fill # Check no fix has been forgotten git cherry -v main release/1.9 # "+" lines are not in main yet
-
10
Monday — post-mortem and archaeology
The whole teamWhen did that header disappear? The pickaxe and bisect answer in two minutes, and the answer feeds the retrospective.
investigation# Which commit removed this string? git log -S"Idempotency-Key" --oneline --all # Which release first contains that commit? git tag --contains a1b2c3d | sort -V | head -1 git describe --contains a1b2c3d # Confirm by automated bisection git bisect start v1.9.0 v1.8.4 git bisect run npm test -- tests/payment.spec.ts git bisect reset # And the corrective action: a regression test plus a review rule
What this cycle guarantees
- Traceability — every line in production traces back to a commit, a pull request, a ticket, and a signed tag.
- Reversibility — a batch merged by squash or
--no-ffreverts in one command. - Reproducibility — an annotated tag identifies exactly what is running at a customer.
- Contained conflicts — short branches, daily rebase,
rerere, automatic formatting. - Nothing manual on protected branches — the platform merges, tags, and deploys.
Chapter 12Help, I broke something
Almost everything is repairable. The reflog keeps a record of where you have been for 90 days.
I committed on the wrong branch
# Not pushed yet: carry the commits elsewhere git switch -c the-right-branch # branches from here, commits included git switch main git reset --hard origin/main # put main back where it belongs # A single commit to move git switch the-right-branch && git cherry-pick main git switch main && git reset --hard HEAD~1
I ran reset --hard and lost my work
git reflog # find the line from before
git reset --hard HEAD@{1}
# Uncommitted work lost to reset --hard is unrecoverable,
# UNLESS it had been staged: then an orphaned blob still exists
git fsck --lost-found
git show <hash> > recovered.txt
I pushed a secret
pip install git-filter-repo git filter-repo --invert-paths --path config/secrets.yml # Or only the sensitive content, keeping the file git filter-repo --replace-text replacements.txt git push --force --all && git push --force --tags # Then: everyone re-clones, open pull requests must be recreated, # and you ask the platform to purge its caches.
Other common situations
| Symptom | Remedy |
|---|---|
| Typo in the last commit message | git commit --amend |
| Typo in an older message | git rebase -i then reword |
| Forgot a file in the last commit | git add f && git commit --amend --no-edit |
| The rebase is an endless conflict loop | git rebase --abort, then a plain merge |
| Detached HEAD, I committed into the void | git switch -c recovery (commits come along) |
| A pushed merge needs undoing | git revert -m 1 <merge> |
| A revert I now want to undo | git revert <the-revert> |
| Branch deleted by mistake | git reflog then git switch -c name <hash> |
| File tracked that should be ignored | git rm --cached f + a .gitignore rule |
| The repository got very slow | git gc --aggressive, git maintenance start |
| Push rejected: "non-fast-forward" | git pull --rebase, then push again |
| Hopeless conflict in a generated file | git checkout --ours f, then regenerate |
Chapter 13Cheat sheet
Filter by keyword: command, effect, or intent.
| Command | Effect | Family |
|---|---|---|
git init | Create a repository in the current folder | basics |
git clone --depth 1 URL | Shallow clone, no history | basics remote |
git status -sb | Compact state with tracking info | basics |
git add -p | Stage hunk by hunk | basics index |
git restore --staged f | Unstage without losing work | undo index |
git restore f | Discard local edits to the file | undo |
git restore -s HEAD~2 f | Take the file as it was then | undo history |
git commit --amend | Rewrite the last commit | basics history |
git commit --fixup=SHA | Commit meant to be absorbed into SHA | history review |
git diff --staged | What the next commit will contain | basics index |
git diff main...HEAD | My branch against the common ancestor | basics review |
git diff --check | Catch stray whitespace and markers | conflict quality |
git log --oneline --graph --all | The whole graph in one view | history |
git log --first-parent | Branch story without merge internals | history |
git log -S"text" | Commits adding or removing that code | history investigation |
git log -L 10,20:f | The history of exactly those lines | history investigation |
git log --merge -p f | Commits from both sides of a conflict | conflict |
git blame -w -C -C f | Blame ignoring format and moved code | investigation |
git switch -c name | Create a branch and move to it | branch |
git switch - | Back to the previous branch | branch |
git branch -vv | Branches, tracking, ahead and behind | branch remote |
git branch --merged main | Integrated branches, safe to delete | branch cleanup |
git merge --no-ff b | Merge and create a merge commit | merge |
git merge --ff-only b | Merge only if it fast-forwards | merge |
git merge --squash b | Bring the work in as one commit | merge |
git merge --abort | Cancel the merge in progress | merge conflict |
git rebase origin/main | Replay my commits on top of the remote | history |
git rebase -i --autosquash | Clean the branch and absorb fixups | history review |
git rebase --continue | Resume after resolving a conflict | conflict history |
git cherry-pick -x SHA | Replay a commit, recording its origin | history release |
git cherry -v main branch | What has not been ported yet | release |
git revert SHA | Create the inverse commit | undo |
git revert -m 1 SHA | Undo a merge commit | undo merge |
git reset --soft HEAD~1 | Uncommit, keep everything staged | undo |
git reset --hard HEAD@{1} | Back to the state before the mistake | undo rescue |
git reflog | Every position HEAD has held | rescue |
git fsck --lost-found | Find orphaned objects | rescue |
git stash push -u -m "x" | Park work, untracked files included | stash |
git stash branch name | Turn a stash into a branch | stash branch |
git fetch --all --prune | Update and clean remote refs | remote |
git pull --rebase | Fetch and replay my work on top | remote |
git push -u origin b | Publish the branch and set up tracking | remote |
git push --force-with-lease | Rewrite the remote without clobbering others | remote history |
git push origin --delete b | Delete the remote branch | remote cleanup |
git checkout --ours f | Keep my version of the conflicted file | conflict |
git checkout --theirs f | Take theirs | conflict |
git checkout --merge f | Restore the conflict and start over | conflict |
git mergetool | Open the three-pane merge tool | conflict |
git show :1:f / :2: / :3: | Ancestor, ours, and theirs versions | conflict |
git rerere | Reapply a resolution you already made | conflict |
git tag -as vX.Y.Z -m "…" | Annotated, signed tag | release |
git describe --tags | Readable version since the last tag | release |
git tag --contains SHA | Releases that include this commit | release investigation |
git bisect run cmd | Find the bad commit automatically | investigation |
git worktree add ../d -b b | Second working copy, same repository | branch |
git sparse-checkout set dir | Materialise only part of the repository | monorepo |
git lfs track "*.mp4" | Keep large binaries out of Git objects | monorepo quality |
git check-ignore -v f | Which rule ignores this file | basics quality |
git rm --cached f | Untrack a file without deleting it | basics cleanup |
git clean -nd | List what a clean would delete | cleanup |
git maintenance start | Scheduled repository optimisation | cleanup |
git filter-repo --invert-paths | Erase a path from all of history | history rescue |
Going further
- Pro Git, Chacon & Straub — free, complete, the reference.
- Git from the Bottom Up — starts at the objects and works up to the commands.
- Learn Git Branching — the graph as a direct manipulation puzzle, excellent for rebase.
git help -gand thengit help revisions— the full syntax ofHEAD~3^2,@{upstream},branch@{2.days.ago}.