Git Advanced
git add -p— the single best command for clean commits.git rebase -i— squash and rewrite before you push.git reflog— your undo log. Nothing is gone for 90 days.git worktree— two branches checked out, no stashing.git log -S 'string'— find when code appeared or disappeared.
Rebase: interactive, onto, autosquash
Interactive squash
# Rewrite the last 5 commits
git rebase -i HEAD~5
# Editor opens:
# pick a1b2c3d Add user model
# squash e4f5g6h fix typo
# squash h7i8j9k address review
# reword k0l1m2n Add tests
# drop n3o4p5q debug print
Verbs:
pick— keep as-is.reword— keep the change; edit the message.edit— pause here so you can amend. Rebase continues withgit rebase --continue.squash/s— fold into the previous commit; combine messages.fixup/f— fold in, discard this commit's message.drop/d— delete it entirely.exec— run a shell command after that step (exec make test— bisect-friendly).
--onto: move a range to a new base
You branched feature off develop, but work now lives on main. Move the feature's commits onto main:
# A---B---C feature
# /
# D---E---F develop
# \
# G---H main
git rebase --onto main develop feature
# A'--B'--C' feature
# /
# D---E---F develop
# \
# G---H main
Read it as: take commits after develop, up to and including feature, and replay them onto main.
Autosquash (the best rebase trick)
# During development, as you notice fixes for earlier commits:
git commit --fixup=a1b2c3d # message becomes "fixup! original subject"
git commit --squash=a1b2c3d # ditto, but prompts to combine messages
# When ready:
git rebase -i --autosquash HEAD~10
# All fixup!/squash! commits are pre-ordered and flagged; just save and quit.
Set it permanent: git config --global rebase.autosquash true.
Interactive staging
git add -p
Review and stage hunks one at a time. Lifesaver for "five unrelated changes in one file":
git add -p
# Stage this hunk [y,n,q,a,d,s,e,?]?
Keys you'll actually use:
y— yes, stage this hunk.n— no, skip it (stays in the working tree).s— split this hunk into smaller ones.e— edit the hunk. Delete+lines to un-stage them; change-to(space) to keep a line. Easy to get wrong;?brings up the full cheat sheet.q— quit the session; stage nothing further.a/d— stage/skip all remaining hunks in this file.
The inverse commands
git reset -p # un-stage hunks interactively
git checkout -p # discard working-tree changes, hunk by hunk
git stash -p # stash only selected hunks
git restore -p # modern equivalent of checkout -p (>= 2.23)
git commit --interactive is a menu-driven front-end over the same idea — add, add-by-path, patch, diff, revert, quit. Rare in muscle memory; useful when teaching.
Bisect with a script
When a test regresses between release X and Y, bisect finds the breaking commit in O(log n) steps:
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# Git checks out a midpoint. Run the test, then:
git bisect good # it passed
git bisect bad # it failed
# ...repeat until git names the culprit.
git bisect reset
Automated with git bisect run
Give bisect a script: exit 0 = good, exit 1-127 = bad (125 = skip), and it runs the whole search for you:
cat > /tmp/try.sh <<'EOF'
#!/bin/bash
make -s build || exit 125 # broken build -> skip, not bad
./run_tests.sh test_user_login # exit 0 good, nonzero bad
EOF
chmod +x /tmp/try.sh
git bisect start HEAD v1.4.0
git bisect run /tmp/try.sh
# ...
# abcd1234 is the first bad commit
git bisect reset
Walk away. Come back to the SHA.
Worktrees
A worktree is a second checkout of the same repository — same .git, different branch on disk, same set of objects. No cloning, no stashing.
# Current: on main in ~/src/myapp
git worktree add ../myapp-hotfix release/1.4
# creates ~/src/myapp-hotfix with release/1.4 checked out
# Branch doesn't exist yet? Create it at the same time:
git worktree add -b fix/auth ../myapp-auth origin/main
# Work there, commit, push. When done:
git worktree remove ../myapp-hotfix
# See all active worktrees
git worktree list
Use it for: running a long CI build on main while you develop on a branch; emergency hotfix to release/1.4 without disturbing your in-progress feature; reviewing a PR in a real working copy.
git worktree add --detach ../tmp v1.4.0.
Reflog recovery
The reflog is Git's local "where HEAD has been" log. Every branch move, every commit, every reset leaves a line. By default entries live 90 days (gc.reflogExpire).
git reflog
# 7a8b9cd (HEAD -> main) HEAD@{0}: commit: new feature
# e4f5g6h HEAD@{1}: reset: moving to HEAD~3 <-- disaster happened here
# a1b2c3d HEAD@{2}: commit: work before disaster
# ...
"I did git reset --hard and lost commits"
git reflog # find the SHA from before the reset
git reset --hard HEAD@{2} # or the SHA directly
"I deleted a branch"
git reflog show feature-x # still works for a while after deletion
git branch feature-x <tip-sha>
# If the branch reflog is gone, search all reflog entries:
git reflog --all | grep feature-x
"I rebased and my commits are gone"
They aren't gone; HEAD moved. git reflog shows the old tip. git reset --hard back to it, or cherry-pick.
Objects reachable but unreferenced
git fsck --lost-found --no-reflogs # orphans
# Each dangling commit gets a file in .git/lost-found/commit/
Signed commits (GPG and SSH)
Signed commits prove a commit was made by someone in control of a given key. Enforced by GitHub / GitLab branch protection, signed commits turn "someone with push access" into "someone with push access AND the signing key".
GPG
gpg --full-generate-key # ed25519 recommended
gpg --list-secret-keys --keyid-format=long
# sec ed25519/ABCD1234EF56 2026-04-23 ...
git config --global user.signingkey ABCD1234EF56
git config --global commit.gpgsign true
git config --global tag.gpgsign true
Export the public key, upload to GitHub/GitLab: gpg --armor --export ABCD1234EF56.
SSH signing (Git ≥ 2.34)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
# Trust yourself (so `git log --show-signature` says "Good signature")
cat >> ~/.ssh/allowed_signers <<EOF
alice@example.com $(cat ~/.ssh/id_ed25519.pub)
EOF
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
SSH signing plays well with SSH CA certs (SSH Certificate Authorities) — the allowed_signers file can list a cert-authority entry and every user with a valid cert signs without per-user setup.
git log --show-signature -1
# commit 7a8b9cd (HEAD -> main)
# Good "git" signature for alice@example.com with ED25519 key SHA256:...
Git notes
Notes attach metadata to a commit without rewriting it. Unlike a commit message, a note can be added after the fact and changed later.
git notes add -m "Shipped to prod 2026-04-23 in release 1.5.0" abcd123
git notes show abcd123
git log --show-notes # include notes in log output
git log --show-notes=release-history # a specific notes namespace
# Push / fetch notes (they live in refs/notes/* and are NOT default)
git push origin refs/notes/commits
git fetch origin refs/notes/commits:refs/notes/commits
Common use: deployment tracking, review links, "which customer hit this bug". Unused in most repos because nobody pushes them.
Hooks (and Husky)
Hooks are scripts in .git/hooks/* that Git runs around events. The useful ones:
| Hook | Runs when | Typical use |
|---|---|---|
pre-commit | Before the commit is created | Linters, format checks, secret scanning. |
commit-msg | Message is written but commit not created | Enforce conventional commits / ticket IDs. |
prepare-commit-msg | Before the editor opens | Inject template, prepend branch-derived prefix. |
pre-push | Before push transfers data | Run the full test suite. |
post-merge / post-checkout | After merge/checkout | yarn install if lockfile changed; rebuild DB on migration files. |
# A minimal pre-commit
#!/bin/bash
set -e
staged=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true)
[ -n "$staged" ] || exit 0
ruff check $staged
ruff format --check $staged
Local hooks are not version-controlled. For team-wide hooks use pre-commit.com (Python) or Husky (Node; stores hooks under .husky/). Both set core.hooksPath to a tracked directory so everyone runs the same checks.
git commit --no-verify. CI is the fence; hooks are the carpet in front of it.
Blame with ranges
git blame path/to/file.py
git blame -L 40,80 path/to/file.py # only lines 40-80
git blame -L :function_name path/to/file.py # the definition of function_name
git blame -L '/def handle_request/,+20' file.py # 20 lines starting from the regex match
# Ignore whitespace / reformat commits
git blame -w file.py
git blame --ignore-revs-file .git-blame-ignore-revs file.py
Maintain .git-blame-ignore-revs listing reformat/rename SHAs; configure git config blame.ignoreRevsFile .git-blame-ignore-revs. GitHub honours it automatically in the web blame.
Pickaxe: log -S and log -G
"When did the string DEPRECATED_API_V1 enter the codebase?" or "When did that nasty regex disappear?"
git log -S'DEPRECATED_API_V1' --oneline # commits that changed the count of this literal
git log -S'DEPRECATED_API_V1' -p # ...with the diff
git log -G'DEPRECATED_API_v[12]' --oneline # regex variant
git log -S'setuid' -- src/**/*.c # scoped to a pathspec
git log --follow -p -S'parse_header' src/http.c # follows file renames
-S is exact-string, fast, scales to big repos. -G is regex, slower but searches arbitrary patterns in the diff. Both answer "who touched this and why" in seconds where `grep`-then-blame takes ages.
Extras worth knowing
git log --grep='fix' --author='alice' --since='2 weeks'
git log --diff-filter=D -- path/to/deleted/file # commits that deleted a path
git show --stat v1.4.0 # release-scope diff summary
git range-diff main..feature-old main..feature-new # compare two rebase runs
git log --oneline --graph --decorate --all # the classic visualisation
See also: Git Basics, Git for Infra Workflow, GitLab Merge Requests.