Field note / Git

Three Accounts, One Laptop

Three GitHub identities on one machine, kept apart by four independent mechanisms — none of which is a global switch you can forget to flip.

15 min read

  • Git
  • SSH
  • Developer Tooling
  • Bash

Every one of these accounts talks to the same host, github.com, over SSH as the same user, git. That is worth sitting with for a second: nothing in git@github.com:Org/repo.git says which of me is asking. GitHub works out who you are from the key you present, and it will happily accept any of the three.

So the question isn’t “how do I log in as the right account” — it’s “how does every tool on this machine independently arrive at the right answer, without me remembering to tell it.”

Org slugs and email domains in this post are placeholders — Acme, university-eng, *.example. The account labels are the generic roles, not the real names. The mechanisms are exactly what I run; the identifiers aren’t.

The problem: four surfaces, four ways to leak

“Which account am I?” isn’t one setting. It’s four, owned by four different tools that have never heard of each other:

Surface Decided by When it goes wrong
SSH key Which private key ssh offers github.com You push to a client repo authenticated as your personal account — or get a 403 you can’t explain
Commit author user.email resolved by git Your personal Gmail is now permanently in a client repo’s history
gh CLI auth GH_CONFIG_DIR / hosts.yml gh pr create opens the PR as the wrong human
Claude Code CLAUDE_CONFIG_DIR Client session billed to a personal account, or personal history in a client config

What makes this genuinely dangerous is that three of those four fail silently. A wrong SSH key that still authenticates gives you a successful push. A wrong user.email gives you a clean commit. Nothing turns red. You find out weeks later, in someone else’s code review.

The principle: derive, don’t switch

The obvious fix is a work command that swaps everything at once. It’s also the fix that breaks, because it depends on a human remembering to run it, and because it makes identity global — one wrong state and every terminal on the machine is wrong at the same time.

The setup below does the opposite. Each layer derives identity from a signal that’s already unambiguous and already present, and the layers don’t consult each other. There are exactly two such signals:

The remote URL decides everything git-shaped — key, rewriting, author. The herdr workspace label decides everything session-shaped — which gh account and which Claude config the shell is holding. That split isn’t arbitrary: a URL is per-repo and known at the moment of the operation, while gh and Claude are per-shell and have to be right before you type anything.

Remote URL Acme/api.git Workspace label work insteadOf rules ~/.config/git/config Host alias + IdentitiesOnly ~/.ssh/config includeIf hasconfig ~/.config/git/config _gh_env export ~/.bashrc git@work:Acme/ rewritten URL id_work ssh key offered @work.example commit author gh-work + .claude-personal org host match lookup
The remote URL is consulted three separate times by three tools that never talk to each other; the workspace label is consulted once, at shell startup. Layers 1–4 read the URL. Layers 5–6 read the label.

Layer 1: one host, three keys

~/.ssh/config

This is the foundation, and it’s the layer everything else is built to feed. GitHub has no notion of “logging in” over SSH — the key is the identity. So the trick is to invent three names for one server, each bound to a different key:

# the default: bare github.com is the personal account
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_personal
    IdentitiesOnly yes

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

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

git@work:Acme/api.git is not a different server. HostName github.com sends it to exactly the same place. The alias exists purely to carry one bit of information — which key — through a URL format that has nowhere else to put it.

The line that does the real work. IdentitiesOnly yes is not optional here, and leaving it out produces the single most confusing bug in this whole space. Without it, ssh offers every key in your agent, in agent order. GitHub accepts the first one that’s valid — and all three of yours are valid. You’d authenticate as whoever happened to be first in the agent, the push would succeed, and nothing would warn you. IdentitiesOnly yes means “use the key I named and nothing else.”

Layer 2: rewriting URLs you didn’t type

~/.config/git/config

Layer 1 works only if the URL actually says git@work:. But most URLs on your disk weren’t typed by you. gh repo clone writes its own. A submodule ships one in .gitmodules. An IDE’s clone dialog builds one. A lockfile references one.

So git rewrites them at the plumbing level, before the transport ever runs:

[url "git@work:Acme/"]
	insteadOf = git@github.com:Acme/
	insteadOf = https://github.com/Acme/

