Skip Navigation

How to Use Two GitHub Accounts on One Machine Without Switching Anything

Two separate key paths leaving one laptop for two different folders

Tl,dr:

  • One default GitHub account. A second account that takes over for every repo under one folder.
  • No switching. No git config user.email per clone. No "oops, pushed to work as my personal self".
  • Three separate things have to agree: your git identity, your SSH key, and the gh CLI. They live in three different places and none of them know about each other.
  • Two traps will silently break this and give you no error message. Both are at the bottom, with proof.
  • Two more things that break it later: logging in a new account hijacks your global default, and moving a scoped folder reverts that tree to your default identity without complaining.
  • There is a copy-paste prompt at the end of each part, if you want an agent to just do it.

Why is this so annoying?

You have a personal GitHub account. Then you get a client, or a job, and now you have a work GitHub account too.

So you clone the work repo and push.

And the commit shows up under your personal name and email.

Or worse, it doesn't show up at all, because your personal SSH key has no access to the work org, and git yells at you about a repository that "does not exist".

The usual fix you land on is git config user.email work@example.com in every work clone.

That works until you forget once.

I wanted a rule instead:

Everything under ~/dev/work is the work account. Everything else is me.

No thinking. No switching. Just a folder.

Here's how to build that from nothing.

Why is this three problems and not one?

This is the part that trips people up, so it's worth 30 seconds of your time.

When you push to GitHub, three completely independent systems get involved.

  1. Identity. The name and email baked into the commit object. This is just text. Git does not verify it. Lives in ~/.gitconfig.
  2. Transport. The SSH key that proves to GitHub who you are. This is what actually decides whether your push is allowed. Lives in ~/.ssh/config.
  3. The API. The gh CLI, for gh pr create, gh repo view and friends. Its own token, its own keyring. Knows nothing about your git config or your SSH setup.

You can get all three wrong independently.

Think about how bad that is. Your commit can be authored as your work self, while being pushed with your personal key, while gh pr create fails because it's talking to GitHub as a third identity.

So we configure all three. And we make all three key off the same folder.

There's a fourth thing, commit signing, which is really a sub-problem of identity. I'll cover it. Get it wrong and GitHub stamps "Unverified" on every commit you make, and you will not know why.

Part 1: How do you set it up from scratch?

Assume your machine is completely fresh. Nothing configured.

I'll use these placeholders. Swap in your own:

  • personal-user and work-user: the two GitHub usernames
  • personal@example.com and work@example.com: the two emails
  • ~/dev/work: the folder that belongs to the work account

1. Make two SSH keys

One key per account. GitHub will NOT let two accounts share a key.

ssh-keygen -t ed25519 -C "personal@example.com" -f ~/.ssh/id_ed25519
ssh-keygen -t ed25519 -C "work@example.com"     -f ~/.ssh/id_ed25519_work

Add each of your public keys to its own GitHub account, under Settings → SSH and GPG keys → New SSH key, key type Authentication.

cat ~/.ssh/id_ed25519.pub       # paste into personal-user
cat ~/.ssh/id_ed25519_work.pub  # paste into work-user

2. Write ~/.ssh/config

This is your default. It pins your personal key for github.com.

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes

That last line is not optional, and it's the one you'll leave out.

Why does it matter? Without it, SSH offers every key in your agent, in agent order, and GitHub accepts the first valid one. Both of your keys are valid.

So the account you land on depends on which key you happened to load first.

It will work for weeks and then break on a Tuesday.

3. Write a second SSH config, for work only

Create ~/.ssh/config-work:

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_work
  IdentitiesOnly yes

Yes, it declares Host github.com again.

That's the whole point. This file is going to replace ~/.ssh/config for work repos, not get merged into it.

Lock both down:

chmod 600 ~/.ssh/config ~/.ssh/config-work

4. Write ~/.gitconfig

Your default identity, plus one conditional include at the bottom.

[user]
	name = Your Name
	email = personal@example.com

[init]
	defaultBranch = main

# Always reach GitHub over SSH, never HTTPS, so the KEY decides the account
# instead of a cached token.
[url "git@github.com:"]
	insteadOf = https://github.com/

