Git Advanced

The Git commands you reach for when the basics run out: rebase, interactive staging, bisect, worktrees, reflog recovery, signed commits, notes, hooks, and forensic log search.

Commands you want reflexes for
  • 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:

--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:

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.

You cannot have the same branch checked out in two worktrees. Git's protection against "which one wins on push?". Detach if you must: 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/
Reflog is local. If you cloned fresh on another machine, the lost-commit trail is only on the machine where the accident happened. Recover from there.

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:

HookRuns whenTypical use
pre-commitBefore the commit is createdLinters, format checks, secret scanning.
commit-msgMessage is written but commit not createdEnforce conventional commits / ticket IDs.
prepare-commit-msgBefore the editor opensInject template, prepend branch-derived prefix.
pre-pushBefore push transfers dataRun the full test suite.
post-merge / post-checkoutAfter merge/checkoutyarn 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.

Hooks can be bypassed by 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.