Your Git Identity and Your Push Credential Were Never the Same System
The map is not the territory. — Alfred Korzybski
Your Git Identity and Your Push Credential Were Never the Same System
From “but my gitconfig is correct” to knowing exactly which three places to check
Kai pinged me with a screenshot of a PR. Every commit had that grey, faceless GitHub avatar next to it — the one that means “this email doesn’t belong to any account here.” The commits were on a work repo, authored, apparently, by a stranger.
“I checked.
git config user.emailreturns my work address. So what exactly is it lying about?”
I asked them to run it again with one extra flag:
git config --show-origin --get user.email
file:/Users/kai/.gitconfig kai@personal.example.com
One line, and the story inverted. The earlier check had been run in a different terminal tab — one whose working directory wasn’t inside the repo they thought it was. At the moment the commits were actually made, includeif never matched at all.
That’s the boring half of the bug. The interesting half is this: even if includeif had matched, and every commit carried the right email, those pushes could still have gone out signed by an entirely different SSH key — and git would not have said a word about it.
That gap is what this post is about.
Three things you’ll walk away with:
- A diagnostic one-liner that pins down a multi-identity problem in about ten seconds, and why it probes exactly those three places
- The one-sentence boundary between
includeifanddirenv— one swaps your ID card, the other swaps your wallet - A broken symmetry almost nobody notices: whose name goes into a commit and whose key gets you through the door are two systems that never compare notes
The order below isn’t the order I actually debugged it in. Real debugging works backwards from the symptom; teaching should work forwards from what you ought to check first.
1. Why that one-liner probes exactly those three places
For any multi-identity problem, I run this first. It’s read-only — it changes nothing:
echo "--- local ---"; git -C ~/ws/work/etl-pipeline config --local --get-regexp 'ssh|url|core\.' 2>/dev/null
echo "--- global ---"; git config --global --get-regexp 'ssh|url|includeif|core\.ssh' 2>/dev/null
echo "--- direnv ---"; ls ~/.local/share/direnv/allow 2>/dev/null | head; direnv status 2>&1 | head -8
Each section answers a separate question.
Section 1 — is this repo quietly overriding something?
git -C ~/ws/work/etl-pipeline config --local --get-regexp 'ssh|url|core\.'
-C <path>: run against a specific repo withoutcd-ing there. This matters more than it looks — the single most common misdiagnosis in multi-identity setups is checking config from the wrong working directory, which is precisely how Kai got fooled.--local: reads only that repo’s.git/config, ignoring global. Highest-precedence layer first.--get-regexp 'ssh|url|core\.': regex against key names, pulling outcore.sshCommandandurl."git@github.com:".insteadOf-style rewrite rules in one shot.
url.*.insteadOf is the one people forget to check. It silently rewrites an https:// remote into git@, sending you down a completely different authentication path than the one you think you’re on.
Section 2 — what do the global rules actually say?
git config --global --get-regexp 'ssh|url|includeif|core\.ssh'
Same idea, aimed at ~/.gitconfig, with includeif added — the mechanism that conditionally pulls in another config file.
Note what this checks: the rule, not the result. --get-regexp 'includeif' tells you what conditions you intended to switch identity on; --show-origin --get user.email tells you which one actually won just now. When those two disagree, your bug lives in the match condition.
Section 3 — has direnv actually been allowed here?
ls ~/.local/share/direnv/allow 2>/dev/null | head
direnv status 2>&1 | head -8
This probes something git cannot see at all: whether you’ve ever signed off on this directory’s .envrc. Without that approval, not one environment variable loads — and nothing complains. Git won’t error. The AWS CLI won’t tell you that you forgot to direnv allow. It will just quietly use the default profile and do something you didn’t want.
| Section | Question it answers | What you miss by skipping it |
|---|---|---|
--local |
Does this repo override the global rules? | You edit ~/.gitconfig and nothing changes |
--global + includeif |
What are my identity-switching rules? | The rule exists but its condition never matches |
direnv allow list |
Did the env vars load at all? | Git looks fine while AWS / kubectl use the wrong credentials |
A good diagnostic command earns its keep not from what it prints, but from what it rules out at the same time.
2. includeif: a static ID card
Written in ~/.gitconfig:
[includeif "gitdir:~/ws/work/"]
path = ~/.gitconfig-work
[includeif "gitdir:~/ws/personal/"]
path = ~/.gitconfig-personal
And the file it pulls in:
# ~/.gitconfig-work
[user]
name = Kai Chen
email = kai.chen@work.example.com
[core]
sshCommand = ssh -i ~/.ssh/work_ed25519
Three things to hold onto:
- Triggered by a directory path match (
gitdir:), or by branch name (onbranch:, git 2.36+) - On a match, it merges in
[user],[core], and anything else from the target file - Purely static and git-internal — no external process, and
git config --show-origin --getwill tell you exactly which file the winning value came from
🩸 Hard-earned lesson: gitdir: matches the repo directory itself (where .git lives), not your shell’s current directory. Reach it through a symlink or a git worktree and the match can fail silently. macOS adds a second trap: the filesystem is case-insensitive, but gitdir: is case-sensitive by default — ~/ws/Work/ and ~/ws/work/ are the same folder in Finder and two different strings to includeif. Use gitdir/i: when that bites.
Stop guessing at it. One command settles it:
git -C <repo> config --show-origin --get user.email
The file path in that output is the verdict.
includeifis a rule, not a guarantee. It only exists in the moments the path actually matches.
3. direnv: a dynamic wallet that needs your signature
An .envrc file plus one direnv allow:
# ~/ws/work/etl-pipeline/.envrc
export AWS_PROFILE=work-dev
export GIT_SSH_COMMAND="ssh -i ~/.ssh/work_ed25519"
export KUBECONFIG=~/.kube/work-dev.yaml
- Triggered every time your shell
cds into the directory; the direnv hook sources it, and unloads it when you leave - Manages arbitrary environment variables — not just git, but AWS profiles, Kubernetes contexts, API keys, whatever you need
- Requires an explicit
direnv allowonce (exactly what section 3 of the diagnostic was checking), and editing.envrcimmediately revokes that approval until you allow it again
That last point is deliberate design, and it’s also the single most common source of “but I changed it and nothing happened.”
🩸 Hard-earned lesson: .envrc is a real, executing shell script, not declarative config. Clone an unfamiliar repo that ships its own .envrc and you should not reflexively direnv allow it — that grants someone else’s script arbitrary execution on your machine, automatically, every time you cd in. The re-approval prompt isn’t friction for its own sake; it turns trust into something you have to perform by hand.
| includeif | direnv | |
|---|---|---|
| Owned by | git internals | the shell (external process + hook) |
| Fires when | any git command runs | you cd into or out of the directory |
| Scope | git config keys only | any environment variable |
| Needs approval | no | yes, and edits revoke it |
| Security model | static data, never executed | executes arbitrary code |
| How to verify | git config --show-origin --get |
direnv status |
4. The broken symmetry: whose name, versus whose key
If you read one section, read this one.
Most people carry this model in their head: “I switched my git identity to work, therefore I’m operating as work.”
That model is wrong. It fuses two things that have nothing to do with each other:
| Decided by | Verified by anyone? | |
|---|---|---|
| Whose name is in the commit | user.name / user.email, plain text fields |
No. Put any address you like in there; git records it without comment |
| Whether the push succeeds | which private key the SSH client presents | Yes. The server checks the public key and returns Permission denied if it’s wrong |
user.email is metadata you typed yourself, and git never validates it. Right now, today, you can run git -c user.email=linus@kernel.org commit and produce a commit attributed to Linus. Whether your push lands depends entirely on which private key came out during the SSH handshake — not one byte of which is influenced by user.email.
That’s the broken symmetry at the heart of this setup: two chains that both go by the name “identity,” one completely unguarded and one strictly verified, with no consistency check anywhere between them. So you get “the push succeeded” and “the attribution is wrong” at the same time, and you read them as if each confirms the other.
There’s a subtler layer underneath:
# in the file includeif pulls in
[core]
sshCommand = ssh -i ~/.ssh/work_ed25519
# in .envrc
export GIT_SSH_COMMAND="ssh -i ~/.ssh/personal_ed25519"
The environment variable GIT_SSH_COMMAND takes precedence over the core.sshCommand config key. So the work key you carefully wired up through includeif gets silently overruled by a variable left sitting in your shell — and git config --get core.sshCommand will keep reporting the work key, because it’s reporting configuration, not behavior.
To see what’s actually happening, you have to go around git’s own account of itself:
# who is really driving SSH
echo "$GIT_SSH_COMMAND"
git config --show-origin --get core.sshCommand
# who the server thinks you are (GitHub answers with the account name)
ssh -T git@github.com
That last command is the most honest one in the whole toolkit. It doesn’t ask your config. It asks the other side.
5. The two systems as one chain
Here’s the full path, with each system owning one link:
cd into ~/ws/work/etl-pipeline/
↓ direnv loads .envrc (only if allowed)
AWS_PROFILE=work-dev + GIT_SSH_COMMAND points at the work key
↓ a git command runs, includeif matches gitdir
user.email becomes the work address, written into the commit
↓ push, SSH handshake
the server authenticates the key — the key, never the email
Break any link and you get a different symptom, none of which names the broken link:
| Symptom | Broken link | Check first |
|---|---|---|
| Commits attributed to your personal email, but the push worked | includeif didn’t match; the key was fine |
git config --show-origin --get user.email |
Permission denied (publickey) |
Identity right, key never arrived | echo $GIT_SSH_COMMAND; ssh -T git@github.com |
| Git behaves perfectly, AWS / kubectl use the wrong account | direnv was never allowed | direnv status |
Edited .envrc, still getting the old values |
the edit revoked the approval | direnv allow |
| Config clearly says work key, personal key is being used | env var overrode the config key | echo $GIT_SSH_COMMAND |
Look at those last two rows. Both are cases where you believe you already fixed it. They’re the expensive ones, because they raise no error at all — they just quietly do the wrong thing.
Do this today
- Run
git -C . config --show-origin --get user.emailin your work repo right now. Not--get—--show-origin --get. Look at which file that value came from. If it’s~/.gitconfigrather than the work config you expected, yourincludeifhas never once fired. - Run
ssh -T git@github.comand see what the server calls you. It’s the only answer that doesn’t depend on your local config telling the truth. echo "$GIT_SSH_COMMAND". If it’s non-empty and you don’t remember setting it, it is currently overriding everycore.sshCommandyou have.- Rewrite one
gitdir:pattern as an absolute path and re-test, to rule out a symlink or worktree quietly breaking the match. -
Put those three checks into a
git-whoamifunction in your dotfiles. This is the highest-value item on the list. Everything above is currently tribal knowledge living only in your head, and in three months you will re-derive all of it from scratch. Turn it into an artifact that executes and you never reason about it again:git-whoami() { echo "email : $(git config --show-origin --get user.email 2>/dev/null || echo '(none)')" echo "ssh : ${GIT_SSH_COMMAND:-$(git config --get core.sshCommand || echo '(default)')}" echo "aws : ${AWS_PROFILE:-'(default)'}" direnv status 2>/dev/null | grep -i 'loaded rc\|allowed' || echo "direnv: n/a" } cat .envrcbefore youdirenv allowany repo you didn’t write. That’s not fastidiousness, it’s a security boundary.
Your config files record what you meant. Your runtime environment records what you did. Every multi-identity incident happens in the gap where those two drift apart and nothing raises a hand.