Git reference
- For: anyone who knows what they want Git to do and needs the command.
- Before you start: Getting started with Git.
- Reading time: about 48 minutes end to end, but it’s a lookup: find your task and read that section.
- You end with: Git set up once, and the command for whatever you need to do next.
Lookup by task. The concepts doc covers the mental model and the why; this one tells you the command.
Conventions in this doc:
- Commands shown with
bashtags. - Angle-bracket placeholders like
<file>or<sha>mean substitute your own value. HEAD~Nmeans “N commits before the current one.”HEAD~1is the previous commit.- Anything marked “local only” rewrites history; never run on commits that have been pushed to a shared remote.
One-time setup
Set your identity. Stamped on every commit; run once per machine.
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
Recommended: don’t set a global identity. Turn on fail-loud and bind the identity to a directory instead, so a repo created outside that directory errors rather than committing under whatever was lying around in global config. See Identity setup.
Set the default branch name to main so new repos use it.
git config --global init.defaultBranch main
Set the editor Git opens for commit messages and interactive rebase.
git config --global core.editor "codium --wait"
Set vim as the diff tool for git difftool. Skips the per-file confirmation prompt. Use this if you read diffs in vim (with or without vim-fugitive); see Which tool when in the concepts doc for how this fits among the other diff-viewing options.
git config --global diff.tool vimdiff
git config --global difftool.prompt false
Make git pull rebase instead of merge. Keeps history linear; no noise commits. With uncommitted changes in the tree, a rebasing pull refuses to start (error: cannot pull with rebase: You have unstaged changes., or Your index contains uncommitted changes. when they’re staged); commit or stash them first. If a pull stops on a conflict, see If a pull stops on a conflict.
git config --global pull.rebase true
Push new branches without the -u dance. With this set, the first push of a new branch creates the remote branch and sets the upstream automatically (Git 2.37+); without it, Git refuses until you run git push -u origin <branch> once.
git config --global push.autoSetupRemote true
Prune deleted remote branches on every fetch. Without it, branches deleted on the remote linger in git branch -a output indefinitely.
git config --global fetch.prune true
Enable rerere (reuse recorded resolution). Git remembers how you resolved a merge conflict and applies the same resolution if the conflict recurs.
git config --global rerere.enabled true
Make git commit show the staged diff in the editor (below a scissors line; the diff gets stripped on save). Reading the diff while writing the message is what most experienced users do reflexively. Available since Git 2.9.
git config --global commit.verbose true
Point Git at a commit message template. The template pre-fills the editor with reminders of the 50/72/imperative rules as comment lines (which get stripped on save).
git config --global commit.template ~/.gitmessage
Create the template file:
cat > ~/.gitmessage <<'EOF'
# Subject (line 1): imperative, under 50 chars, no period.
# Blank line between subject and body.
# Body (line 3+): wrapped at 72. Explain why, not what.
# The diff below the scissors line shows what.
EOF
The two leading blank lines are intentional: that’s where the cursor lands when the editor opens.
Codium settings for commit-message editing. Open Settings, click the JSON icon top-right, merge into settings.json:
"[git-commit]": {
"editor.rulers": [50, 72],
"editor.wordWrap": "wordWrapColumn",
"editor.wordWrapColumn": 72
}
Vertical rulers at columns 50 and 72; word wrap activates at column 72.
Install a commit hook with two checks. The first refuses a commit that adds conflict-marker lines: git add is what tells Git a conflict is resolved, and Git never looks inside the file, so markers left in it are committed like any other text. The second is a rename-safety warning. Git records renames as delete+add and re-infers them post-hoc when you run git log --follow; that inference is content-similarity based, and --follow loses the thread when the rename commit’s similarity drops below Git’s 50% default. The hook inspects the commit being prepared, normal or amend, and warns when it contains a rename below the 60% safety margin (10 points above the floor). See the concepts doc, What Git silently discards, for the fuller framing.
mkdir -p ~/.git-hooks
cat > ~/.git-hooks/prepare-commit-msg << 'EOF'
#!/bin/sh
# Git runs this hook on git commit and on git rebase --continue
# (pre-commit does not run there); --no-verify does not skip it.
#
# 1. Refuse a commit that adds conflict-marker lines. git add is what marks a
# conflict resolved; Git never looks inside the file. Only the opening and
# closing markers count, because a bare ======= line is also a Markdown
# heading underline.
if git diff --cached --no-color --no-ext-diff --no-textconv -U0 |
grep -qE '^\+(<<<<<<<|>>>>>>>)( |$)'; then
echo "Conflict markers are staged. Fix the file, git add it, and retry." >&2
echo "To commit markers on purpose: git -c core.hooksPath=/dev/null commit ..." >&2
exit 1
fi
#
# 2. Warn when the commit being prepared contains a rename with substantial
# content changes (similarity <60%). git log --follow loses a file across a
# rename below 50%; 60% leaves a margin. Detection runs at 30%, because at
# Git's default 50% the worst cases show as a delete plus an add and are
# never seen. $2 is the message source; $3 is HEAD when the source is an
# amend.
if [ "$2" = "commit" ] && [ "$3" = "HEAD" ]; then
base=HEAD~1 # amend: the new commit's parent is HEAD's parent
else
base=HEAD # normal commit: the new commit's parent is HEAD
fi
git rev-parse --verify --quiet "$base" >/dev/null || exit 0 # initial commit: nothing to compare
if git diff --cached --find-renames=30% --name-status "$base" | grep '^R' | awk '{score=substr($1,2)+0; if(score<60) found=1} END{exit !found}'; then
echo "Warning: this commit contains a rename with substantial content changes (similarity <60%). git log --follow may lose history. Rename in one commit; edit in a separate commit." >&2
fi
exit 0
EOF
chmod +x ~/.git-hooks/prepare-commit-msg
Point Git at that hooks directory globally:
git config --global core.hooksPath ~/.git-hooks
The marker check blocks. It runs where conflicts get finished: on git commit, including the commit that concludes a merge, and on git rebase --continue, where a pre-commit hook would not run; --no-verify doesn’t skip it. It looks only at lines the commit adds, and only at <<<<<<< and >>>>>>> lines, because a bare ======= is also a Markdown heading underline. When it blocks, it prints its two lines and the commit doesn’t happen; during a rebase Git adds error: could not commit staged changes. and stays stopped. To commit a page that shows conflict markers on purpose, like this one, bypass hooks for that one commit with git -c core.hooksPath=/dev/null commit.
The rename check compares the staged diff against the commit’s actual future parent: HEAD for a normal commit, HEAD~1 for an amend, whose parent is the old commit’s parent and whose content the index already holds at hook time. It covers both the fresh rename-plus-edit commit and the pure-rename commit about to receive edits via amend, skips cleanly on a repo’s first commit, and only warns. It looks for renames down to 30% similarity: at Git’s default 50% it would miss the very renames --follow already loses, and at 20% a deleted post and an unrelated new one that shared front matter were taken for a rename.
Useful aliases. Add to ~/.gitconfig under [alias], or set with git config --global alias.<name> '<value>'. The status, staging, diff, log, branch, rebase, stash and push entries use names and commands found across the common published sets (Pro Git, Atlassian, mwhite’s gist, Haacked’s post, and oh-my-zsh’s git plugin, which spells them with a g prefix). The commit family is this setup’s own, and its a means add -A, where ca in mwhite’s gist and gca in oh-my-zsh mean commit -a. Grouped by purpose; each line is what gets typed, and the comment is what it does.
[alias]
# Status
s = status # full status
st = status -s # short, one line per file
# Stage
a = add
ap = add -p # interactive hunks (atomic commits)
# Guard: ca, caa, caam and rv run this first; note 9
idle = "!f() { for s in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do if [ -e \"$(git rev-parse --git-path \"$s\")\" ]; then echo \"git idle: a merge, rebase, cherry-pick or revert is in progress; finish or abort it first (git status shows how)\" >&2; return 1; fi; done; if [ -n \"$(git ls-files -u)\" ]; then echo \"git idle: unresolved conflicts; fix each file and git add it first\" >&2; return 1; fi; }; f"
# refuse mid-merge, mid-rebase or with unresolved conflicts
# Commit
c = commit -m # one-line message
ca = !git idle && git add -A && git commit -m
# stage everything + one-line message
caa = !git idle && git add -A && git commit --amend --no-edit
# stage everything + fold into prev commit, keep message
caas = commit --amend --no-edit # fold ALREADY-STAGED into prev commit, keep message
caam = "!f() { git idle && git add -A && git commit --amend -m \"$1\"; }; f"
# stage everything + fold into prev commit, NEW message
caams = commit --amend -m # fold ALREADY-STAGED into prev commit, NEW message
cm = "!bash -c 'args=(); for m in \"$@\"; do args+=(-m \"$m\"); done; git commit \"${args[@]}\"' --"
# each arg becomes a paragraph; no args opens the editor; note 8
cf = commit --fixup # mark fixup; pair with ria
cs = commit -s # signoff (DCO projects)
# Revise older commits (git-revise)
rv = !git idle && git revise # fold staged changes into, or reword, an older commit; note 10
# Diff
d = diff
dc = diff --cached # the about-to-commit diff
ds = diff --stat # filenames + line counts
dw = diff --word-diff # word-level (good for prose)
# Log
lg = log --oneline --graph --all --decorate # standard pretty log
ll = log --oneline # quick scan, no graph
lf = log --follow -- # file history, follows renames
# Inspect
last = show --compact-summary HEAD # what you just committed
when = log -1 --format=%cr -- # last touch on a file (relative)
lastfile = log -1 --format='%cs %s' -- # last touch (date + subject)
who = shortlog -sne # contributors with counts
aliases = config --get-regexp '^alias\\.' # list your own aliases
# Branch / switch
br = branch
co = checkout
cob = checkout -b
sw = switch # modern checkout (branches only)
swc = switch -c # modern checkout -b
# Restore
unstage = restore --staged
discard = restore # discard working-tree edits to file
# Reset (commit rewriting)
uncommit = reset --soft HEAD~1 # undo last commit, keep changes staged
squash = reset --soft # use as: git squash HEAD~3, then git commit
# Rebase
ria = rebase -i --autosquash # auto-orders --fixup commits
rc = rebase --continue
ra = rebase --abort
# Stash
sl = stash list
sa = stash apply
sp = stash pop
# Push
pf = push --force-with-lease --force-if-includes # the safe force push
Reliability notes on the alias set above.
pfuses--force-with-lease --force-if-includes, not plain--force. The lease form refuses to push if the remote has new commits since your last fetch;--force-if-includes(Git 2.30+) additionally verifies your local history includes everything from the remote ref before allowing the push. Together they prevent silent overwrites of someone else’s work. Never alias plain--forceto a short name.- There are four amend-related aliases (
caa,caas,caam,caams) and a deliberate one omitted (commit -a --amend).caasandcaamsoperate on already-staged changes; you control what gets folded in, withcaamsalso taking a new message.caaandcaamboth rungit add -Afirst, which stages new files in addition to modified ones.commit -a --amendonly stages modified tracked files, not new ones, which can lead to commits that omit the file you thought you were including. If you want one-step “stage everything and amend,” prefercaa/caamovercommit -a --amend. All three of these rewrite the previous commit, so the local-only rule applies: don’t run on pushed commits without a force-with-lease push afterwards.caaandcaam, likeca, run theidleguard first (note 9). - There is deliberately no alias for
reset --hardorclean -fdx. The typing friction is the safety. Reflog can recover lost commits but cannot recover uncommitted working-tree changes a hard reset destroys. sw/swc(Git 2.23+) andrestoreseparate whatcheckoutdid into two cleaner commands. They refuse some operations thatcheckoutwould silently complete, which is desirable. The olderco/cobare kept for muscle memory; both forms work indefinitely.- The
cf+riapair is the standard atomic-commit fixup workflow used on Bitcoin Core and other projects with rebase-based PR review.git cf <sha>records a fixup against an earlier commit;git ria <base>runs interactive rebase with autosquash, which automatically orders and squashes the fixups into their targets.rv(note 10) does the same fold in one command when there’s no conflict. - Shell-form aliases (the ones starting with
!) areidle,ca,caa,caam,cm,rv, andwhoami. The leading!makes git run a shell command rather than a git subcommand. Shell aliases execute from the repo root, not the directory where git was invoked. For the ones that callgit add -A(ca,caa,caam) that changes nothing, becausegit add -Aalways operates from the repo root regardless of where you invoke from, which is what you want for snapshot-style commits but worth knowing if you ever expected scoped staging. - The
aliasesentry uses'^alias\\.'with a doubled backslash. That’s gitconfig escaping: the value is parsed before being passed to the regexp engine, so\\becomes\and the pattern matches a literal dot. If you copy this pattern outside gitconfig (into a shell script, for instance), unescape back to a single backslash. Relatedly, a value containing;or#must be wrapped in double quotes in gitconfig, since both characters otherwise begin a comment; that is whyidle,caam, andcmare double-quoted above. cmpasses each argument as its own-m, sogit cm "subject" "first body para" "second body para"expands togit commit -m "subject" -m "first body para" -m "second body para"; with no arguments it opens the editor like plaingit commit. A plain alias can’t interleave-mbetween your arguments, since simple aliases just append args verbatim, so this one is a shell alias with a loop. The explicitbash -cis necessary because on Devuan/bin/shis dash, which has no arrays; the!prefix would otherwise hand the shell command to dash and break. The trailing--sets$0inside thebash -cscript so that git’s appended arguments land in$1onward and$@stays clean; without it, the first argument would be consumed as the script name and dropped. Edit.gitconfigdirectly rather than usinggit config --global alias.cm '...'for this one: the layered shell quoting on the command line is error-prone and the file edit is cleaner.idleis a guard rather than a command you need to type.ca,caa,caamandrvrun it first, and it stops them with a one-line message while a merge, rebase, cherry-pick or revert is in progress, or while any file still has unresolved conflicts, which also covers a conflictedgit stash pop. In those statesgit add -Awould stage conflict markers as if they were resolved, and an amend during a stopped rebase lands on the commit being replayed onto, not on yours. Use plain git there instead (git add <file>, thengit commit,git rcorgit cherry-pick --continue), asgit statussays. The two messages aregit idle: a merge, rebase, cherry-pick or revert is in progress; finish or abort it first (git status shows how)andgit idle: unresolved conflicts; fix each file and git add it first. On its own,git idleprints nothing and exits 0 when nothing is in progress. One cost:caarefuses at a rebaseeditstop too; usegit add -A && git caasthere.rvrunsgit reviseafter theidleguard, which matters because git-revise itself doesn’t check for a rebase in progress. It needs git-revise installed (sudo apt install git-revise); usage is under Revising older commits.
To type g instead of git, add a shell alias to ~/.bashrc:
alias g=git
Then source ~/.bashrc or open a new terminal. g cm 'subject' 'body' will work because g expands to git before the shell invokes it, so git sees cm as a subcommand and looks it up in your .gitconfig aliases normally.
Check what’s configured.
git config --list
git config --list --show-origin # values + which file each came from
git config --global --edit # opens the global config file directly
git aliases # list just your aliases (after adding the alias above)
git config --list --show-origin is the unified view: every effective setting and the file it came from (system, global, per-repo, includeIf chains). Run inside a repo to confirm the identity is resolving correctly; run outside any identity directory to see the fail-loud state (no user.email set anywhere).
Show Git’s state in your shell prompt, so a half-finished merge or rebase is visible before you push or commit on top of it. Add this to the end of ~/.bashrc; it assumes your prompt ends in $ , as Devuan’s default does:
# git branch and in-progress state (MERGING, REBASE) in the prompt
if [ -r /usr/lib/git-core/git-sh-prompt ]; then
. /usr/lib/git-core/git-sh-prompt
PS1=${PS1%'\$ '}'$(__git_ps1 " (%s)")\$ '
fi
The helper ships with the git package. Expected: no output. In a new terminal, inside a repo, the prompt ends (main)$; during a stopped merge it ends (main|MERGING)$, and during a stopped rebase (main|REBASE 1/1)$, until you finish or abort.
Dotfiles repo: the convention experts use
The commands above get you set up on one machine. The convention for multi-machine setup, used widely across the Linux and privacy-focused dev world, is a dotfiles repo: a private Git repository containing your ~/.gitconfig, your ~/.gitmessage (and often ~/.bashrc, ~/.vimrc, anything else you customise). On a fresh machine you clone the dotfiles repo, put the files in place, and you’re done.
Two tools make the dotfiles repo pattern smoother but neither is required:
- GNU Stow (
sudo apt install stow) symlinks files from the dotfiles repo into~so the repo stays the source of truth. Lightweight, no templating, Devuan/Debian native. The most common pick. - chezmoi is the heavier alternative with templating (different machines can render the same dotfile differently) and built-in age encryption for secrets. Worth it once you’re managing several machines or want secrets in the repo without plaintext exposure.
If the dotfiles repo is private and on encrypted storage, plain files are fine. If it’s pushed to GitHub or similar, encrypt anything you’d rather not publish with git-crypt or chezmoi’s built-in age support.
Pseudonymous work
This setup is single-identity by design. For a pseudonymous identity (separate from your real identity), use a separate OS user account, a VM (Whonix on Qubes is the canonical setup), or a separate physical machine. Repeat this setup there with that identity’s name and email.
The reason is structural. Config-level identity separation on one OS account leaves several footguns intact: a repo placed under the wrong directory, a remote pointed at the wrong account, the SSH agent offering the wrong key, a host compromise that reads both identities at once. The convergent pattern among Tor developers, Bitcoin Core’s pseudonymous contributors, and Qubes users is OS-level isolation: different home directory, different ~/.gitconfig, different ~/.ssh, different browser, different shell history. The “wrong terminal” class of failures disappears because the boundaries are real, not procedural.
See the concepts doc, Identity layers and isolation, for the threat-model reasoning, and Beyond passphrase keys for the optional next tiers (hardware tokens, Tor transport, independent signing).
Starting a repo
Initialise a new repo in the current folder.
git init
Clone an existing remote repo.
git clone <url>
git clone <url> <folder-name> # clone into a specific folder
The daily cycle
Check what has changed and what’s staged.
git status
git status -s # short output, one line per file
Stage specific files for the next commit.
git add <file>
git add <file1> <file2>
Stage everything that has changed, including new files.
git add -A
Stage only changed tracked files (ignores new untracked files).
git add -u
Stage hunks interactively. Walks you through each change and asks y/n. Use s to split a hunk, e to hand-edit line-by-line.
git add -p <file>
Commit staged changes with an inline one-line message.
git commit -m "short summary of the change"
Commit and open the editor for a longer message (summary line, blank line, body).
git commit
Skip staging and commit all tracked changes in one step. Does not include new untracked files.
git commit -a -m "message"
Stage and commit in one interactive hunk-by-hunk pass.
git commit -p
Commit just one path (or a few) out of many changed, leaving every other change uncommitted. Useful for splitting one file’s distinct rationale into its own commit while the rest stays bundled. Takes the working-tree content of the named paths and does not touch the index for anything else, so you need not stage first.
git commit -m "Drop rebase glossary entry: duplicates concepts doc" -- glossary.md
Everyday workflow
Six commands cover most days, assuming the aliases from one-time setup are in place. The principles behind these (commits as communication, atomic commits, message discipline) are in the Git concepts doc.
Stage everything that changed and commit with a one-line message:
git ca "fix login redirect"
Commit already-staged changes with a one-line message:
git c "fix login redirect"
See what you just committed (metadata, message, and compact summary of changed files):
git last
Stage everything and fold it into the previous commit, keeping the previous commit’s message. Use this when you commit and immediately notice you missed something (a file, a typo fix, a forgotten change in the same logical unit):
git caa
Fold already-staged changes into the previous commit, keeping the previous commit’s message. Use this when you staged precisely what you want and don’t want add -A picking up unrelated working-tree changes:
git caas
Stage everything and fold it into the previous commit, but with a new message. Use this when you want to also change the previous commit’s title or message:
git caam "new commit title"
caa, caas, and caam all rewrite the previous commit (new hash). Safe locally; on already-pushed commits you’ll need git pf afterwards.
When the everyday six aren’t enough.
For a multi-paragraph commit from the command line, subject plus one or more body paragraphs, pass -m multiple times. Git concatenates the values as separate paragraphs separated by blank lines. Documented git behaviour, no alias needed.
git commit -m "subject" -m "body paragraph"
git commit -m "subject" -m "first paragraph" -m "second paragraph"
The same form scopes a body note to one file in a commit that touches several: prefix the body line with the file’s path.
git commit -m "2026-06-03 — intro, glossary, bibliography" \
-m "glossary.md: dropped the rebase entry; it duplicated the concepts doc and would drift."
git log -- glossary.md surfaces that line against the file later.
Combined with staging:
git add -A && git commit -m "subject" -m "body paragraph"
To start a message with -m and finish in the editor (useful when you’ve typed the subject and want the editor for the body, with rulers and diff visible):
git commit -m "subject" -e
For a fully substantive message, anything where you want auto-wrap at 72, the diff visible while writing, and no shell quoting hassles, drop the -m entirely and let the editor open:
git add -A
git commit
The template pre-fills the editor with rule reminders, the codium rulers mark columns 50 and 72, and the staged diff is appended below a scissors line so you can read the changes while writing the message. Save and close the tab to commit.
To fix the previous commit’s message or fold forgotten changes into it:
git commit --amend # opens editor with previous message preloaded
git commit --amend --no-edit # keeps message, folds in newly staged changes (long form of caas)
To fold staged changes into a commit older than HEAD, use the fixup + autosquash pair. Local only, rewrites every commit from the target forward.
git add <files>
git cf <hash> # records "fixup! <subject>" against <hash>
git ria <hash>^ # interactive rebase, autosquash slots the fixup into place
For the common case of folding changes into the commit before HEAD (e.g., you committed something, then committed something else, then realised the earlier commit was incomplete):
git add <files>
git cf HEAD~1
git ria HEAD~3
Why HEAD~3 and not HEAD~2: the fixup commit itself moves HEAD, so the target that was HEAD~1 is HEAD~2 by rebase time, and the rebase base must be the target’s parent, one further back. The sha-based general form above is immune to this shift, because a hash doesn’t move when HEAD does. The rebase opens the editor with the fixup pre-positioned next to its target and pre-marked as a fixup. Save and close to apply. --amend itself only operates on HEAD; there’s no syntax to point it at an arbitrary commit.
git rv does the same fold in one command, and also rewords an older commit; see Revising older commits. The plain pair above is the fallback when it hits a conflict.
The aliases used above (c, ca, caa, caas, caam, caams, last, cf, ria, rv) are defined in one-time setup. The fallback editor flow uses commit.verbose, commit.template, and the codium settings, all also in one-time setup.
Note on shell quoting: every form that takes a message in quotes (the aliases and all -m variants) is subject to shell rules. Single and double quotes are interchangeable for plain strings: the shell strips both and passes the same bytes to git. Single quotes suppress $VAR and backtick expansion; double quotes allow it. Apostrophes, parens, $, !, and * need escaping or careful quoting. If a message would need heavy escaping, use editor mode instead, or pass it on standard input with a quoted heredoc, which Git takes verbatim:
git commit -F - <<'EOF'
Subject line
Body paragraph. Apostrophes, $dollars, `backticks` and !bangs stay as typed.
EOF
Revising older commits
git rv (note 10 under one-time setup) runs git-revise, which folds staged changes into an older commit, or rewrites an older commit’s message, in one command and in memory: the working tree and index are left alone, and a run that fails changes nothing. It replaces the cf + ria pair from Everyday workflow whenever there’s no conflict; when there is one, use that pair. Local only, like every rewrite: the target and every commit after it get new hashes.
Install it once:
sudo apt install git-revise
Expected: apt’s usual install output. Afterwards git revise --version prints a version number.
Fold staged changes into an older commit:
git add <files>
git rv <sha> # or HEAD~2; no hash shift to worry about
Expected: Applying staged changes to '<sha>', one line per rewritten commit, then an Updating ... line with the old and new tip. git-revise uses only what’s staged. Its own -a stages changed tracked files but not new ones, so for new files run git add -A first.
Reword an older commit, inline or in the editor:
git rv -m "New subject" <sha>
git rv -m "New subject" -m "Body paragraph" <sha> # each -m is a paragraph
git rv -e <sha> # editor, with the old message
Fold one existing commit into an earlier one: mark the source as a fixup of the target, then let git-revise squash it in.
git rv -m "fixup! <target subject>" <source-sha>
git rv --autosquash <target-sha>^
If a later commit changed the same lines, git-revise stops and asks Edit conflicted file? (Y/n). Answer n: it prints merge conflict: user aborted and nothing changes, your staged changes included. Then do that fold with plain git, which gives you Git’s usual conflict tools and runs your hooks:
git cf <sha>
git ria <sha>^
Answering y instead opens the conflicted file in your editor and then asks Merge successful? (y/N). If markers are left it first warns conflict markers found in the merged file, and the default, N, aborts without changing anything.
Three things git-revise doesn’t do:
- Run your hooks. Neither the conflict-marker check nor the rename warning sees what it writes.
- Check for a merge or rebase in progress. Run after
git add -Aat a stopped rebase, it rewrites the commit being replayed onto, conflict markers included;rvruns theidleguard first for that reason. - Sign commits with SSH before version 0.8.0. With this setup’s tags-only signing that doesn’t matter. If you turn commit signing on and
git revise --versionprints something older, every rewrite fails withgpg: signing failed: No secret key.
Remotes
List remotes.
git remote -v
Add a remote.
git remote add origin <url>
Change a remote URL.
git remote set-url origin <new-url>
Remove a remote.
git remote remove origin
Fetch from remote without merging.
git fetch origin
Fetch and clean up references to branches deleted on the remote. Without --prune, deleted remote branches linger in git branch -a output indefinitely.
git fetch --prune
git fetch -p
Make pruning automatic for all fetches (one-time setup sets this):
git config --global fetch.prune true
Pull (fetch plus merge or rebase depending on config).
git pull
Push the current branch, setting upstream the first time. With push.autoSetupRemote from one-time setup, a plain git push does this automatically; -u is only needed where that setting is absent.
git push -u origin main
git push # after upstream is set
Push a new branch.
git push -u origin <branch-name>
Delete a remote branch.
git push origin --delete <branch-name>
Force-push. Overwrites remote history. Use --force-with-lease --force-if-includes instead of plain --force: the lease refuses the push if the remote changed unexpectedly, and --force-if-includes (Git 2.30+) additionally verifies your local history contains everything on the remote ref. Aliased as git pf in one-time setup. The lease alone isn’t enough right after a fetch: it compares the remote with what you last fetched, so after a pull that stopped on a conflict, --force-with-lease deletes the commit you just fetched. --force-if-includes refuses that push, because your branch doesn’t contain the commit.
git push --force-with-lease --force-if-includes
Keep a linear history on pull instead of generating merge commits. Rebase your local commits on top of what you fetched, ad hoc or as the default.
git pull --rebase
git config --global pull.rebase true # make rebase the default for every pull
git config --global pull.ff only # otherwise, refuse pulls that aren't fast-forward
Setting pull.rebase true is the durable choice for solo work: when local and remote have both moved, a plain git pull rebases instead of stopping with an error (Git 2.33.1 and later) or making a merge bubble (older Git).
Set or retarget the upstream-tracking branch without pushing. Use when a branch was created or pushed without -u, or to point it at a different remote branch.
git branch -u origin/<branch>
git branch --set-upstream-to=origin/<branch> # long form
Removing and renaming tracked files
Both operations stage their change immediately, so the next commit records them.
Remove a tracked file from the repo and the working tree.
git rm <file>
git rm -r <dir> # recursively, for a directory
Rename or move a tracked file. Git records a rename as a delete plus an add and re-infers it later by content similarity, so do the rename in its own commit and edit the file’s contents in a separate commit. A combined rename-and-edit commit can drop the similarity below Git’s detection threshold and break git log --follow, git blame across the rename, and GitLens history.
git mv <old> <new>
To stop tracking a file while keeping it on disk (the inverse of git add), see git rm --cached under Ignoring files.
Ignoring files
Create a .gitignore at the repo root. Each line is a pattern.
# comments start with hash, at the start of a line only
*.log
node_modules/
.env
.DS_Store
build/
# leading slash: only at repo root
/dist
# negation: don't ignore this one
!important.log
Start from a language-specific template at github.com/github/gitignore.
Global ignore for OS and editor clutter that should never be tracked.
git config --global core.excludesfile ~/.gitignore_global
Stop tracking a file that’s already tracked, without deleting it locally. Needed because .gitignore only affects untracked files.
git rm --cached <file>
Ignore a file in this repo only, without committing a change to the shared .gitignore. Patterns go in .git/info/exclude, which lives in the repo’s .git directory and is never committed. Use it for personal editor or scratch files that shouldn’t be imposed on collaborators.
# .git/info/exclude -- same pattern syntax as .gitignore
.scratch/
notes-to-self.md
.gitattributes template
A .gitattributes file at the repo root tells Git how to handle specific file types: line endings, binary detection, diff strategy. Committed to the repo, so every clone gets the same rules. Overrides per-machine core.* settings, which is why it’s preferred for anything that should apply consistently across collaborators.
Combined template covering line endings and binary markers:
# Line endings: normalize to LF in repo, convert on checkout as needed
* text=auto eol=lf
# Files that must keep CRLF (Windows-specific scripts)
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary files: don't normalize, don't try to diff
*.png binary
*.jpg binary
*.gif binary
*.pdf binary
*.zip binary
*.mp4 binary
*.mov binary
*.mp3 binary
Word-level diffs inline (Git has no setting that makes them the default; git dw from one-time setup is the short form):
git diff --word-diff
git diff --word-diff=color
If a repo already has mixed line endings, renormalise once after adding the template:
git add --renormalize .
git commit -m "Normalize line endings"
Per-machine line-ending fallback. Use only when .gitattributes isn’t an option (e.g., contributing to a repo whose maintainers haven’t adopted the file). Travels with the machine, not the repo, so it’s the inferior choice when you control the repo:
git config --global core.autocrlf input # Linux/macOS
git config --global core.autocrlf true # Windows
Inspecting state and history
Show the full log.
git log
Show a compact one-line-per-commit log.
git log --oneline
Visualise all branches as a graph.
git log --oneline --graph --all --decorate
Filter log by author.
git log --author="name"
Filter log by date range.
git log --since="2 weeks ago"
git log --since="2026-01-01" --until="2026-03-01"
Search commit history for a string that was added or removed (pickaxe).
git log -S "search string"
git log -G "regex pattern" # regex variant
Show what changed in a specific commit.
git show <sha>
Show a file as it existed at a specific commit.
git show <sha>:path/to/file
git show HEAD~3:path/to/file
Diff: unstaged changes against the staging area.
git diff
Diff: what’s staged for the next commit.
git diff --staged
Diff between two commits.
git diff <sha1> <sha2>
git diff HEAD~3 HEAD
Diff between two branches (any branch name works as a ref).
git diff main..feature-branch
git diff main feature-branch # same thing, two-dot form omitted
Diff against the upstream-tracking branch. Useful for “what am I about to push” before git push.
git diff @{u}.. # current branch vs its upstream
git log @{u}.. # local commits not yet on upstream
git log ..@{u} # upstream commits not yet local
Diff against an earlier state of HEAD (from the reflog). Useful for “what changed in my last reset/rebase/merge.”
git diff HEAD@{1} # current state vs HEAD one move ago
git diff HEAD@{1}..HEAD # commits only, leaving out uncommitted work
git diff HEAD@{yesterday} # time-based reflog reference
Diff at word level (better for prose than line level).
git diff --word-diff
Who last modified each line of a file, and in which commit.
git blame <file>
git blame -L 20,40 <file> # limit to lines 20-40
Find which commit introduced a bug by binary search.
git bisect start
git bisect bad # current commit is broken
git bisect good <sha> # known good commit
# git checks out a midpoint; you test it and run:
git bisect good # or: git bisect bad
# repeat until git names the culprit, then:
git bisect reset
Automate the search by handing bisect a test command instead of judging each step by hand. Git runs the command at every midpoint and reads its exit code: 0 is good, 1 to 127 except 125 is bad, 125 means skip this commit as untestable. The command can be a test script, a one-liner, or a compiler invocation, and bisect names the first bad commit with no further interaction.
git bisect start HEAD <known-good-sha>
git bisect run ./test.sh
git bisect reset
Show every commit that touched a file, following it across renames. Without --follow the history stops at the rename; keeping renames content-pure is what makes --follow reliable (see the concepts doc). Aliased as git lf.
git log --follow -- <file>
git log --oneline --follow -- <file>
Show a file’s history with the actual diffs, or with per-commit change stats.
git log -p -- <file> # full patch at each commit touching the file
git log --stat # files changed and line counts per commit
Show the history of specific lines or a single function, with the diff at each step. The line-range form takes start and end; the function form names the symbol and lets Git find its bounds. This is the time axis to blame -L’s snapshot: blame says who last touched each line, -L says how the region got there.
git log -L 40,60:path/to/file # history of lines 40-60
git log -L :funcName:path/to/file # history of one function, bounds auto-detected
Search tracked content. Faster than a plain recursive grep, respects .gitignore, and can search any revision rather than just the working tree.
git grep "pattern"
git grep "pattern" <sha> # search the tree at a past commit
git grep -n "pattern" # show line numbers
Find which branches or tags contain a given commit. Answers “which release shipped this fix.”
git branch --contains <sha>
git tag --contains <sha>
Diff a branch against the point where it diverged, not against the other branch’s current tip. The three-dot form diffs from the merge-base, so it shows only what your branch added; the two-dot form above mixes in the other branch’s later commits.
git diff main...feature # changes on feature since it forked from main
Compare two versions of a commit series, such as a branch before and after a rebase, or v1 against v2 of the same work. It pairs commits up by content and shows a diff of the diffs, so you can confirm a rewrite changed only what you meant it to. The reflog form feature@{1} is the quickest before-and-after right after a rebase.
git range-diff main..feature@{1} main..feature # this branch, before vs after the last rebase
git range-diff <base> <v1-tip> <v2-tip> # explicit three-arg form
Keep git blame readable across bulk-reformat commits. List the reformat commits’ shas in a .git-blame-ignore-revs file (one per line) and blame skips them, attributing each line to its last meaningful change instead.
git blame --ignore-rev <sha> <file>
git config blame.ignoreRevsFile .git-blame-ignore-revs # apply the file by default
Undoing
Discard unstaged changes to a file. Destroys your edits.
git restore <file>
git restore . # all files in current directory
Unstage a file (moves it from staged back to modified, does not change its contents).
git restore --staged <file>
Fix the most recent commit’s message. Local only.
git commit --amend -m "new message"
Add forgotten changes to the most recent commit. Local only.
git add <forgotten-file>
git commit --amend --no-edit
Undo the last amend, restoring the pre-amend commit. The amend replaced HEAD with a new commit and left the original in the reflog; this points HEAD back at it. The target is HEAD@{1} (the reflog entry of the original commit), not HEAD~1 (its parent): reset --soft HEAD~1 would undo the original commit along with the amend. --soft keeps everything the amend touched staged, so nothing is lost.
git reset --soft HEAD@{1}
git reset --hard HEAD@{1} # discard the amend's changes too; destructive
HEAD@{1} is correct only if the amend was the most recent action that moved HEAD. If you’ve committed, checked out, or reset since, read git reflog and reset to the original commit’s explicit sha instead.
Re-stamp the author of the last commit. Use after a commit lands under the wrong identity (the bug the Identity setup section warns about). --reset-author re-reads the current user.name and user.email; the explicit form sets a specific author.
git commit --amend --reset-author --no-edit
git commit --amend --author="Name <email>" --no-edit
Rewrites the commit (new hash), so it’s local-only or force-push with git pf after.
Undo the last commit but keep the changes staged.
git reset --soft HEAD~1
Undo the last commit and unstage the changes (keep them in working directory).
git reset HEAD~1
Undo the last commit and discard the changes entirely. Destructive.
git reset --hard HEAD~1
Create a new commit that reverses a previous one. Safe on pushed history.
git revert <sha>
Revert a merge commit. Plain git revert fails on a merge because it can’t tell which parent to treat as mainline; -m 1 selects the first parent (the branch you merged into).
git revert -m 1 <merge-sha>
Reset vs revert: reset rewrites history and is local-only safe; revert adds a new commit that undoes the target and is safe to push.
Preview untracked files Git would delete (always run this before git clean -fd). The -n flag is dry-run; nothing is deleted.
git clean -nd
git clean -ndx # also include ignored files
Actually delete untracked files. Destructive: files not tracked by Git are gone, including any new work you forgot to add. Run -nd first.
git clean -fd # untracked files and directories
git clean -fdx # also ignored files (.env, build artifacts)
Restore one file to its state at another commit or branch, leaving everything else untouched. Unlike git restore <file> (which discards to the staged version), --source pulls the file’s content from any ref.
git restore --source=<sha> <file>
git restore --source=main <file> # the version on another branch
Then add and commit it as normal. It’s non-destructive: all prior commits survive and the old state appears as a new tip. Interactive rebase is the alternative but rewrites history, so it’s only right when you actually want the intervening commits gone, not just the file rolled back. Pasting the old content in by hand gives an identical commit; the command is faster, skips clipboard and editor surface area that can corrupt whitespace or encoding, and works on binary files where paste isn’t an option.
Undo a just-completed merge, rebase, or pull. Each of these sets ORIG_HEAD to the tip before the operation, so a hard reset to it backs the whole operation out.
git reset --hard ORIG_HEAD
For an operation still in progress (conflicts unresolved), use --abort instead; ORIG_HEAD is for one that already completed.
Quick recipes for common messes
Committed to the wrong branch.
git reset --soft HEAD~1 # undo the commit, keep the changes staged
git stash # save them
git switch <correct-branch>
git stash pop # changes come back unstaged
git add -A
git commit -m "message"
Accidentally committed a huge file.
git reset HEAD~1 # undo the commit
# remove or gitignore the huge file, then recommit
Works only while the commit is unpushed; once pushed, the blob is in history and needs the Purging section.
Accidentally committed a secret. Rotate the secret first (assume it’s compromised). Then purge from history with the gitdel script from Git history delete or git filter-repo. Force-push.
Rolled back too far with git reset --hard.
git reflog # find the sha from before the reset
git reset --hard <sha>
Want to throw away all local changes and match the remote exactly.
git fetch origin
git reset --hard origin/main
Want to see who changed a line and why, three years later.
git blame <file> # find the sha
git show <sha> # read the commit message
Branches
List local branches. Current branch is marked with *.
git branch
List all branches including remotes.
git branch -a
Create a branch (stays on current branch).
git branch <name>
Switch to an existing branch.
git switch <name>
Create and switch in one step.
git switch -c <name>
Look at an old commit without affecting any branch (detached HEAD). Useful for inspecting a historical state. Switch back to a real branch when done; commits made in detached HEAD without a branch become unreferenced.
git switch --detach <commit-sha>
git switch - # back to previous branch
Older equivalent still widely used.
git checkout <name>
git checkout -b <name>
Rename the current branch.
git branch -m <new-name>
Delete a branch that has been merged.
git branch -d <name>
Force-delete a branch that hasn’t been merged. Destroys unmerged work on that branch.
git branch -D <name>
Merge another branch into the current one.
git merge <other-branch>
Abort a merge in progress (after conflicts).
git merge --abort
Rebase current branch onto another. Local only if the current branch has been pushed.
git rebase <base-branch>
git rebase --abort # back out mid-rebase
git rebase --continue # after resolving conflicts
List branches by merge status, to see what’s safe to delete and what still has unmerged work.
git branch --merged main # already merged into main; safe to delete
git branch --no-merged main # still hold unmerged commits
Control how a merge is recorded. --no-ff forces a merge commit even when a fast-forward is possible, preserving the branch as a visible unit; --ff-only refuses to merge unless it can fast-forward, rejecting anything that would create a merge commit.
git merge --no-ff <branch>
git merge --ff-only <branch>
Drop the current commit during a rebase. Use only when the rebase stops on a commit whose changes are already present upstream and applying it is redundant. Anywhere else, --skip throws that commit’s changes out of the result; in a fixup/autosquash rebase that means the fix you queued silently vanishes. When in doubt, resolve and continue, or git rebase --abort and rethink.
git rebase --skip
Transplant a range of commits onto a new base, or drop a range. --onto takes the new base, the old base (an exclusive lower bound), and the branch to move.
git rebase --onto <newbase> <upstream> <branch>
git rebase --onto HEAD~3 HEAD~1 # replay HEAD onto HEAD~3, dropping HEAD~1 and HEAD~2
Read it as: replay the commits in <upstream>..<branch> on top of <newbase>.
Merge conflicts
When git merge, git pull, or git rebase stops with conflicts, git status lists the conflicted files. Each conflicted file contains markers:
<<<<<<< HEAD
your version
=======
their version
>>>>>>> other-branch
Edit the file to the final desired content, remove all three markers, then:
git add <file>
git commit # if merging
git rebase --continue # if rebasing
To abandon the attempt entirely:
git merge --abort
git rebase --abort
Take one whole side of a conflicted file instead of editing markers by hand, then stage it.
git checkout --ours <file> # keep your side
git checkout --theirs <file> # take the incoming side
git add <file>
The meaning inverts between merge and rebase. In a merge, --ours is your current branch and --theirs is the branch being merged in. In a rebase the sides are swapped: --ours is the branch you’re replaying onto and --theirs is your own commit being replayed. Check which operation you’re in before picking a side.
If a pull stops on a conflict
A pull that stops on a conflict is half done. Git fetched the other side’s commit and started combining it with yours, then stopped, so until you finish or abort, your branch still doesn’t contain that commit. Every push fails (after a merge with the same advice to pull, after a rebase with fatal: You are not currently on a branch.), and running git pull again doesn’t help. git status shows the state (You have unmerged paths. after a merge, interactive rebase in progress after a rebase), and so does the prompt from one-time setup (MERGING or REBASE).
See which commit came in, and who made it:
git log --format='%h %an (committed by %cn) %cr %s' main..origin/main
Expected: one line per incoming commit. committed by GitHub means it was made in GitHub’s web editor; your own name means it came from another clone or machine. Pulling at the start of each session, before you edit, avoids most of these: a pull with nothing local to combine just fast-forwards.
Finish in the mode the pull used. After a merge (pull.rebase false):
git checkout --ours <file> # keep your version
git checkout --theirs <file> # or keep the incoming one
git add <file> # marks the conflict resolved
git commit --no-edit # concludes the merge
After a rebase (pull.rebase true, this setup’s default), the sides swap:
git checkout --theirs <file> # keep your version
git checkout --ours <file> # or keep the incoming one
git add <file>
git rebase --continue # git rc
Expected: git checkout prints Updated 1 path from the index, and git add prints nothing. git commit --no-edit prints one [main <hash>] Merge branch 'main' of ... line. git rebase --continue opens your editor on the replayed commit’s message; save and close it, and Git prints Successfully rebased and updated refs/heads/main. Lines starting Recorded preimage or Recorded resolution are rerere saving your resolution, not errors. Then push as usual.
To back out instead, git merge --abort or git rebase --abort (git ra) returns you to just before the pull, with your commits intact.
Three traps:
- Staging everything. Other uncommitted edits in the tree would go into the merge commit, and
git commit -acommits the conflict markers along with them. Stage only the conflicted file and commit the other edits separately afterwards.ca,caa,caamandrvrefuse in this state (note 9 under one-time setup), and the commit hook refuses staged markers. - Picking a side after
git add. Once the file is staged,git checkout --oursand--theirsquietly do nothing (Updated 0 paths from the index). Bring the conflict back first withgit checkout -m <file>, which printsRecreated 1 merge conflict, then pick. - Force-pushing past the rejection. Right after a fetch,
git push --force-with-leasepasses its check and deletes the incoming commit from the remote.git pfadds--force-if-includes, which refuses because your branch doesn’t contain that commit.
Stashing
Save uncommitted changes and revert the working directory to the last commit.
git stash push -m "what I was working on"
List stashes.
git stash list
Reapply the most recent stash and remove it from the stash list.
git stash pop
Reapply a stash but keep it in the list.
git stash apply stash@{0}
Drop a stash without applying.
git stash drop stash@{0}
Clear all stashes.
git stash clear
Promote a stash to a branch. Creates a new branch from the commit the stash was made on, then applies the stash on top. Useful when a stash turns out to be larger than expected or conflicts with the current branch.
git stash branch <new-branch-name>
git stash branch <new-branch-name> stash@{1} # specific stash
Include untracked files in the stash. Plain git stash leaves new (untracked) files in the working directory, a common cause of “my stash didn’t save everything.”
git stash push -u -m "what I was working on"
git stash -u # short form, no message
Inspect a stash’s contents before applying it.
git stash show -p stash@{0} # full diff of the stash
git stash show stash@{0} # summary only
Rewriting history (local only)
Interactive rebase opens an editor with the last N commits listed, each prefixed with pick. Change the prefix to alter what happens to that commit:
reword: keep the commit, change the message.squash(ors): fold this commit into the one above, combining messages.fixup(orf): fold into the one above, discarding this commit’s own message.edit: stop at this commit so you can amend it, thengit rebase --continue.drop(ord): remove the commit entirely. Same effect as deleting its line.
Reorder commits by reordering lines in the editor.
git rebase -i HEAD~5
Skip the todo-list editor for a straight squash-all-into-first via environment variable; Git still opens the editor once, for the combined message:
GIT_SEQUENCE_EDITOR="sed -i 's/^pick/squash/; 1s/squash/pick/'" git rebase -i HEAD~5
Non-interactive shortcut for the collapse-all-into-one case: reset --soft plus a fresh commit. Cleaner than interactive rebase when all you want is to combine the last N commits into one new commit with a new message.
git reset --soft HEAD~4
git commit -m "your message"
reset --soft moves the branch pointer back N commits but keeps all the changes staged. The single new commit captures everything. No editor. Limited to the straight-collapse case; use rebase -i for reorder, edit, drop, or selective squash. The aliased forms uncommit (for HEAD~1) and squash (general) are defined in the one-time setup.
Rebase preserving merge commits.
git rebase -i --rebase-merges HEAD~5
Apply a single commit from elsewhere onto the current branch.
git cherry-pick <sha>
git cherry-pick <sha1> <sha2> <sha3>
When a cherry-pick hits a conflict, resolve and continue, drop the conflicting commit, or back out entirely.
git cherry-pick --continue # after staging the resolution
git cherry-pick --skip # drop this commit, keep going
git cherry-pick --abort # undo the whole cherry-pick
Recovery
The reflog is your safety net. It records every move of HEAD for ninety days by default, including operations that “lost” commits, though an entry for a commit the current tip can no longer reach expires after thirty. Almost nothing is truly gone.
Show the reflog.
git reflog
Recover a commit by its sha from the reflog.
git checkout <sha> # look at it
git branch recovery <sha> # save it as a branch
git update-ref refs/heads/<branch> <sha> # force a branch to point at it
Recover after a bad reset. Find the sha of the commit you were on before the reset in the reflog, then:
git reset --hard <sha>
Find dangling commits that the reflog doesn’t surface.
git fsck --lost-found
Tags
List tags.
git tag
Create a lightweight tag (just a pointer).
git tag v1.0.0
Create an annotated tag (recommended for releases; carries a message and metadata).
git tag -a v1.0.0 -m "First stable release"
Push a specific tag.
git push origin v1.0.0
Push commits plus any annotated tags reachable from them in a single operation. Useful when releases are tagged on commits you’re about to push; avoids the two-step push + push –tags.
git push --follow-tags
Push all tags.
git push --tags
Delete a local tag.
git tag -d v1.0.0
Delete a remote tag.
git push origin --delete v1.0.0
Get a human-readable name for the current commit relative to the most recent tag. Output like v1.2.0-5-gabc123 means five commits after v1.2.0, at commit abc123.
git describe --tags
git describe --tags --dirty # append -dirty if the working tree has changes
SSH setup for GitHub
Generate a key, and give it a passphrase when asked. The key authenticates your pushes and, under Signing, signs your tags, so a copy of the file is useless to anyone without the passphrase.
ssh-keygen -t ed25519 -C "you@example.com"
Expected: it asks where to save (press Enter for ~/.ssh/id_ed25519), then Enter passphrase (empty for no passphrase): and Enter same passphrase again:, and prints the key’s fingerprint and a randomart picture.
Check whether an existing key has a passphrase, and add one if not:
ssh-keygen -y -P "" -f ~/.ssh/id_ed25519 >/dev/null 2>&1 && echo "no passphrase" || echo "has a passphrase"
ssh-keygen -p -f ~/.ssh/id_ed25519
Expected from ssh-keygen -p: Key has comment '<comment>', then Enter new passphrase (empty for no passphrase): and Enter same passphrase again:, then Your identification has been saved with the new passphrase. On a key that already has one, it asks Enter old passphrase: first.
Load the key into ssh-agent once per login, so pushes and signed tags don’t ask for the passphrase every time:
ssh-add -l # is an agent running?
eval "$(ssh-agent -s)" # only if the line above couldn't connect
ssh-add ~/.ssh/id_ed25519
Expected: ssh-add -l prints The agent has no identities. when an agent is running but empty, or Could not open a connection to your authentication agent. when none is; then eval prints Agent pid <number>. ssh-add asks Enter passphrase for /home/<you>/.ssh/id_ed25519: and prints Identity added: /home/<you>/.ssh/id_ed25519 (you@example.com). Without the agent, every push and every signed tag asks for the passphrase.
Copy the public key and add it to GitHub at Settings > SSH and GPG keys > New SSH key, with the key type Authentication Key. Signing adds it a second time, as a Signing Key.
cat ~/.ssh/id_ed25519.pub
Verify the connection.
ssh -T git@github.com
Expected: Hi <username>! You've successfully authenticated, but GitHub does not provide shell access., with exit status 1, which is normal here. The first time, ssh asks whether to trust github.com: answer yes only if the ED25519 fingerprint it shows is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, the one GitHub publishes.
Use SSH URLs for remotes (git@github.com:user/repo.git) rather than HTTPS URLs once SSH is set up.
Identity setup
Single-identity setup with directory binding and fail-loud safety. The global config carries no identity; the identity is bound to a directory via includeIf gitdir:. A repo created outside that directory errors instead of committing under whatever happened to be in global config.
For pseudonymous work see Pseudonymous work under One-time setup, above; this section is the mechanics for one identity on this OS account.
Fail-loud global
The global config carries no usable identity. user.useConfigOnly (Git 2.8+) then makes a repo outside the configured identity directory refuse to commit (“Author identity unknown”) instead of silently stamping whichever identity happened to be global.
git config --global user.useConfigOnly true
git config --global --unset user.name # remove any existing global identity
git config --global --unset user.email
useConfigOnly only stops Git guessing or falling back to a default; on its own it does nothing if a global user.name/user.email is still set. The unset lines are what close the fallback.
Identity include file
The identity lives in its own include file that sets name, email, signing config, and the SSH key to use. The core.sshCommand line pins the key by directory, so the right key is used even when a remote URL was never rewritten to a Host alias. Signing here covers tags only; Signing explains why, and what the allowedSignersFile line is for.
~/.gitconfig-me:
[user]
name = Your Name
email = you@example.com
signingkey = ~/.ssh/id_ed25519.pub
[gpg]
format = ssh
[gpg "ssh"]
allowedSignersFile = ~/.ssh/allowed_signers
[tag]
gpgsign = true
[core]
sshCommand = ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes
Set the file to chmod 600 so other local users on the same machine can’t read it.
Bind the identity to a directory
In ~/.gitconfig:
[includeIf "gitdir:~/code/me/"]
path = ~/.gitconfig-me
A repo under ~/code/me/ resolves the identity; anything elsewhere has no identity and fails loud. Three caveats on the match:
- The trailing slash matters. Git turns
~/code/me/into~/code/me/**, which matches everything inside; without the slash the pattern has to match a repo’s.gitpath exactly, so~/code/mematches no repo under it at all. gitdir:matches the canonicalised path of the repo’s.git. A repo reached through a symlinked parent can miss the match; point the condition at real paths.- Use
gitdir/i:instead ofgitdir:for a case-insensitive match on a case-insensitive filesystem.
Alternative: bind by remote URL
If your repos won’t all live under one identity directory, key the include off the remote URL instead (Git 2.36+, April 2022):
[includeIf "hasconfig:remote.*.url:git@github.com:youruser/**"]
path = ~/.gitconfig-me
One trap: you cannot define a remote inside a file included this way. The condition needs the remote to exist before the file that would define it is read, so a [remote] block there is a circular dependency Git rejects; keep remotes in the repo’s own config. Directory binding sets identity at git init, before any remote exists; remote binding activates only once a remote is set, so a fresh repo has no identity until then. Prefer directory binding by default.
Check the active identity
Before committing in an unfamiliar repo, confirm who you are. Add an alias once:
git config --global alias.whoami '!git config user.name; git config user.email; git config user.signingkey'
Then, inside any repo:
git var GIT_AUTHOR_IDENT # the identity a commit here would record
git whoami # name, email, signing key from config
git config --show-origin user.email # and which file set the email
Trust the first line. git var GIT_AUTHOR_IDENT prints Name <email> <timestamp> <zone>, the identity a commit would record, including one set by exported GIT_AUTHOR_* and GIT_COMMITTER_* variables, which git whoami can’t see. With the fail-loud global in place, in a repo outside the identity directory it fails the way a commit does: Git’s Author identity unknown hint, then fatal: no email was given and auto-detection is disabled. That failure is the safety net, not a misconfiguration; ignore the hint’s advice to set a global identity, which would undo it. If git whoami comes back empty but git var prints a name, an exported variable is overriding the setup, and env | grep '^GIT_' shows which.
Signing
This setup signs tags, not commits. A tag names its commit by hash, and every commit names its files and its parent by hash, so one signature on a tag vouches for everything behind it. The Linux kernel works the same way: Linus pulls only from signed tags. Signing every commit adds a per-commit “Verified” badge on GitHub, at the cost of re-signing after every history rewrite, because filter-repo, and so the gitdel script, strips commit signatures. Signing in the concepts doc has the reasoning.
Two methods: SSH (Git 2.34+, GitHub support since 2022) and GPG. SSH reuses the key from SSH setup for GitHub and is simpler to set up; GPG is the long-standing standard. Pick one; don’t combine.
SSH signing
The settings belong in the identity include file from Identity setup, so signing follows the identity directory:
git config --file ~/.gitconfig-me gpg.format ssh
git config --file ~/.gitconfig-me user.signingkey ~/.ssh/id_ed25519.pub
git config --file ~/.gitconfig-me tag.gpgsign true
git config --file ~/.gitconfig-me gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
Expected: no output. With tag.gpgsign on, every tag is signed, even a plain git tag <name>, which then asks for a message. To sign every commit as well, add git config --file ~/.gitconfig-me commit.gpgsign true.
Verification needs an allowed_signers file, which says whose key may sign as which email. Add your own line once, with your email:
echo "you@example.com namespaces=\"git\" $(cut -d' ' -f1,2 ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers
Expected: no output. The namespaces="git" annotation is what tells ssh-keygen (and Git through it) that this key is trusted for Git signatures specifically, not other SSH-signed contexts.
Sign a tag and check it:
git tag -s v1.0.0 -m "First stable release"
git verify-tag v1.0.0
Expected: git tag -s prints nothing, or asks Enter passphrase: when ssh-agent doesn’t hold the key. git verify-tag prints Good "git" signature for you@example.com with ED25519 key SHA256:…. A line such as allowed_signers:2: invalid key points at a malformed line in that file; it doesn’t stop your own tag verifying.
Upload the same public key to GitHub a second time, at Settings > SSH and GPG keys > New SSH key, with the key type set to Signing Key: a separate entry from the authentication key, even though the key material is identical. Without it GitHub shows your tags as unverified.
After a history rewrite, a tag’s signature no longer verifies: filter-repo either removes it or leaves one that fails with Could not verify signature. Re-sign each tag where it now points, then force-push it:
git tag -s -f v1.0.0 'v1.0.0^{}' -m "First stable release"
git push --force origin v1.0.0
Expected: Updated tag 'v1.0.0' (was <hash>), then the push reports + <old>...<new> v1.0.0 -> v1.0.0 (forced update).
GitHub’s vigilant mode marks every unsigned commit and tag attributed to you as “Unverified”. With tags-only signing, leave it off: GitHub says to turn it on only if you sign all your commits and tags.
The gpg.format and tag.gpgsign names are misleading: they govern SSH signing too, and the keys kept gpg in their names from before SSH signing existed.
GPG signing
git config --file ~/.gitconfig-me user.signingkey <key-id>
git config --file ~/.gitconfig-me tag.gpgsign true
git tag -s v1.0.0 -m "First stable release"
Leave gpg.format unset for GPG; it’s the default.
List your GPG keys.
gpg --list-secret-keys --keyid-format=long
Verify signatures rather than create them. The first shows signature status inline in the log; the others check a specific commit or tag and exit nonzero on failure.
git log --show-signature
git verify-commit <sha>
git verify-tag <tag>
SSH-signature verification needs the allowed_signers file from the SSH signing setup above; without it these report an error instead of a verdict.
Worktrees
Check out a second branch in a parallel working directory without cloning the repo again. Useful for quick context switches.
git worktree add ../other-branch-folder <branch>
git worktree list
git worktree remove ../other-branch-folder
Submodules and LFS
Both are advanced features that usually cause more friction than they solve for personal projects. See Large binaries are permanent and the submodule item in What Git silently discards, both in the concepts doc, for why. Minimal commands when you do need them.
Add a submodule (pin another repo at a specific commit inside this repo).
git submodule add <url> <path>
git submodule update --init --recursive
Clone a repo that has submodules (without this, the submodule directories will be empty).
git clone --recurse-submodules <url>
LFS install and track. Requires git-lfs installed (sudo apt install git-lfs).
git lfs install # one-time per user
git lfs track "*.psd" # tell LFS to handle this pattern
git add .gitattributes # the track command writes here
Files matching tracked patterns are stored in LFS instead of the regular git object store.
Purging files from history
Removing a file in a commit does not remove it from history; earlier commits still contain it. To actually purge a file from every commit in every branch, history has to be rewritten.
The tool depends on what’s being scrubbed:
- Specific deleted files, with rename-chain awareness: the gitdel script from Git history delete, this project’s wrapper around
git filter-repo. - Files matching a glob, or files over a size threshold: BFG Repo-Cleaner.
- Secrets or other content inside files to be kept: BFG
--replace-textorgit filter-repo --replace-text. - Surgical edits to the last N commits (unpushed or coordinated):
git rebase -i. See the Rewriting history (local only) section above for commands. - Never:
git filter-branch. Not recommended by the git project itself.
After any rewrite: force-push, then assume platform-side caches (forks, PR pages, mirror clones, search indices) still hold the old content. Rewrite is the start of a leak response, not the end.
git filter-repo
The modern tool. The filter-branch man page now points users here. Python-based, supports path filtering, content replacement, ref renaming, and per-blob callbacks. Refuses to operate on non-fresh clones by default; --force bypasses, which is what the gitdel script uses internally because the wrapper performs its own working-tree cleanliness checks. The typical trigger on a follow-up invocation is a prior rewrite in the same repo whose .git/filter-repo/ directory has since been removed (the gitdel script and the crash fix below both remove it): the first run took out origin, so the check fails with “expected one remote, origin” even though the working tree is otherwise clean. While that directory is in place, follow-up runs skip the check.
Install: sudo apt install git-filter-repo.
Purge a single file from all history.
git filter-repo --invert-paths --path secret.env
Purge multiple paths listed in a file, one per line.
git filter-repo --invert-paths --paths-from-file paths-to-purge.txt
Rename a path across all of history, so the file reads under the new name from the commit that first added it and no rename event appears. The argument is matched against the full repo-relative path from the root, at path-component boundaries rather than as a raw string prefix. A path matches only when it equals OLD exactly or continues with a / after it, so old/path:new/path rewrites old/path and everything under old/path/ but leaves old/path2 and old/path-archive untouched even though they share the leading text. It rewrites every commit from the add point forward, with new hashes, broken signatures, and a force-push, so the post-rewrite hygiene below applies.
git filter-repo --path-rename old/path:new/path
Operational points about this flag. A trailing slash makes the argument a directory rename bounded to that directory’s contents: old-dir/:new-dir/ moves everything under old-dir/ and does not match a file literally named old-dir. The slashes must agree on both sides; filter-repo errors on old/:new or old:new/, accepting only both-slash, neither-slash, or an empty side (old-dir/: lifts the directory’s contents to the repo root). Without slashes the argument names a single path component, file or directory: old.md:new.md renames the file old.md, or if old.md is a directory it renames the directory and everything under it, but never a sibling like old.md.bak whose name merely starts with the same text. A colon makes filter-repo error, it does not silently mis-split: the argument is split on every colon and must yield exactly two fields, so any path containing a colon aborts with “expects one colon”; rename such paths with --paths-from-file using an ==> line, which is split on ==> and tolerates colons in the path. The rename never selects paths: a bare --path-rename keeps every other path and only relabels matches, so files vanish only when a --path filter is also present, which is a whitelist that drops everything unmatched, or when a rename lands on a path that already exists in the tree, which is silently overwritten. A rename collision inside a single commit errors loudly with “File renaming caused colliding pathnames!” unless one side is a deletion or the two blobs are identical, so two distinct files folded onto one name fail fast rather than losing data quietly. filter-repo rewrites every ref by default (heads, tags, stashes), so a single --path-rename invocation propagates the rename across every branch and tag in the repo; this is what you usually want for a file-shape change but is the same blast-radius point that the gitdel script’s TRACKED_SET block already documents.
This rewrites history, removes the origin remote and its remote-tracking refs by design, and breaks other clones of the repo. After running, re-add origin and force-push, and tell any collaborators to re-clone from scratch.
If filter-repo refuses with “not a fresh clone”
Aborting: Refusing to destructively overwrite repo history since this does not look like a fresh clone. (expected freshly packed repo) ... Please operate on a fresh clone instead. If you want to proceed anyway, use --force.
This is filter-repo’s pre-rewrite safety check, refusing to start because the repo doesn’t look like a fresh clone; the reason in brackets names the test that failed, here the pack state. The check exists because the standard recovery from a bad rewrite is to discard the clone and re-clone, and that recovery only works if a forge copy holds the pre-rewrite state and you have no local-only commits the forge doesn’t.
Two paths.
Add --force to the same command and re-run when the repo mirrors to a forge you can re-clone from, has no local-only commits, and you accept that the throw-the-clone-away recovery posture is now provided by the forge mirror, not by the check. This is what the gitdel script does internally for exactly this reason.
git filter-repo --path-rename old/path:new/path --force
Re-clone fresh into a sibling directory, from the forge, or from your working copy when it holds commits the forge doesn’t; run the rewrite there, git push --mirror to every mirror, and discard the original working copy once the rewrite checks out. This is the path the check is enforcing. Choose it when the repo is local-only, when you have any local commits the forge doesn’t (verify with git log @{u}..HEAD per branch), or when you’ve forgotten whether you’ve pushed.
git clone --mirror <forge-url> /tmp/repo.git # or the path to your working copy
cd /tmp/repo.git
git filter-repo --path-rename old/path:new/path
git push --mirror <forge-url>
The --mirror clone preserves every ref, so the rewrite covers every branch and tag the forge holds, not just the current branch.
If filter-repo crashes after writing new history
New history written in N seconds; now repacking/cleaning... followed by a Python traceback such as FileNotFoundError: ...first-changed-commits means the rewrite finished but filter-repo died in its post-rewrite metadata step before refreshing the working tree. Refs already point at the rewritten history; the index and working tree still show the pre-rewrite snapshot, so a casual ls makes it look as if nothing changed.
This happens specifically when filter-repo finds .git/filter-repo/already_ran from an earlier rewrite older than a day, prompts Treat this run as a continuation of filtering in the previous run (Y/N)?, you answer Y, and the prior metadata is incomplete or written by a filter-repo version that didn’t yet emit first-changed-commits. The bare avoidance is to answer N to that prompt for any new rewrite (the docs say a rewrite shouldn’t count as a continuation once the earlier one’s commits have been shared); the bare fix when you have already answered Y and hit the traceback is below.
First verify HEAD has the rewrite before doing anything destructive.
git ls-tree -r HEAD | grep <new-name>
If that prints a line, refs are correct. The fix is to repoint the working tree and finish the cleanup filter-repo aborted.
git reset --hard HEAD
rm -rf .git/filter-repo/
git reflog expire --expire=now --all
git gc --prune=now --aggressive
reset --hard HEAD replaces the stale index and working tree with what HEAD already points to. Removing .git/filter-repo/ clears state from the partial run; left in place and older than a day, it triggers the same continuation prompt on the next filter-repo invocation in this repo, and another Y answer reproduces the same crash. The reflog expire plus aggressive gc is the cleanup filter-repo would have done if it had reached the end; run it only after ls-tree HEAD verification, since it drops the unreachable-objects grace window that the Recovery section below relies on for restoring a botched rewrite.
If ls-tree HEAD does not show the new path, the rewrite did not match. --path-rename is literal and root-anchored, so a bare filename matches only a file at the repo root. Clean up and re-run with the full repo-relative path on both sides.
rm -rf .git/filter-repo/
git filter-repo --path-rename docs/old.md:docs/new.md --force
--force is needed on the re-run because the repo is no longer a fresh clone after the prior rewrite, but the prior rewrite was a no-op (it matched no paths), so the force-bypass is safe here.
The gitdel script pre-cleans .git/filter-repo/ before invoking filter-repo for exactly this reason; the crash mode above is only reachable when filter-repo is invoked directly without the wrapper.
gitdel
For a safer, validated purge that auto-detects deleted files, handles renames, protects files still live on any branch, and prints the tip of every branch and tag before it rewrites, use the gitdel script on Git history delete.
bash gitdel.sh <repo-path> [file-list.txt] [--dry-run] [--save-list <file>] [--yes]
Always run with --dry-run first.
bash gitdel.sh ~/projects/myrepo --dry-run
bash gitdel.sh ~/projects/myrepo --save-list ~/purge_log.txt
Without a file list, it scans git history for every deleted file and offers to purge them. With a file list, it purges those paths and every other name each one had, after validation; the purge list it shows before you confirm includes them all.
BFG Repo-Cleaner
JVM tool, multi-threaded, can be significantly faster than filter-repo on large repos, especially for size-based stripping. Two distinctive features:
--strip-blobs-bigger-than 100M: remove every blob over a size threshold without enumerating files.--replace-text rules.txt: rewrite blob contents to redact secrets in-place. The file stays in history; the secret does not.
Weaknesses: no rename awareness, HEAD-only working-tree protection by default, no concept of “files that were deleted but might be on another branch.” Best for size-driven or pattern-driven scrubs, not exact-set surgery.
git filter-branch
Not recommended. The git man page actively recommends against it: orders of magnitude slower than filter-repo, with subtle semantic bugs in path filtering. Mentioned only because old Stack Overflow answers still recommend it. Don’t.
If the only available tool is filter-branch (locked environment, no Python, no JVM), the right move is to install one of the alternatives. The cost of a one-time install is dwarfed by the cost of getting filter-branch wrong on a real repo.
git rebase -i for recent surgical edits
For dropping or amending a few specific recent commits. Works when:
- The change is recent (within reach of an interactive rebase).
- The commits are unpushed, or the force-push is coordinated with everyone holding the branch.
- The scope is a few specific commits, not “every commit that touched file X.”
Beyond that, use filter-repo or BFG. See the Rewriting history (local only) section above for the commands.
Removing specific versions of a file from history
Use case: a tracked file has accumulated history, and you want to erase exactly two (or N) historical versions from the repo while keeping every other version of that file, every other file, and the surrounding commits intact. Common reason: a draft you don’t want preserved, accidentally-committed content that shouldn’t have been there, content you want to archive externally and then scrub from history.
Step 1, find the commits if you don’t already have the SHAs:
git log --all --follow -- path/to/file
git log --all -S 'distinctive substring from the version' -- path/to/file
--follow walks renames. -S (pickaxe) finds commits that added or removed a specific string.
Step 2, extract each version to a location outside the repo before doing anything destructive:
git show <SHA_A>:path/to/file > ~/safekeeping/file_va.txt
git show <SHA_B>:path/to/file > ~/safekeeping/file_vb.txt
git show <sha>:<path> prints the file’s exact contents as of that commit. Verify the extracts are what you wanted before continuing.
Step 3, decide which path to use.
Path A: drop the commits entirely. Use this when the target commits only changed that file, or when you accept losing everything else they did. Cleanest history afterward.
git rebase -i <parent-of-earliest-target>
# in the editor, change `pick` to `drop` on the two target lines
Path B: surgical erase of just the file content at those commits, keeping the commits themselves and any other changes they carried. Use when the target commits touched other files you want to keep.
Find the two blob hashes (the IDs Git uses for the file contents at each commit):
git ls-tree <SHA_A> path/to/file
git ls-tree <SHA_B> path/to/file
# output format: <mode> blob <hash> <path>
Rewrite to blank just those blobs:
git filter-repo --force --blob-callback '
if blob.original_id in (b"<blob_a_hash>", b"<blob_b_hash>"):
blob.data = b""
'
The two commits remain in history with their other changes intact; the file at those commits is empty. Other versions of the file in other commits survive untouched because they point to different blobs. Anything else with exactly the same content is emptied too, since Git stores identical content as one blob: the same file in later commits that left it unchanged, and any other file holding the same text, including in your current checkout. List what else holds it before you run the callback:
git log --all --oneline --name-status --find-object=<blob_a_hash>
Caveat on Path B: a downstream commit that “added paragraph X” relative to an erased blob will produce an unusual diff afterward, because its parent’s content at that path is now empty. If history readability at those points matters more than commit preservation, prefer Path A.
Post-rewrite for Path A:
git push --force-with-lease
git reflog expire --expire=now --all
git gc --prune=now --aggressive
Path B’s filter-repo run has already expired the reflog and pruned, and it removes origin, so the lease push above fails there; re-add the remote and force-push as in After the rewrite.
If the repo is on a forge (GitHub, Codeberg), forks, PR pages, mirror clones, and search indices may retain the old blobs. If the hidden content is sensitive, assume it’s been seen and rotate whatever the content protected. See Anti-patterns below for the rest of the post-rewrite hygiene.
git replace
The non-rewriting alternative: keep the old commits, graft a replacement in front. Doesn’t propagate to clones unless refs/replace/* is explicitly pushed (rare). Useful for hiding history locally when force-push isn’t available. Not a real secret-scrub mechanism; the original blob still exists in the repo.
Decision tree
Start at the top. Stop at the first match.
- Need to remove a specific known set of deleted files, with rename awareness? Use the gitdel script.
- Need to strip blobs over a size threshold? Use BFG
--strip-blobs-bigger-than. - Need to redact secrets inside files to keep? Use BFG or filter-repo
--replace-text. - Need to drop or edit specific recent commits? Use
git rebase -i. - Need to remove specific historical versions of a still-tracked file (not the whole file)? Use the Removing specific versions of a file from history recipe above.
- Need to remove every file matching a glob, accepting path-literal matching with no rename chasing? Use filter-repo or BFG with the appropriate path filter directly.
- Need to hide history locally without force-pushing? Use
git replace.
Anti-patterns
- Running the rewrite on the canonical working clone. Always use a throwaway. filter-repo’s fresh-clone check exists for a reason.
- Force-pushing without coordinating with anyone else holding the branch. Their local history diverges silently; the next merge reintroduces what was scrubbed.
- Assuming
git gcreclaims space automatically. It doesn’t. Rungit reflog expire --expire=now --all && git gc --prune=now --aggressiveto actually free disk. - Treating “rewrite succeeded” as “secret is gone.” Platform caches, PR pages, mirror clones, search indices, IDE history, backup systems may all still hold it. Rotate the credential at its source regardless.
- Rewriting signed commits without re-signing. Signatures don’t survive a commit-hash change; filter-repo strips them, so downstream verifiers see unsigned commits. With tags-only signing, re-sign the tags as shown under Signing.
- Running
filter-branchin 2026. Stop.
Recovery
The gitdel script prints the tip of every branch and tag before filter-repo runs. Restore with git update-ref <refname> <sha> if the rewrite goes wrong, as long as the old objects still exist (see below).
For broader coverage across every branch, tag, and the top of the stash stack, capture all refs before the rewrite:
git for-each-ref --format='%(objectname) %(refname)' > /tmp/pre-rewrite-refs.txt
If the rewrite is wrong, restore each ref:
while IFS=' ' read -r sha ref; do
git update-ref "$ref" "$sha"
done < /tmp/pre-rewrite-refs.txt
This works only while the original objects still exist. As its last step, filter-repo expires the reflog and runs git gc --prune=now, which deletes every object no ref reaches any more, so after a completed filter-repo run, including the gitdel script’s, update-ref fails with “nonexistent object” in that clone. The old commits then survive only on the forge until you force-push, in another clone, or in the tarball below. A rewrite that doesn’t clean up after itself, such as an interactive rebase, leaves them in place until gc prunes them. So: don’t run aggressive gc until the rewrite is verified.
Belt and braces: tarball the entire .git directory before the rewrite. Cheap, and recovers from any failure mode including gc-eaten objects.
After the rewrite
- Force-push every remote:
git push --force --all && git push --force --tags. filter-repo removes theoriginremote by design, so it needs re-adding first; the gitdel script prints those commands. - Rotate the leaked credential at its source. Treat the rewrite as evidence of disclosure, not as a cure.
- Notify collaborators. Anyone with a local clone needs to re-clone or carefully re-base their work; existing branches reference commit hashes that no longer exist.
- Expect platform caches to lag. GitHub, GitLab, and Codeberg may keep old refs accessible via the API or PR pages for some window. Contact platform support for accelerated removal if the leak is severe.
- Verify: clone fresh, then run
git log --all --oneline -- <path>for every path you purged (gitdel’s--save-listfile lists them all); each prints nothing once the path is gone. For a secret, also search the content, which catches a copy under a name you didn’t purge:git rev-list --all | xargs git grep -n -F '<part of the secret>'prints nothing when it’s gone, and a hit prints<commit>:<path>:<line>:<text>. Search for only part of the secret, so the whole key doesn’t land in your shell history.