# Work account override. This is LAST on purpose: git applies config in file
# order, so whatever is in here wins inside that folder.
[includeIf "gitdir:/home/you/dev/work/"]
	path = /home/you/dev/work/.gitconfig-work

Three things you should know about that includeIf:

  • Use an absolute path. ~ works in newer git versions. Absolute paths always work.
  • Keep the trailing slash. gitdir:/home/you/dev/work/ matches that folder and everything below it. Drop the slash and you're matching a filename pattern instead, and it gets weird.
  • It matches where the REPO is, not where you are. So git -C ~/dev/work/thing status from anywhere still gets you the work identity. That's good, and it's why the folder rule is reliable.

It also doesn't leak into look-alike folders. ~/dev/work-notes and ~/dev/workX stay personal.

I tested that one. It's exact.

5. Write ~/dev/work/.gitconfig-work

The override. Identity, SSH key, and signing.

[user]
	name = Your Name
	email = work@example.com

# Pin the work SSH key for every git network operation in this tree.
# -F REPLACES ~/.ssh/config, so the personal key is not even a candidate.
[core]
	sshCommand = ssh -F /home/you/.ssh/config-work

# Sign with the work SSH key, not your personal GPG key.
[gpg]
	format = ssh
[user]
	signingkey = /home/you/.ssh/id_ed25519_work.pub
[commit]
	gpgsign = true
[tag]
	gpgsign = true

That core.sshCommand line is doing the heavy lifting here.

It's the only mechanism I found that is genuinely airtight. The obvious alternatives are not, and I put both of them in the traps section at the bottom, with the output that proves it.

6. Make commit signing actually verify

If you turn on signing, do it properly or turn it off.

Half-configured signing is worse than none. GitHub renders "Unverified" on every commit, and most people never work out why.

Here's the rule GitHub uses: the signing key must be registered on the account, AND it must be associated with the email in the commit.

So go to the work account's Settings → SSH and GPG keys → New SSH key and add the work public key a SECOND time, this time with key type Signing Key.

cat ~/.ssh/id_ed25519_work.pub

Same key, added twice. Once as Authentication, once as Signing. GitHub treats those as two different things.

This is the step you are most likely to miss in the whole setup.

You can check what's registered without even logging in, because it's public:

curl -s https://api.github.com/users/work-user/ssh_signing_keys

Empty array? Then GitHub will mark your commits Unverified, no matter how correct your local config is.

While you're here, teach git to verify SSH signatures locally too. Otherwise git log --show-signature just shrugs at you. Create ~/.config/git/allowed_signers:

work@example.com ssh-ed25519 AAAAC3Nz...paste the key part here

And point git at it in ~/.gitconfig:

[gpg "ssh"]
	allowedSignersFile = /home/you/.config/git/allowed_signers

7. Make the gh CLI follow the folder too

Log into both of your accounts. gh supports this natively and keeps both tokens around:

gh auth login   # do this twice, once per account
gh auth status  # should list both

One thing that will catch you on that second login: gh auth login has no --user flag. You cannot tell it which account to log in as.

$ gh auth login -u work-user
unknown shorthand flag: 'u' in -u

The account is decided by whoever you happen to be signed in as in the browser when you complete the flow. So before the second login, sign out of GitHub in your browser or run the flow in a private window. Otherwise you will authenticate as the same account twice and wonder why gh auth status only ever lists one.

-u does exist on gh auth token and gh auth switch. Just not on login. That inconsistency is the whole trap.

One more thing, and it bites harder than it looks: gh auth login makes whichever account you just logged in as the globally active one.

The wrapper below injects a token per invocation for your scoped folders, so those stay correct. But everywhere else it deliberately does nothing and lets gh use the active account. Which you just changed.

So the moment that second login finishes, every personal repo on the machine is quietly talking to GitHub as your work self:

$ cd ~/code/personal-thing && gh api user --jq .login
work-user          # wrong, and nothing told you

Switch back once, right after logging in:

gh auth switch -u personal-user
gh auth status              # confirm: personal is Active, the rest are not

This is the same failure mode as everything else in this post. Nothing errors. The wrong identity just becomes the default and waits.

But gh has no idea what folder you're in. It uses whichever account is globally "active".

So in a work repo you get this:

$ gh repo view
GraphQL: Could not resolve to a Repository with the name 'work-user/thing'

