Practice
Git
Inspecting history, undoing changes safely, resolving conflicts and the object model that explains why each command behaves as it does.
Cheatsheet #
| Task | Command |
|---|---|
| What is going on | git status -sb |
| Staged vs working tree | git diff (unstaged), git diff --staged |
| Compact history | git log --oneline --graph --decorate --all |
| Who changed this line | git blame -L 10,20 file |
| Unstage a file | git restore --staged file |
| Discard local changes | git restore file |
| Undo last commit, keep changes | git reset --soft HEAD~1 |
| Undo a pushed commit | git revert <sha> |
| Amend the last commit | git commit --amend --no-edit |
| Park work | git stash push -m wip / git stash pop |
| Recover anything | git reflog |
| File from another branch | git restore --source main -- path |
| Search all history for text | git log -S 'needle' --oneline |
| Rebase onto latest main | git fetch && git rebase origin/main |
| Branches already merged | git branch --merged main |
| Delete remote branch | git push origin --delete feature-x |
| Second working tree | git worktree add ../hotfix hotfix |
| Who owns the most churn | git shortlog -sn --no-merges |
The model in one paragraph #
Git stores snapshots as immutable objects: blobs (content), trees (directories), commits (a tree plus parents plus metadata). A branch is a movable pointer to a commit, HEAD points at the current branch, and the index is a staging area between the working tree and the next commit. Every command that “changes history” writes new objects and moves a pointer — the old commits stay reachable through the reflog until garbage collection, which is why almost nothing is truly lost.
git cat-file -p HEAD # the commit object: tree, parent, author
git cat-file -p HEAD^{tree} # its directory listing
git rev-parse HEAD # resolve a ref to a SHAInspecting #
git status -sb # short status plus tracking info
git log --oneline --graph --decorate --all # topology at a glance
git log --since='2 weeks' --author=jodis --stat
git log -p -- path/to/file # history of one file, with diffs
git log --follow -- path/to/file # ...across renames
git log -S 'AWS_SECRET' --oneline # commits that add or remove a string
git log -G 'regex' --oneline # commits whose diff matches a regex
git log --grep 'JIRA-123' --oneline # commits whose message matches
git log main..feature --oneline # in feature, not in main
git log --left-right --oneline main...feature # divergence on both sides
git show <sha> --stat
git blame -L 20,40 -- file
git diff main...feature # changes since the branches divergedA..B means “reachable from B but not A”; A...B in log means the symmetric difference, but in diff it means “changes on B since it diverged from A”. The inconsistency is historical and worth memorising.
Undoing #
Pick the command by what you need to preserve.
| Goal | Command | Safe on a pushed branch |
|---|---|---|
| Unstage, keep the edit | git restore --staged file | Yes |
| Throw away local edits | git restore file | Yes, but the edit is gone |
| Undo commit, keep staged | git reset --soft HEAD~1 | No |
| Undo commit, keep working tree | git reset HEAD~1 | No |
| Undo commit and the changes | git reset --hard HEAD~1 | No, and the work is gone |
| Reverse a commit with a new one | git revert <sha> | Yes — this is the one for shared branches |
| Fix the last commit’s message | git commit --amend | No |
| Restore a deleted file | git restore --source HEAD~1 -- path | Yes |
git restore --source main -- config/app.yaml # one file from another branch
git revert -m 1 <merge-sha> # revert a merge, keeping mainline 1
git reset --hard origin/main # make local match remote exactly
git clean -nd # preview untracked removal
git clean -fd # remove untracked files and directoriesreset --hard and clean -fd delete work that was never committed
The reflog can recover commits, not uncommitted edits. Run git stash push -u first if there is any doubt.
Reflog: the undo history #
Every move of HEAD is recorded locally for 90 days by default, including resets, rebases and checkouts.
git reflog # HEAD movements with reasons
git reflog show feature-x # one branch's movements
git reset --hard HEAD@{2} # back to where you were two moves ago
git branch rescue <sha-from-reflog> # recover a deleted branch
git fsck --lost-found # dangling commits, when even reflog was prunedBranches #
git switch -c feature-x # create and switch (modern spelling)
git switch - # previous branch
git switch --detach <sha> # look around without moving a branch
git branch -vv # local branches and their upstreams
git branch --merged main # already in main: safe to delete
git branch --no-merged main # still carrying unique commits
git push -u origin feature-x # push and set upstream
git push origin --delete feature-x
git fetch --prune # drop remote-tracking refs that no longer exist# Delete every local branch already merged into main
git branch --merged main | grep -vE '^\*|main|master' | xargs -r git branch -dRebase and merge #
Merge preserves what actually happened; rebase produces a history that reads as if the work were done in order. Rebase your own unpublished branch, merge everything shared.
git fetch origin
git rebase origin/main # replay my commits on top of main
git rebase -i origin/main # squash, reword, drop, reorder
git rebase --continue | --skip | --abort
git merge --no-ff feature-x # keep the branch's shape in history
git merge --squash feature-x # one commit, no merge record
git pull --rebase # avoid merge commits from pulling
git config --global pull.rebase truegit push --force-with-lease instead of --force: it refuses when the remote moved since your last fetch, so it cannot silently overwrite a colleague’s push.
Conflicts #
A conflict is two commits changing the same region. Git stages what it could resolve and leaves the rest marked.
git status # "both modified" lists the conflicts
git diff --diff-filter=U # only unresolved files
git checkout --ours -- file # keep the current branch's version
git checkout --theirs -- file # keep the incoming version
git add file && git rebase --continue
git merge --abort # back to before the merge
git rerere status # replay a previously recorded resolution
git config --global rerere.enabled trueDuring a rebase “ours” is the branch being replayed onto (upstream) and “theirs” is your commit, which is the reverse of a merge. Read git status rather than trusting the words.
Stash and worktrees #
git stash push -u -m 'wip: auth refactor' # -u includes untracked files
git stash list
git stash show -p stash@{0}
git stash pop # apply and drop
git stash apply stash@{1} # apply and keep
git stash branch fix-auth stash@{0} # turn a stash into a branch
git worktree add ../hotfix hotfix # second checkout, same repository
git worktree list
git worktree remove ../hotfixWorktrees beat stashing for “I need to look at another branch right now”: no context switch, no risk of popping into the wrong tree.
Remotes and authentication #
git remote -v
git remote set-url origin git@github.com:example/repo.git
git config --global credential.helper 'cache --timeout=3600'
git config --global url."git@github.com:".insteadOf "https://github.com/"
git clone --filter=blob:none <url> # partial clone: history without old blobs
git clone --depth 1 <url> # shallow, for CI
git fetch --unshallow # turn a shallow clone into a full oneStore credentials in a helper, never in the remote URL: a token in origin leaks through git remote -v, CI logs and shell history.
Tags #
git tag -a v1.4.2 -m 'release 1.4.2' # annotated: has an author, date and message
git tag -l 'v1.*' --sort=-v:refname
git push origin v1.4.2
git push origin --tags
git tag -d v1.4.2 && git push origin :refs/tags/v1.4.2
git describe --tags --always # human-readable version from the nearest tagLightweight tags (git tag v1) are a bare pointer with no metadata — fine for scratch marks, wrong for releases.
Bisect #
git bisect start
git bisect bad # current commit is broken
git bisect good v1.4.0 # this one was fine
# ...test, then mark each step
git bisect run ./test.sh # or automate: exit 0 = good, non-zero = bad
git bisect resetgit bisect run with a script that reproduces the bug turns “somewhere in 300 commits” into eight or nine automatic steps.
Hooks and configuration #
git config --global user.name 'Jodis Fields'
git config --global user.email 'you@example.com'
git config --global commit.gpgsign true
git config --global init.defaultBranch main
git config --global core.excludesFile ~/.gitignore
git config --global rebase.autosquash true
git config --list --show-origin # which file set what# .git/hooks/pre-commit — reject obvious secrets
#!/usr/bin/env bash
if git diff --cached -U0 | grep -nE 'AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH) PRIVATE KEY'; then
echo 'possible credential in staged changes' >&2
exit 1
fiHooks live in .git/hooks and are not cloned. Set core.hooksPath to a tracked directory to share them.
Oneliners #
# Branches by last commit date, newest first
git for-each-ref --sort=-committerdate refs/heads --format='%(committerdate:short) %(refname:short) %(authorname)'
# Commits per author, excluding merges
git shortlog -sn --no-merges
# Files changed most often
git log --format=format: --name-only | sort | uniq -c | sort -rn | head
# Largest objects in the repository
git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | awk '$1=="blob"' | sort -k3 -nr | head
# What landed between two tags
git log --oneline --no-merges v1.4.0..v1.5.0
# Everything that touched a directory last month
git log --since='1 month' --oneline -- infra/
# Which branches contain a commit
git branch -a --contains <sha>
# The commit that introduced a string
git log -S 'deprecatedFlag' --oneline --reverse | head -1
# Diff ignoring whitespace churn
git diff -w --ignore-blank-lines
# Show a file as it was at a date
git show 'HEAD@{2024-01-15}:path/to/file'
# Apply one commit from another branch
git cherry-pick -x <sha>
# Stage only part of a file
git add -p file
# Rewrite author on the last commit after fixing config
git commit --amend --reset-author --no-edit
# Repository size and object count
git count-objects -vH
# Prune and repack a bloated clone
git gc --prune=now --aggressive