[url "git@university:university-eng/"]
	insteadOf = git@github.com:university-eng/
	insteadOf = https://github.com/university-eng/

[url "git@university:university-labs/"]
	insteadOf = git@github.com:university-labs/
	insteadOf = https://github.com/university-labs/

The rule is prefix-based and org-scoped: anything under Acme/ gets the work key, whoever built the URL and whichever protocol they picked. Bare github.com is deliberately left alone, because Layer 1 already points it at the personal key — the personal account is the default, and the two client accounts are the exceptions that have to announce themselves.

That’s a good default to pick. If a rule ever fails to match, you fall back to the account whose repos are public and whose mistakes are cheap.

Layer 3: the clone wrapper

~/.bashrc — git() and _git_clone_target()

Layer 2 handles URLs that are already on disk. Layer 3 handles the one you’re pasting from a browser right now — and, more usefully, tells you what it did. A shell function shadows git, intercepts clone, and passes everything else straight through to command git:

local url_lower="${url,,}"          # lowercase the URL for matching
if   [[ "$url_lower" == *"gaabmarquez"* ]];       then ssh_host="personal" ...
elif [[ "$url_lower" == *"work"* ]];         then ssh_host="work" ...
elif [[ "$url_lower" == *"university-eng"* ]] \
  || [[ "$url_lower" == *"university-labs"* ]];  then ssh_host="university" ...
fi

# no match at all: not one of my accounts, don't touch it
if [[ -z "$ssh_host" ]]; then command git "$@"; return $?; fi

On a match it rewrites https://github.com/Org/repo or git@github.com:Org/repo into git@<host>:Org/repo.git, prints what it’s about to do, and clones. The echo matters more than it looks — it’s the one moment in this entire system where account routing is visible:

🔄 Rewriting URL for work account:
   https://github.com/Acme/api → git@work:Acme/api.git
✅ Set git user to gabriel@work.example

The fiddly bit: finding the target directory

After a successful clone the wrapper writes a repo-local user.email, which means it has to know what directory got created. That’s harder than it sounds, because git clone’s target is a positional argument buried in a pile of flags — and some of those flags eat the argument after them.

_git_clone_target walks the arguments with a one-slot lookahead:

local a skip=0
for a in "$@"; do
    if (( skip )); then skip=0; continue; fi     # this one belongs to the last flag
    case "$a" in
        -b|--branch|-o|--origin|-c|--config|-j|--jobs|--depth|--filter) skip=1 ;;
        --reference|--reference-if-able|--template|--separate-git-dir)  skip=1 ;;
        -u|--upload-pack|--shallow-since|--shallow-exclude|--bundle-uri) skip=1 ;;
        -*) ;;                                   # a valueless flag: ignore it
        *)  printf '%s\n' "$a"; return 0 ;;      # first bare word wins
    esac
done

The skip=1 list is the whole point. Without it, git clone url --depth 1 would see 1 as a bare word and decide the repo was cloned into a directory called 1. When no target is given at all the function prints nothing and the caller falls back to basename of the repo path — the same rule git itself uses.

Layer 4: author identity, by remote

~/.config/git/config → identity-work, identity-university

The wrapper only sees clones you type. Repos you already have, repos a colleague set up, worktrees, repos cloned by a tool — none of those pass through it. So author identity is resolved independently, from the same signal, by git itself.

The usual advice is includeIf "gitdir:~/work/" — identity by directory. This setup uses the better variant, available since git 2.36: identity by remote.

[user]                              # the base identity is personal
	name = Gabriel Márquez
	email = me@gaabmarquez.com

[includeIf "hasconfig:remote.*.url:git@work:*/**"]
	path = ~/.config/git/identity-work
[includeIf "hasconfig:remote.*.url:https://github.com/Acme/**"]
	path = ~/.config/git/identity-work

[includeIf "hasconfig:remote.*.url:git@university:*/**"]
	path = ~/.config/git/identity-university
[includeIf "hasconfig:remote.*.url:https://github.com/university-eng/**"]
	path = ~/.config/git/identity-university

Why this beats gitdir:: a repo can be cloned anywhere. Drop a client repo in ~/Downloads and a directory rule silently gives you your personal email; a remote rule gets it right, because the thing that determines who you are is the thing you’re pushing to, not where the folder happens to sit.