It's asking as your personal self, which cannot see the repo.

Now you will say: just use gh auth switch, Luis!

Sure. But that's global mutable state you have to remember to flip back, which is exactly the thing we're trying to get rid of.

Better: a tiny wrapper on your PATH that picks the account per invocation. Put this at ~/.local/bin/gh, and make sure ~/.local/bin comes before /usr/bin in your PATH:

#!/bin/bash
# Pick the GitHub account per invocation, mirroring the git folder rule.

GH_BIN=/usr/bin/gh
WORK_TREE="$HOME/dev/work"
WORK_ACCOUNT=work-user
# Owners belonging to the work account, for when a repo is named explicitly.
WORK_OWNERS='work-user|SomeWorkOrg'

use_work=0
[[ "$(pwd -P)/" == "$WORK_TREE/"* ]] && use_work=1
[[ $* =~ (^|[[:space:]=/])($WORK_OWNERS)/ ]] && use_work=1
[[ $GH_REPO =~ ^($WORK_OWNERS)/ ]] && use_work=1

# Exclude `gh auth ...` so login/logout/status keep working on the real
# account list instead of on an injected token.
if [[ $use_work -eq 1 && "$1" != "auth" ]]; then
    token=$("$GH_BIN" auth token -u "$WORK_ACCOUNT" 2>/dev/null)
    if [[ -n "$token" ]]; then
        GH_TOKEN="$token" exec "$GH_BIN" "$@"
    fi
    echo "gh: no stored token for $WORK_ACCOUNT" >&2
fi

exec "$GH_BIN" "$@"
chmod +x ~/.local/bin/gh

GH_TOKEN is scoped to that one process. Your globally active account is never touched, so there's no state to forget to reset.

The WORK_OWNERS check is there for a case the folder rule genuinely can't cover: gh pr list -R SomeWorkOrg/thing, run from your home directory. There's no repo folder to look at, so we match on the owner name instead.

And notice it's a shell script on PATH, not an alias or a shell function. That matters more than it looks. It keeps working inside scripts, inside Makefiles, in non-interactive shells, and when your editor or your AI agent shells out to gh.

8. Verify it, properly

Do not trust "it didn't error".

Check what actually goes over the wire.

# Which account does each tree authenticate as?
ssh -T git@github.com                          # -> Hi personal-user!
ssh -F ~/.ssh/config-work -T git@github.com    # -> Hi work-user!
# Which identity does each repo resolve to?
git -C ~/dev/work/some-repo config user.email  # -> work@example.com
git -C ~/code/personal-thing config user.email # -> personal@example.com

Run that inside an actual repo, and nowhere else. This is the one verification step that lies to you.

gitdir: matches the location of a .git directory. If there isn't one, there is nothing to match, and git quietly falls back to your global identity:

$ cd ~/dev/work && git config user.email      # the work folder itself
personal@example.com                          # looks broken, is fine

$ cd ~/dev/work/some-repo && git config user.email
work@example.com                              # the real answer

The top-level folder of your work tree is usually just a container, not a repo. Check it there and you will conclude the whole setup failed. git init a throwaway repo inside instead.

# Which key is actually OFFERED? This is the real test.
ssh -F ~/.ssh/config-work -v -T git@github.com 2>&1 | grep 'Offering public key'

You want exactly ONE line there, naming the work key. If you see two, IdentitiesOnly is missing somewhere.

# Real write access, without changing anything on the server.
git -C ~/dev/work/some-repo push --dry-run origin HEAD
# gh follows the folder?
(cd ~/dev/work && gh api user --jq .login)   # -> work-user
(cd ~ && gh api user --jq .login)            # -> personal-user

And a signing check, in a throwaway repo inside the work tree:

mkdir -p ~/dev/work/tmp-check && cd ~/dev/work/tmp-check
git init -q && git commit -q --allow-empty -m "check"
git log -1 --format='%an <%ae> sig:%G?'
# -> Your Name <work@example.com> sig:G
cd .. && rm -rf tmp-check

sig:G means good signature. sig:N means not signed. Anything else, something's off.

Can an agent just do part 1 for me?

It can. Paste this into Claude Code, Codex, or whatever you use. It's written so the agent verifies things instead of assuming them.

