Posts in category “Git”

Two GitHub accounts on one machine? Let one gh wrapper hand git the right token

If you keep a personal and a work GitHub account on the same machine, you have probably hit this: gh auth login both accounts, run gh auth setup-git, and half your repos start returning Repository not found. That's because gh auth git-credential always answers with the keyring's active account. It never looks at which repo git is asking about.

The fix is to put a small wrapper in front of the real gh. The wrapper picks a token by repo owner, and git uses the same wrapper as its credential helper. Now both gh and plain git go through one place that knows about your accounts.

The heart of the wrapper is a few lines of bash (save as ~/bin/gh-route, keep the real gh on PATH):

#!/usr/bin/env bash
# Pick a token by owner, then run the real gh with it.
owner_from() { sed -E 's#^(https://github.com/|[email protected]:)##; s#/.*##'; }

if [[ "$1 $2" == "auth git-credential" ]]; then
    req="$(cat)"                                   # git's request, on stdin
    owner="$(sed -n 's#^path=\([^/]*\)/.*#\1#p' <<< "$req")"
fi
[[ -n "$owner" ]] || owner="$(git remote get-url origin 2>/dev/null | owner_from)"

case "${owner,,}" in
    my-work-org|my-work-user) export GH_TOKEN="$GH_TOKEN_WORK" ;;
    ?*)                       export GH_TOKEN="$GH_TOKEN_PERSONAL" ;;
esac

if [[ -n "$req" ]]; then gh "$@" <<< "$req"; else gh "$@"; fi

Then wire git to it, and move everything to HTTPS:

# ~/.gitconfig
[credential "https://github.com"]
    helper =
    helper = !bash ~/bin/gh-route auth git-credential
    useHttpPath = true
[url "https://github.com/"]
    insteadOf = [email protected]:
    insteadOf = ssh://[email protected]/

useHttpPath is what makes this reliable. Without it git only tells the helper host=github.com, so the wrapper has to guess from the current directory, and git ls-remote <url> run outside a repo gets the wrong account. With it git sends path=<owner>/<repo>.git, so the owner is right there in the request. (git clone happens to work either way: git writes origin into the new repo and exports GIT_DIR before it asks for credentials.)

The insteadOf lines rewrite existing [email protected]: remotes on the fly, so you don't have to edit a single .git/config. HTTPS also gets through networks that reset SSH, and it's the only transport GitHub App installation tokens work with.

One trap: never run gh auth setup-git again. It appends its own [credential "https://github.com"] block after your include, and its empty helper = line silently wipes out the wrapper.

On Windows (Git Bash), call the wrapper through bash as shown instead of an .exe shim. Git spawning a shim from its own sh can die with a cygheap read copy failed fork error.

Git Bash and MSYS2 disagree on Git SSL? Use one Git for Windows

Git Bash and MSYS2 can load different Git executables while sharing the same ~/.gitconfig. http.sslbackend=schannel works with Git for Windows, but an MSYS2-native Git may support only OpenSSL and fail with Unsupported SSL backend.

Remove the MSYS2 Git package and put Git for Windows first in MSYS2's PATH:

pacman -R --noconfirm git
printf '%s\n' 'export PATH="/c/Program Files/Git/cmd:$PATH"' >> ~/.bashrc
source ~/.bashrc
hash -r

Use one shared configuration:

git config --global http.sslbackend schannel
git config --global credential.helper manager

Verify both shells resolve the same executable:

type -a git
git --version
git ls-remote origin HEAD

This keeps certificate verification in the Windows certificate store and avoids shell-specific OpenSSL CA or stale credential-store differences. Do not disable TLS verification with http.sslVerify=false.

Reviewing a branch: `git diff` wants three dots, `git log` wants two

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.

Git for Windows nagging "Unlink failed. Should I try again? (y/n)"? One env var kills it

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.

Need a per-repo toggle for your own tool? Stash it in `git config`

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.