Glob gotcha, already handled. In these patterns * does not cross a /, but ** does. That’s why the pattern is git@work:*/** and not git@work:* — you need * for the org segment and ** for the arbitrarily deep tail. Get this wrong and the include silently never fires, which looks exactly like it not being configured at all.

Layer 5: gh and Claude, per shell

~/.bashrc — _gh_profile_spec, _gh_env, _gh_profile

Layers 1–4 all key off a URL, which works because git operations always have one. gh pr create and claude don’t — they need to already know who you are before you type. Both read their state from a config directory named by an environment variable, which turns out to be exactly the right hook: environment variables are per-process, so two terminals can be two different people at the same time.

One function is the single source of truth. It fakes a lookup table by printing three whitespace-separated fields:

_gh_profile_spec() {
    case "$1" in
        personal)   echo "$HOME/projects   $HOME/.claude-personal   $HOME/.config/gh-personal" ;;
        work)       echo "$HOME/work       $HOME/.claude-personal   $HOME/.config/gh-work" ;;
        university) echo "$HOME/university $HOME/.claude-university $HOME/.config/gh-university" ;;
        *)          return 1 ;;                  # unknown profile: fail, print nothing
    esac
}

Note that personal and work deliberately share ~/.claude-personal while keeping separate gh configs. Those are different concerns and the table lets them disagree — one Claude subscription covering two GitHub identities is a perfectly reasonable thing to want, and a design that forced them to move together couldn’t express it.

Two consumers split the work, and the split is the interesting part:

_gh_env() {                                # exports only — no cd
    local dir claude gh
    read -r dir claude gh < <(_gh_profile_spec "$1") || return 1
    export CLAUDE_CONFIG_DIR="$claude"
    export GH_CONFIG_DIR="$gh"
    export GH_PROFILE="$1"
}

_gh_profile() {                            # cd first, then export
    local dir claude gh
    read -r dir claude gh < <(_gh_profile_spec "$1") || return 1
    cd "$dir" || { echo "❌ $1: no such directory: $dir" >&2; return 1; }
    _gh_env "$1"
    echo "✅ $1  ·  $dir  ·  gh: $(ghwho)"
}

personal()   { _gh_profile personal; }
work()       { _gh_profile work; }
university() { _gh_profile university; }

read -r dir claude gh < <(...) is process substitution: <(cmd) makes the function’s output look like a file, the leading < reads stdin from it, and read splits the line on whitespace into three variables. If the profile is unknown, _gh_profile_spec returns non-zero, read gets nothing, and the whole thing bails without exporting a half-set of variables.

_gh_profile cds before exporting on purpose: a failed cd leaves your environment completely untouched rather than half-switched. _gh_env exists as the separate, cd-less version because of Layer 6 — where something else already owns the working directory.

And a way to check the answer without paying gh’s startup cost, by reading its config file directly:

ghwho() {
    local hosts="${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml"
    if [[ -r "$hosts" ]]; then
        awk '/^[[:space:]]+user:/ { print $2; exit }' "$hosts"
    else
        echo "not logged in"
    fi
}

Layer 6: workspaces as identity

herdr + ~/.bashrc

Layer 5 gives you the ability to be different people in different terminals. It doesn’t stop you from opening a new tab and forgetting. That’s what the last layer fixes.

herdr is a terminal workspace manager — its own mapping puts a workspace where tmux would put a session, a tab where tmux has a window, a pane where tmux has a pane. Crucially, it exports HERDR_WORKSPACE_ID into every pane it spawns. That makes the workspace a signal, and the workspace’s label a place to store an answer:

if [[ -z "$GH_PROFILE" ]]; then
    _herdr_label=""
    if [[ -n "$HERDR_WORKSPACE_ID" ]]; then
        _herdr_label=$("${HERDR_BIN_PATH:-herdr}" workspace get "$HERDR_WORKSPACE_ID" 2>/dev/null \
                       | grep -o '"label":"[^"]*"' | head -1 | cut -d'"' -f4)
    fi
    _gh_env "$_herdr_label" 2>/dev/null || _gh_env "$(_gh_profile_for_dir)" 2>/dev/null
    unset _herdr_label
fi

Three details worth pulling out.

JSON parsing without a JSON parser

grep -o '"label":"[^"]*"' | head -1 | cut -d'"' -f4 is doing what jq would do, deliberately avoiding the dependency because this runs on every shell startup. grep -o prints just the match rather than the line; [^"]* stops at the closing quote; cut -d'"' -f4 splits "label":"work" on quote characters and takes the fourth field. Not elegant, but it’s one process instead of a JSON parser in your prompt latency budget.

A fallback chain, not a lookup

|| runs the right side only when the left fails. If the label isn’t a profile name — you’re in a plain terminal, or the workspace is called something else — it falls back to _gh_profile_for_dir, which matches $PWD against each profile’s root directory by prefix. That’s why an ordinary terminal opened inside ~/university still gets the university account.

Why the guard exists

[[ -z "$GH_PROFILE" ]] means the hook only fires when no profile is set. Subshells inherit exported variables, so without the guard, running bash inside a pane where you’d explicitly typed university would snap you back to the workspace default. An explicit switch should stick.

The net effect is that the workspace label becomes the account. Every pane, every tab, every new split in that workspace is already the right identity — and no gh or claude invocation ever needed to be told.

What survives, and what doesn’t

herdr persists workspaces to ~/.config/herdr/session.json — id, label (stored as custom_name), working directory, tab and pane layout. A server restart or a reboot restores all of it, and the identity comes back for free, because the bashrc hook re-derives it from the label at every shell start rather than storing it.

That’s the real payoff of keying on the label. There is a herdr workspace create --env KEY=VALUE flag, and setting GH_CONFIG_DIR that way would work — until the next restart. Environment variables are not in session.json. The label is.

Closing a workspace, on the other hand, is permanent: the entry leaves session.json and nothing brings it back. Which is worth defending against explicitly:

# herdr config.toml — deliberately unbound.
# A workspace is closed only from the CLI: herdr workspace close <id>
close_workspace = ""

And since ids are irrelevant — nothing keys off w16 — rebuilding is just a matter of restoring three labels:

herdr workspace create --cwd "$HOME/projects"   --label personal   --no-focus
herdr workspace create --cwd "$HOME/work"       --label work       --no-focus
herdr workspace create --cwd "$HOME/university" --label university --no-focus

Seams worth knowing about

Nothing here is broken. But a system with six layers has places where the layers don’t quite line up, and it’s better to know where they are than to rediscover them at 2am.

Author identity is set in two places. Layer 3’s clone wrapper writes a repo-local user.email; Layer 4’s includeIf resolves the same thing globally. Repo-local wins. They currently agree, so this is harmless belt-and-braces — but if you ever change an address in identity-work, already-cloned repos will keep the old one baked into their .git/config, and only new clones will pick up the change.

A mistyped workspace label fails soft, not loud. Label a workspace Work instead of work and _gh_profile_spec won’t match. There’s no error — the || falls through to directory matching, and you get a plausible-looking account that may not be the one you meant. The label is a string contract with no validation on either end.

ghwho says “not logged in” outside a profile. There’s no bare ~/.config/gh on this machine — every account lives in a suffixed directory. So in a shell where no profile resolved, ghwho correctly reports nothing, which reads like breakage but is actually the system telling you it has no opinion yet. Arguably the more useful message.

Proving it works

Every layer resolves silently, which is the goal — and also why each one needs a way to ask it what it decided. The full sweep, one command per layer:

Command What it proves
ssh -T git@work Layer 1 — GitHub greets you by the username that owns id_work
git remote -v Layer 2 — the remote should read git@work:, not github.com
git config --show-origin user.email Layer 4 — both the address and which file decided it
ghwho Layer 5 — the GitHub user this shell’s gh is authenticated as
echo "$GH_PROFILE $CLAUDE_CONFIG_DIR" Layers 5–6 — which profile resolved, and which Claude config came with it
herdr workspace list Layer 6 — the labels that drive everything above

git config --show-origin user.email is the one to reach for first when something looks wrong. It doesn’t just tell you the answer, it tells you which file gave it — repo-local, an includeIf, or the global default — which is usually the actual question.


Six layers, two signals, zero commands to remember. The measure of it isn’t that switching accounts is easy — it’s that there’s nothing to switch.

← All writing