Course — version control · 13 chapters · beginner to advanced - Generated for Waifly by Claude AI

Git, from mental model to production

Understand the graph, master the commands that matter, resolve conflicts without panic, and run all of it inside a team that ships.

git version 2.4x Every command is safe to try on a throwaway repository Last reviewed: 2026

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

    a normal day, start to finish
    # 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
    Three habits worth more than any command Read 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.
    Two things never to do Never 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

    ObjectContainsAnalogy
    blobRaw file content, with no name and no permissionsThe text on a page
    treeA list of names mapping to blobs or other trees, with modesA folder
    commitA root tree, 0..n parents, author, committer, date, messageA timestamped, signed photograph
    annotated tagA named pointer to an object, with a message and a signatureA 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.

    exploring the objects by hand
    # 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.

    looking at the plumbing
    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

    Working treeYour files, as your editor sees them.git status
    Index (staging area)A draft of the next commit. You decide what goes in.git add / git restore --staged
    Local repositoryObjects and branches, inside .git. Offline and complete.git commit / git log
    Remote repositoryA copy on a server. Synchronised only when you say so.git fetch / git push

    The 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".

    Three-minute exercise Make a throwaway repository and break it safely: 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

    ~/.gitconfig — the baseline
    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.

    useful aliases
    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.

    .gitattributes
    # 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.

    ignoring things properly
    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
    Careful .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.

    signing with an SSH key
    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:

    ~/.gitconfig
    [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

    init and clone
    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.

    surgical staging
    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
    Tip Inside 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

    commit
    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
    Golden rule --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, for real
    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…CommandEffect
    Throw away my edits to a filegit restore fileUncommitted work is gone for good
    Unstage without losing workgit restore --staged fileLeaves the index, keeps the disk
    Take a file from another commitgit restore -s HEAD~2 fileOverwrites the local version
    Undo the last commit, keep it stagedgit reset --soft HEAD~1Changes stay ready to recommit
    Undo the last commit, keep the workgit reset HEAD~1--mixed mode: index cleared, disk intact
    Undo the last commit and drop everythinggit reset --hard HEAD~1Destructive. Recoverable via the reflog
    Undo a commit that is already sharedgit revert a1b2c3dCreates the inverse commit. History preserved
    Undo a merge that is already pushedgit revert -m 1 <merge>-m 1 = keep the first-parent line
    Drop everything, untracked files toogit clean -fdAlways 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

    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.

    the three integrations
    # 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
    Criterionmerge --no-ffrebase + ffsquash
    History shapeBranched, faithfulLinearLinear, one commit per batch
    Original hashesPreservedRewrittenLost
    git bisectWorksIdealCoarse granularity
    ConflictsOnce, at merge timePossibly on every replayed commitOnce
    Traceability of the batchThe merge commitWeak without conventionExcellent
    Best forRelease merges, large batchesShort single-author branchesMessy branches, simple fixes
    The rule that avoids 90% of the drama Never rewrite the history of a branch other people have fetched. Rebase your own branch as much as you like; never rebase 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

    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.

    syncing without surprises
    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

    push
    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
    Always --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

    reviewing a pull request locally
    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
    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
    the todo list (oldest to newest)
    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.

    fixing a commit in the middle
    # 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.

    recovering what looks lost
    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

    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}
    Use the stash sparingly A stash is invisible, unnamed by default, and absent from 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

    cherry-pick
    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.

    bisect, manual then automated
    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

    who, when, why
    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.

    worktree
    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

    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

    git filter-repo (replaces filter-branch)
    # 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
    Coordinate this one A global rewrite changes every hash: everyone has to re-clone. It gets announced, scheduled, and done with branches frozen. Open pull requests become unusable.

    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 filespackage-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.

    default style: two versions
    function totalPrice(cart) {
    <<<<<<< HEAD
      return cart.total * 1.20;
    =======
      return cart.total + shipping(cart);
    >>>>>>> feature/shipping
    }
    merge.conflictStyle = zdiff3: ancestor included
    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.

    The orientation trap During a 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

    the full procedure
    # 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
    The "resolved" trap 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?

    src/cart.ts — conflicted
    <<<<<<< 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/main every 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.
    prevention tooling
    # 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).

    anatomy of a good message
    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…".
    • Typefeat, fix, refactor, perf, test, docs, build, ci, chore. A ! or a BREAKING 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.
    tooling the convention
    # 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

    PatternExampleUse
    feat/…feat/4821-loyalty-discountNew feature, ticket number first
    fix/…fix/4902-vat-roundingNon-urgent bug fix
    hotfix/…hotfix/1.8.1-payment-downProduction fix
    release/…release/1.9Stabilising a release
    chore/…chore/bump-node-22Maintenance, 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

    • main protected: 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-lease instead of --force.
    • git status before every commit, git diff --staged before 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.

    shared hooks
    # 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
    HookWhenTypical use
    pre-commitBefore the message is writtenFormat and lint the staged files
    commit-msgMessage writtenValidate Conventional Commits
    pre-pushBefore sendingFast unit tests
    pre-receiveServer side, before acceptingReject force-push, scan for secrets

    Large files

    Git LFS
    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.
    surviving a big monorepo
    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 FlowGit FlowTrunk-based
    Long-lived branchesmainmain + developmain only
    Branch lifetime1 to 5 daysDays to weeksHours to a day
    Release cadenceContinuousScheduled releasesSeveral times a day
    Multiple supported versionsNoYes, nativelyVia release branches
    Cost of conflictsLowHighVery low
    PrerequisitesCI and reviewProcess disciplineStrong tests, feature flags
    FitsSaaS, web teamsInstalled software, supported versionsMature 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.

    The target graph: 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

    Cotidal conventions (excerpt from CONTRIBUTING.md)
    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. 1

      Monday — starting a ticket

      Karim · TICKET-4821 "loyalty discount"

      Always start from the up-to-date remote state, never from a local main that is four days old.

      opening a branch
      git switch main
      git fetch origin --prune
      git merge --ff-only origin/main
      git switch -c feat/4821-loyalty-discount
    2. 2

      Monday afternoon — atomic commits and a draft PR

      Karim

      The pull request opens on the first push, as a draft: CI runs, the team sees the scope, nobody starts on the same files.

      first commits
      git 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. 3

      Tuesday morning — resynchronise

      Karim

      A daily ritual. Absorbing main in small doses turns a future 200-line conflict into three five-line ones.

      daily rebase
      git 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. 4

      Tuesday — a conflict in the invoicing service

      Karim · conflicting with Lea's work

      Lea 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.

      resolution
      git 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. 5

      Wednesday — code review

      Lea reviews, Karim fixes

      Two 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 fixes
      git 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. 6

      Thursday — merging into main

      Automated, through the platform

      The 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 up
      gh 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. 7

      Friday — freeze and release branch

      Release manager

      main keeps moving while 1.9 stabilises. No feature enters the release branch any more: only fixes, merged with --no-ff so every batch stays identifiable.

      release freeze
      git 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. 8

      Sunday, 2 a.m. — production incident

      On call · payments failing

      Start from the exact tag that is deployed, not from main, which already carries ten unvalidated commits. A worktree avoids disturbing whatever is in progress on the machine.

      urgent fix
      git 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. 9

      Sunday, 3 a.m. — ship and port back

      On call

      The 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. 10

      Monday — post-mortem and archaeology

      The whole team

      When 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-ff reverts 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

    moving commits
    # 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

    walking back through the reflog
    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

    The secret is compromised, full stop It has been cloned, cached by the platform, and indexed by bots. First action: revoke and rotate the key. Cleaning history comes second, and never replaces rotation.
    purge, after rotation
    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

    SymptomRemedy
    Typo in the last commit messagegit commit --amend
    Typo in an older messagegit rebase -i then reword
    Forgot a file in the last commitgit add f && git commit --amend --no-edit
    The rebase is an endless conflict loopgit rebase --abort, then a plain merge
    Detached HEAD, I committed into the voidgit switch -c recovery (commits come along)
    A pushed merge needs undoinggit revert -m 1 <merge>
    A revert I now want to undogit revert <the-revert>
    Branch deleted by mistakegit reflog then git switch -c name <hash>
    File tracked that should be ignoredgit rm --cached f + a .gitignore rule
    The repository got very slowgit gc --aggressive, git maintenance start
    Push rejected: "non-fast-forward"git pull --rebase, then push again
    Hopeless conflict in a generated filegit checkout --ours f, then regenerate

    Chapter 13Cheat sheet

    Filter by keyword: command, effect, or intent.

    CommandEffectFamily
    git initCreate a repository in the current folderbasics
    git clone --depth 1 URLShallow clone, no historybasics remote
    git status -sbCompact state with tracking infobasics
    git add -pStage hunk by hunkbasics index
    git restore --staged fUnstage without losing workundo index
    git restore fDiscard local edits to the fileundo
    git restore -s HEAD~2 fTake the file as it was thenundo history
    git commit --amendRewrite the last commitbasics history
    git commit --fixup=SHACommit meant to be absorbed into SHAhistory review
    git diff --stagedWhat the next commit will containbasics index
    git diff main...HEADMy branch against the common ancestorbasics review
    git diff --checkCatch stray whitespace and markersconflict quality
    git log --oneline --graph --allThe whole graph in one viewhistory
    git log --first-parentBranch story without merge internalshistory
    git log -S"text"Commits adding or removing that codehistory investigation
    git log -L 10,20:fThe history of exactly those lineshistory investigation
    git log --merge -p fCommits from both sides of a conflictconflict
    git blame -w -C -C fBlame ignoring format and moved codeinvestigation
    git switch -c nameCreate a branch and move to itbranch
    git switch -Back to the previous branchbranch
    git branch -vvBranches, tracking, ahead and behindbranch remote
    git branch --merged mainIntegrated branches, safe to deletebranch cleanup
    git merge --no-ff bMerge and create a merge commitmerge
    git merge --ff-only bMerge only if it fast-forwardsmerge
    git merge --squash bBring the work in as one commitmerge
    git merge --abortCancel the merge in progressmerge conflict
    git rebase origin/mainReplay my commits on top of the remotehistory
    git rebase -i --autosquashClean the branch and absorb fixupshistory review
    git rebase --continueResume after resolving a conflictconflict history
    git cherry-pick -x SHAReplay a commit, recording its originhistory release
    git cherry -v main branchWhat has not been ported yetrelease
    git revert SHACreate the inverse commitundo
    git revert -m 1 SHAUndo a merge commitundo merge
    git reset --soft HEAD~1Uncommit, keep everything stagedundo
    git reset --hard HEAD@{1}Back to the state before the mistakeundo rescue
    git reflogEvery position HEAD has heldrescue
    git fsck --lost-foundFind orphaned objectsrescue
    git stash push -u -m "x"Park work, untracked files includedstash
    git stash branch nameTurn a stash into a branchstash branch
    git fetch --all --pruneUpdate and clean remote refsremote
    git pull --rebaseFetch and replay my work on topremote
    git push -u origin bPublish the branch and set up trackingremote
    git push --force-with-leaseRewrite the remote without clobbering othersremote history
    git push origin --delete bDelete the remote branchremote cleanup
    git checkout --ours fKeep my version of the conflicted fileconflict
    git checkout --theirs fTake theirsconflict
    git checkout --merge fRestore the conflict and start overconflict
    git mergetoolOpen the three-pane merge toolconflict
    git show :1:f / :2: / :3:Ancestor, ours, and theirs versionsconflict
    git rerereReapply a resolution you already madeconflict
    git tag -as vX.Y.Z -m "…"Annotated, signed tagrelease
    git describe --tagsReadable version since the last tagrelease
    git tag --contains SHAReleases that include this commitrelease investigation
    git bisect run cmdFind the bad commit automaticallyinvestigation
    git worktree add ../d -b bSecond working copy, same repositorybranch
    git sparse-checkout set dirMaterialise only part of the repositorymonorepo
    git lfs track "*.mp4"Keep large binaries out of Git objectsmonorepo quality
    git check-ignore -v fWhich rule ignores this filebasics quality
    git rm --cached fUntrack a file without deleting itbasics cleanup
    git clean -ndList what a clean would deletecleanup
    git maintenance startScheduled repository optimisationcleanup
    git filter-repo --invert-pathsErase a path from all of historyhistory 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 -g and then git help revisions — the full syntax of HEAD~3^2, @{upstream}, branch@{2.days.ago}.

    Back to the starter kit