Set up two GitHub accounts on this machine, scoped by folder. I want a default
account, plus a second account that automatically takes over for every repo
under one specific folder. No manual switching, ever.

Ask me for these first, then do the work:
  - default GitHub username + email
  - second GitHub username + email
  - the absolute path of the folder that belongs to the second account

Configure all three layers so they agree:

1. IDENTITY. In ~/.gitconfig set the default user.name/user.email. At the very
   BOTTOM of the file add an includeIf with a "gitdir:" condition pointing at
   the folder (absolute path, trailing slash) and loading a .gitconfig-work
   file inside it, which overrides user.email.

2. TRANSPORT. Generate a separate ed25519 key per account if they do not exist.
   In ~/.ssh/config pin the default key to Host github.com and include
   "IdentitiesOnly yes". Then create a SEPARATE file ~/.ssh/config-work that
   also declares Host github.com but points at the second key, again with
   IdentitiesOnly yes. In .gitconfig-work set:
       core.sshCommand = ssh -F /absolute/path/to/.ssh/config-work
   Do NOT try to solve this with url.insteadOf rewriting and do NOT try to
   solve it with "ssh -i". Both silently fail. Verify why before trusting it:
     - insteadOf rules do not chain, and when two rules match the same prefix
       length the global one wins, so an https:// remote inside the work tree
       still resolves to the default key.
     - "ssh -i key" does NOT override IdentityFile from ssh_config, they are
       additive and the config entry gets offered first.
   Prove both of these on this machine and show me the output.

3. SIGNING. In .gitconfig-work use SSH signing (gpg.format = ssh,
   user.signingkey = the work .pub, commit.gpgsign = true) so the signing
   identity matches the commit identity. Set gpg.ssh.allowedSignersFile
   globally and write the allowed_signers entry. Then TELL me to add that
   public key to the second GitHub account a second time with key type
   "Signing Key" (not just Authentication), and check via
   https://api.github.com/users/USERNAME/ssh_signing_keys whether it landed.

4. gh CLI. Log in to both accounts. Note that `gh auth login` has NO --user
   flag, so the account is chosen by whoever is signed in in the browser
   during the flow. Tell me to use a private window for the second one. Then
   install a wrapper script on PATH (before
   the real gh) that picks the account per invocation: if the working directory
   is inside the work folder, or an explicitly named repo owner belongs to the
   work account, run the real gh with GH_TOKEN set from
   `gh auth token -u WORK_USER`. Exclude `gh auth ...` from the wrapper. Never
   call `gh auth switch`, the globally active account must stay untouched.

Also check ~/.git-credentials. If a bare `[credential] helper = store` exists,
it will silently copy tokens out of the keyring into a plaintext file. Scope it
away from github.com and tell me if a stale token is sitting there.

Back up every file you touch before editing, and tell me where the backups are.

Then VERIFY and show me the output of each, do not just tell me it worked:
  - ssh -T git@github.com, and with -F the work config, showing two different
    "Hi <user>!" responses
  - `ssh -v ... | grep 'Offering public key'` for both, proving exactly ONE key
    is offered in each case
  - git config user.email resolved inside and outside the folder, checked
    INSIDE A REAL REPO in each case (includeIf "gitdir:" matches the location
    of a .git directory, so in a bare folder it silently reports the global
    identity and looks like a failure)
  - git push --dry-run against a real repo in each tree
  - gh api user --jq .login from inside and outside the folder
  - a signed test commit in a throwaway repo inside the work tree, showing
    sig:G and the correct author email, then delete it
  - that look-alike sibling folders (e.g. the folder name plus a suffix) do NOT
    inherit the work identity

Report anything that does not pass instead of glossing over it.

How do you add a third account?

Once you have the pattern, another account is mechanical. Say a second client, client-user, living in ~/dev/client.

Four steps. All of them copies of what you already have.

1. A key.

ssh-keygen -t ed25519 -C "client@example.com" -f ~/.ssh/id_ed25519_client

Add your public key to that GitHub account twice: once as Authentication, once as Signing Key.

2. An SSH config for it. ~/.ssh/config-client:

Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_client
  IdentitiesOnly yes

3. A git config for it. ~/dev/client/.gitconfig-client:

