Same task — "show me what this branch changed" — but the two tools take opposite dot conventions. Get it backwards and your review fills with commits the author never touched.
The diff: use three dots
git diff origin/main...HEAD
Three-dot diff is git diff $(git merge-base origin/main HEAD) HEAD — it diffs against the branch point, not the tip of main. That's exactly what you want: the author's delta and nothing else.
The nice property: it's immune to origin/main moving forward. New commits landing on main after the branch point aren't ancestors of HEAD, so they don't shift the merge-base. The diff stays clean.
The trap is a stale base, not a newer one. If your local main is older than the branch point, the merge-base slides back to an older ancestor and the diff swallows unrelated upstream commits — making the author look like they changed far more than they did. So fetch first:
git fetch origin
git diff origin/main...HEAD
Want to see the branch point itself? git merge-base origin/main HEAD.
The log: use two dots
git log origin/main..HEAD
Two-dot log = commits reachable from HEAD but not from origin/main = the branch's own commits, exactly.
Don't reach for three dots here out of habit — git log A...B is the symmetric difference, so it also lists the commits main picked up that HEAD doesn't have. That's the noise you were trying to avoid.
So: diff three-dot, log two-dot. Different tools, opposite defaults, same job.
On a corporate Windows box, git pull/git fetch keeps stopping to ask:
Unlink of file '.git/objects/pack/pack-305a05....idx' failed. Should I try again? (y/n)
The cause is a security agent — SentinelOne, ZScaler, Defender — holding an open handle on the old .idx files while Git tries to repack. Git for Windows wraps unlink/rename failures in a retry prompt, and you end up babysitting every pull, mashing n.
yes n | git pull works but you have to remember to prefix it every time. The permanent fix is one line in ~/.bashrc (the Git Bash one):
export GIT_ASK_YESNO=false
Git runs the value of GIT_ASK_YESNO as a command to decide whether to retry — a non-zero exit is treated as "n". false always exits non-zero, so every prompt is silently answered "no". It's cleaner than </dev/null redirection (works regardless of whether stdin is a tty) and doesn't touch the other interactive bits — commit-message editor, credential prompts — which go through different machinery.
Answering "n" just means Git leaves the locked old pack file on disk; the new pack is already live, so the repo is fine. Once the security agent lets go, a git gc sweeps up the leftovers.
If the prompts are frequent, this cuts down how often they fire — sometimes the lock is Git's own multi-threaded pack-objects, not the AV:
git config --global pack.threads 1
This is the silence-it companion to the heavier "it's an open handle, not a permission" diagnosis — same root cause (a process holding a handle), but here you just want the nagging to stop, not to hunt the locker down.
When you build tooling around git repos, you eventually hit "where do I store a per-repo setting?" Committing a .toolrc pollutes the repo and needs a PR; a sidecar file in ~/.config can't easily be per-repo. The clean answer: put it straight into git config under your own namespace.
# per-repo (lands in <repo>/.git/config — local, never committed)
git config --local mux.adhocWorktree false
# per-machine, all repos (lands in ~/.gitconfig)
git config --global mux.adhocWorktree false
# read it back, normalized to true/false
git config --bool --get mux.adhocWorktree # -> false ; exit 1 if unset
git lets you invent any <section>.<key> it doesn't recognize (mux.* here) and just stores it. That hands you three things for free that you'd otherwise have to build:
- A per-repo store that never leaks —
.git/config is local to the checkout and never committed, so it won't pollute someone else's repo.
- Scope layering with native precedence —
--local (repo) transparently overrides --global (machine). One git config --get resolves the hierarchy; you don't write any "check repo, then fall back to machine" logic.
- Type parsing —
--bool normalizes false/0/no/off → false, and a non-zero exit cleanly signals "not configured" so you can fall through to your built-in default.
So a full precedence chain for a feature flag becomes: CLI flag → env var → git config (local→global) → built-in default — and the middle two scopes are entirely git's job.
One gotcha with worktrees: git config --local writes to the shared common .git/config, not the linked worktree, because worktrees share config. If your setting is meant to key off the main checkout (it usually is), that's exactly right — but don't expect two worktrees of the same repo to hold different --local values.
The decision that surfaced this: an agent launcher was hardwiring a git worktree per session (hundreds of MB on big repos, painful on a VM). The whole "make it opt-out, but where does the per-repo marker live?" problem evaporated the moment we realized git config already is a per-repo + per-machine settings store with precedence baked in.
When you want Git to ignore something that's yours — a local scratch dir, editor cruft, a personal plans/ folder, machine-local config — don't add it to the tracked .gitignore. That file is shared: a rule like plans/ or wiki-drafts/ forces your personal directory conventions onto everyone, and they don't have those dirs.
Put it in .git/info/exclude instead. Same syntax as .gitignore, but it's per-clone and never committed — so it's invisible to teammates and travels with nothing:
# .git/info/exclude
.claude/settings.local.json
.claude/plans/
.claude/wiki-drafts/
The rule of thumb: **.gitignore is for what the project should ignore (build output, node_modules/, *.user); .git/info/exclude is for what you happen to ignore on this machine.** If a teammate cloning fresh wouldn't also produce the file, it doesn't belong in the shared list.
One gotcha with worktrees: info/exclude lives in the common git dir, so it's shared across all linked worktrees of the same clone — but still not across separate clones. Find it with git rev-parse --git-common-dir if you're not sure where it is.
And if you ever need to start tracking something a blanket exclude was hiding (e.g. you had .claude excluded but now want .claude/skills/ committed), narrow the exclude line to the specific paths you still want ignored — don't delete it and shove replacement rules into the shared .gitignore.
If you run gh auth setup-git, the GitHub CLI becomes git's credential helper — and it only ever hands out the token for the active account. The moment you git pull a repo belonging to your other account, you get the wrong token (403, or commits land under the wrong identity). There's no per-repo routing; you'd have to gh auth switch every single time you cross accounts.
You can prove it to yourself — ask the helper for the non-active account and it returns nothing:
"protocol=https`nhost=github.com`nusername=second-account`n`n" |
& gh auth git-credential get # exit 1, no token
The fix is to hand git over to Git Credential Manager (it already ships with Git for Windows). GCM picks the token by the username embedded in the remote URL, so routing becomes automatic. gh keeps working for gh issue/gh pr — it uses its own keyring, separate from the git helper.
Switch the helper and point GCM at GitHub:
git config --global --unset-all credential.https://github.com.helper
git config --global credential.helper manager
git config --global credential.https://github.com.provider github
git config --global credential.guiPrompt false # terminal prompts, no GUI popup on a server
…more