[user]
	name = Your Name
	email = client@example.com
[core]
	sshCommand = ssh -F /home/you/.ssh/config-client
[gpg]
	format = ssh
[user]
	signingkey = /home/you/.ssh/id_ed25519_client.pub
[commit]
	gpgsign = true
[tag]
	gpgsign = true

4. One more includeIf, appended to ~/.gitconfig after the existing one:

[includeIf "gitdir:/home/you/dev/client/"]
	path = /home/you/dev/client/.gitconfig-client

Does the order matter? Only if the folders are nested. Last match wins. If they're siblings, order is irrelevant.

Then add the account to the gh wrapper. With three accounts a lookup table reads better than a chain of ifs:

#!/bin/bash
GH_BIN=/usr/bin/gh

# One row per NON-default account:  <gh-account>:<tree>:<owners-regex>
# The default account is deliberately absent. It is whatever `gh` has active
# when no row matches.
ACCOUNTS=(
  "work-user:$HOME/dev/work:work-user|SomeWorkOrg"
  "client-user:$HOME/dev/client:client-user|ClientOrg"
)

account=""
best=0
cwd="$(pwd -P)/"

for row in "${ACCOUNTS[@]}"; do
    acct="${row%%:*}"; rest="${row#*:}"
    tree="${rest%%:*}"; owners="${rest#*:}"

    # LONGEST matching tree wins, so a nested folder beats the one it sits in.
    if [[ "$cwd" == "$tree/"* && ${#tree} -gt $best ]]; then
        account="$acct"; best=${#tree}
    fi
    # An explicitly named owner outranks the folder rule.
    if [[ $* =~ (^|[[:space:]=/])($owners)/ ]] || [[ $GH_REPO =~ ^($owners)/ ]]; then
        account="$acct"; best=9999
    fi
done

if [[ -n "$account" && "$1" != "auth" ]]; then
    token=$("$GH_BIN" auth token -u "$account" 2>/dev/null)
    if [[ -n "$token" ]]; then
        GH_TOKEN="$token" exec "$GH_BIN" "$@"
    fi
    echo "gh: no stored token for $account" >&2
    echo "gh: run \`gh auth login\` and authenticate as $account" >&2
fi

exec "$GH_BIN" "$@"

Why a loop and not a case statement? Because case is first-match-wins, and the includeIf rule one section above is last-match-wins. List your accounts in the same order in both places and nested folders resolve to opposite accounts:

case "$CWD" in
  "$HOME/dev/work/"*)        account=work-user ;;
  "$HOME/dev/work/client/"*) account=client-user ;;   # never reached
esac

cwd = ~/dev/work/client/repo  ->  work-user      (git says client-user)

Ranking by the length of the matching tree makes the wrapper agree with git instead of quietly disagreeing with it.

Don't forget gh auth login for the new account. Otherwise the wrapper finds no token and quietly falls through to your default.

That failure is silent. Which is exactly why the version back in part 1 prints a warning.

And then immediately switch back:

gh auth switch -u personal-user

Adding a third account makes this worse than it was with two, because now the login you just did leaves the newest account as the global default for every unscoped folder you own. Check all of your trees, not just the new one:

for d in ~/code/personal-thing ~/dev/work/some-repo ~/dev/client/some-repo; do
  printf '%-32s %s\n' "$d" "$(cd "$d" && gh api user --jq .login)"
done

Three different answers, each matching the folder. If they are all the same, your wrapper is not being reached at all. Check that ~/.local/bin really comes before /usr/bin:

echo "$PATH" | tr ':' '\n' | grep -n -E '^(.*/\.local/bin|/usr/bin)$'

Can an agent do part 2 too?

This machine already has multi-account GitHub set up: a default account, plus
per-folder overrides driven by "includeIf gitdir:" entries in ~/.gitconfig,
each loading a .gitconfig-NAME that sets user.email and
core.sshCommand = ssh -F ~/.ssh/config-NAME, plus a gh wrapper on PATH that
picks the account per invocation.

Read those existing files first and follow the pattern exactly. Do not
redesign it and do not touch the accounts that already work.

Add one more account. Ask me for:
  - the new GitHub username + email
  - the absolute path of the folder it should own
  - any GitHub org names belonging to it

Then:
  1. Generate ~/.ssh/id_ed25519_NEW if it does not exist, chmod 600.
  2. Create ~/.ssh/config-NEW declaring Host github.com with that key and
     IdentitiesOnly yes, chmod 600.
  3. Create the folder's .gitconfig-NEW with user.name/user.email,
     core.sshCommand pointing at config-NEW, and SSH commit signing
     (gpg.format = ssh, user.signingkey = the .pub, commit.gpgsign = true).
     Add the key to ~/.config/git/allowed_signers.
  4. Append one more includeIf to ~/.gitconfig. If the new folder is nested
     inside an existing scoped folder, place it AFTER that one, since last
     match wins. Say which case applies.
  5. Extend the gh wrapper with the new folder and its owners. Refactor to a
     lookup table if it is getting long, and rank directory matches by longest
     matching path, NOT with a `case` statement, which is first-match-wins and
     would disagree with git on nested folders. Keep the `gh auth` exclusion
     and keep the warning when no token is found.

Tell me to run `gh auth login` for the new account. There is no --user flag on
`gh auth login`, so warn me that the account is picked by whoever is signed in
in the browser, and that I should use a private window. Also tell me to add the
public key to that GitHub account TWICE: once as an Authentication key, once as
a Signing Key.

After I have logged in, remind me to run `gh auth switch -u DEFAULT_USER`.
`gh auth login` leaves the account it just logged in as globally ACTIVE, and
the wrapper falls through to the active account for every unscoped folder, so
until I switch back all of my default repos silently resolve to the new
account. Verify this by running `gh api user --jq .login` from EVERY tree,
including the ones you did not touch, not just the new one.

Back up what you edit. Then verify and show real output:
  - ssh -F ~/.ssh/config-NEW -T git@github.com returns the new username
  - `ssh -v ... | grep 'Offering public key'` shows exactly ONE key
  - git config user.email in the new folder, checked inside a REAL REPO (in a
    bare folder includeIf does not fire and you will get the global identity
    and a false negative), and confirm the PRE-EXISTING accounts still resolve
    correctly (regression check, this is the point)
  - gh api user --jq .login from the new folder and from the old ones
  - a signed empty commit in a throwaway repo in the new folder shows sig:G
    with the right email, then delete it

If I tell you the folder for an EXISTING account has moved, treat that as a
different job: the path is hardcoded in four places and a missing includeIf
path is silently ignored rather than erroring, so the tree just reverts to the
default identity. Update all four, then verify. The four are the includeIf
CONDITION and its path= line (both, in ~/.gitconfig), the .gitconfig-NAME file
itself which must move with the folder, the tree in the gh wrapper table, and
any comments naming the old path. Run this first and make sure it comes back
empty afterwards:
    grep -rn "OLD/PATH" ~/.gitconfig ~/.ssh/config-* ~/.local/bin/gh
~/.ssh/config-NAME and the keys are keyed on host and account, not directory,
so they do NOT need changing.

What if you move a scoped folder later?

You will. The folder that seemed right on day one ends up somewhere else, and the whole mechanism is keyed on an absolute path.

Nothing warns you. Git does not validate that an includeIf path exists, and a missing include is not an error, it is just skipped. So the tree silently reverts to your default identity and keeps committing.

There are four places holding that path, and you have to change all of them:

grep -rn "dev/work" ~/.gitconfig ~/.ssh/config-work ~/.local/bin/gh
  1. ~/.gitconfig, the includeIf condition and its path = line. Two edits on one stanza, and it is easy to change the condition and forget the path.
  2. <new-folder>/.gitconfig-work, which has to physically move with the folder.
  3. ~/.local/bin/gh, the tree in the account table. Miss this one and git is right while gh is wrong, which is the most confusing possible state.
  4. Any comments naming the old path. Cosmetic, but this is the file you will read in a year when something breaks.

Note what is NOT on that list: ~/.ssh/config-work and the keys. Those are keyed on host and account, not on a directory, so they survive a move untouched.

Then verify with a real repo in the new location, and confirm both layers agree:

mkdir -p ~/dev/newplace/tmp-check && cd ~/dev/newplace/tmp-check
git init -q && git commit -q --allow-empty -m check
git log -1 --format='%ae sig:%G?'      # -> work@example.com sig:G
gh api user --jq .login                # -> work-user
cd .. && rm -rf tmp-check

Both lines have to be right. Checking only the git one is how you end up with correctly-authored commits and a gh pr create that opens PRs as the wrong person.

What are the two traps?

Both of these look correct. Both are recommended all over the internet. Both silently do the wrong thing.

I only found them because I tested the output instead of trusting the config.

Trap 1: why don't insteadOf rewrites chain?

The classic advice for this problem is a fake SSH host alias plus URL rewriting:

Host github.com-work
  HostName github.com
  IdentityFile ~/.ssh/id_ed25519_work
[url "git@github.com-work:"]
	insteadOf = git@github.com:

That works for git@ remotes. It does not work for https:// remotes.

And here's the thing. It fails quietly.

If you also have the common global rewrite https://github.com/ to git@github.com:, then inside your work folder an https remote gets rewritten once, to git@github.com:, and stops. Git does not run the rewrite a second time to catch the work rule.

So it goes out on your personal key.

You'd think adding an https:// rule to the work config would fix it. It doesn't. Both rules match the same prefix, and on a tie the global one wins:

inside the work folder:
  https://github.com/Org/repo.git  ->  git@github.com:Org/repo.git   (personal key)

Piling more rules into the work file won't save you either, because you can't make your rule more specific. The owner varies.

Trap 2: why doesn't ssh -i override ssh_config?

So your next idea is to skip URL rewriting entirely and just force the key:

[core]
	sshCommand = ssh -i ~/.ssh/id_ed25519_work -o IdentitiesOnly=yes

Looks bulletproof, right?

It is not. -i and IdentityFile from ssh_config are additive, not overriding, and the config entry gets offered first:

$ ssh -i ~/.ssh/id_ed25519_work -o IdentitiesOnly=yes -T git@github.com
Hi personal-user!

Read that again. You explicitly passed the work key, with IdentitiesOnly, and you got the personal account.

-F is the ONLY flag that's actually authoritative, because it replaces the config file rather than adding to it:

$ ssh -F ~/.ssh/config-work -T git@github.com
Hi work-user!

That's why the setup above uses a whole second SSH config file instead of a one-line flag. It's slightly more machinery.

It's also the difference between working and appearing to work.

What is that token doing in .git-credentials?

Go look at this file:

cat ~/.git-credentials

If you find a GitHub token sitting in there, it probably arrived by accident.

Git's credential protocol runs every configured helper. When one of them answers successfully, git then calls store on the rest of the chain. So if you have a bare [credential] helper = store sitting alongside the gh credential helper, store will faithfully copy gh's OAuth token out of your keyring and write it into a plaintext file in your home directory.

Nobody chose that. It just happens.

Why care, in a multi-account setup? Two reasons. It's a plaintext credential, often world readable. And it's ONE account's token being handed out for whatever host matches, which is precisely the cross-contamination you're trying to prevent.

If you follow the setup above, all your GitHub traffic goes over SSH and that file is never consulted for github.com. Still worth cleaning up.

Find out whether a bare store is in the chain:

git config --global --get-regexp '^credential'

A line reading credential.helper store, with no host in the key, is the problem one. It applies to every host. Drop it, and re-add store scoped to only the host that genuinely needs it:

git config --global --unset-all credential.helper
git config --global --add credential.https://gitlab.com.helper store

Then delete any stale github.com line out of the file itself, and lock it down:

sed -i '/github\.com/d' ~/.git-credentials
chmod 600 ~/.git-credentials

What you want to end up with is host-scoped helpers only:

credential.https://github.com.helper  !/usr/bin/gh auth git-credential
credential.https://gitlab.com.helper  store

So what do you actually get?

  • git clone a work repo into the work folder, and it just works. Right identity, right key, verified signature.
  • gh pr create in a work repo opens the PR as your work self.
  • Your personal repos are completely untouched by any of it.
  • Nothing to switch, so nothing to forget to switch back.

The whole thing is five files and about sixty lines.

In short: the config was the easy part. Most of the work was finding out that the two most-recommended approaches don't actually work.

Resources

Set it up once and stop thinking about it. Ganbatte!