DEV Community

Elbert Bae
Elbert Bae

Posted on

One Laptop, Three GitHub Accounts, Zero Wrong-Account Commits

Git and the gh CLI both assume one person with one account. Here's a small tool that teaches them to follow your directories instead.


Picture a freelancer three contracts deep. Each client has its own GitHub org, its own access rules, and its own reasonable expectation about who is authoring commits in their repositories. Or picture a maintainer who keeps a separate account for an open-source project, or a team that runs a second account for automation and review. Two accounts, three, four — all on one machine.

Now watch what the tooling does with that.

The first symptom is cosmetic but permanent: a commit lands in one client's repository authored by the address belonging to a different account, because user.email is global and nobody remembers to override it until after the first bad commit. Git history is append-only in practice, so it stays.

The second symptom is worse, because it doesn't look like a failure at all. git push succeeds — as the wrong account. ssh-agent offers every key it holds, GitHub authenticates whoever owns the first key it accepts, and if that account also has write access, the push goes through. No error. Just the wrong name in the repository's audit log.

The third is the one that wastes an afternoon: Permission denied (publickey) on a repo you definitely have access to, with no indication of which key was even tried.

None of this is user error. It's the default behaviour of four separate systems that each assume you have exactly one identity.

A note on scope. This is about keeping apart several GitHub accounts that are already legitimately yours to manage on a machine you control — a freelancer's client contracts, a maintainer's project account, a second account kept for automation or review.

It is not a recommendation to add a personal GitHub account to a device your employer owns or administers, or to put one client's credentials on hardware another client controls. Whose machine you are on, and what is permitted on it, is a question for your contracts, your client agreements and your employer's policy — not for a config file. Check first.

ghidmgt solves the problem that starts after that decision is made: given several accounts that all belong on this machine, keep them from impersonating each other.


Four problems wearing one costume

Multi-account git is frustrating because it isn't a single problem. It's four, and they live in four different places.

1. The commit author. user.email is global. Every clone inherits it until you override it per repo, which means remembering, on every clone, forever.

2. The SSH key. ssh-agent presents keys in whatever order it likes, and GitHub logs you in as the owner of the first one it accepts. This is the layer that produces both silent wrong-account pushes and unexplainable permission denials.

3. The gh CLI. gh keeps one active account per host. gh pr create, gh repo clone, gh api — all of them act as whoever logged in last, in every terminal, until someone remembers gh auth switch. It is a global mutable variable with a 30-second feedback loop.

4. HTTPS credentials. A global credential.helper store keeps every account's token in a single ~/.git-credentials, and that file answers for every repo on the machine.

Plus one bonus failure mode that silently overrides all four: a GH_TOKEN exported from a shell rc file.

The usual workarounds each fix one layer and leave the rest standing:

Workaround Fixes Leaves broken
SSH host aliases (git@github-work:org/repo) the key commit email, gh, HTTPS — and every remote URL has to be rewritten, forever
Per-repo git config user.email the author everything else, and only when remembered
direnv / shell cd hooks whatever you script anything that isn't an interactive shell

That last row is the subtle one. A cd hook feels like a complete solution right up until you notice what doesn't run it: VS Code's git integration, JetBrains' commit dialog, pre-commit hooks, cron jobs, Docker builds, CI runners, and AI coding agents issuing git commands in non-interactive shells. None of those source a shell rc. The terminal is perfectly configured while the editor commits as the wrong person.


The idea: put it in git's config, not the shell's

Git has had the mechanism since 2.13 and it's badly underused. includeIf "gitdir:" applies a config file conditionally, based on where the repository lives:

[includeIf "gitdir:~/work/"]
    path = ~/.config/ghidmgt/git/work.gitconfig
Enter fullscreen mode Exit fullscreen mode

The included file carries everything that matters:

[user]
    name = Your Name
    email = you@company.com

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

[credential "https://github.com"]
    helper =
    helper = "!GH_CONFIG_DIR=~/.config/gh-work gh auth git-credential"
Enter fullscreen mode Exit fullscreen mode

Three of the four layers — commit author, SSH key, HTTPS credentials — now live in git's own configuration. Every git process reads that. It holds in IDEs, git hooks, cron jobs, containers, and agent tooling, with no shell involvement whatsoever. Only the gh CLI layer still needs an environment variable, and that one gets exported on cd.

That's the entire concept behind ghidmgt: a single bash script that generates this config correctly, keeps it in sync, and then verifies it against reality.

~/projects/        →  personal   you@example.com    ~/.ssh/id_ed25519        ~/.config/gh
~/work/            →  work       you@company.com    ~/.ssh/id_ed25519_work   ~/.config/gh-work
~/clients/acme/    →  acme       you@acme.example   ~/.ssh/id_ed25519_acme   ~/.config/gh-acme
Enter fullscreen mode Exit fullscreen mode

Register each identity once. After that, cd and work.

Three details that are easy to get wrong

These are worth naming individually, because they're the reason this is a tool rather than a config snippet to copy.

IdentitiesOnly=yes is not optional. Without it, ssh -i is merely a suggestion. ssh still offers every agent key ahead of the one you named, and GitHub still authenticates the first one it accepts. The result is a config file that looks completely correct alongside pushes that go out as the wrong account.

The empty helper = line matters. Git accumulates credential helpers rather than replacing them. Without an empty value to clear the inherited list first, a store helper from ~/.gitconfig is still in play and will happily answer a client's repo with a personal token.

Nested roots must be ordered by specificity. Git applies includes in file order and the last value read wins. The naive implementation — one includeIf block per identity, appended as identities get added — breaks here. With personal → ~/projects and client → ~/projects/client, alphabetical ordering puts client first, personal last, and personal wins: every commit in the client's repos goes out under the wrong email, which is the precise outcome the whole setup exists to prevent. All the includeIf lines have to live in one block sorted shortest-root-first, and the shell resolver has to use the same ordering so the two layers can't disagree.


What using it looks like

git clone https://github.com/bert-bae/ghidmgt.git ~/.local/share/ghidmgt
~/.local/share/ghidmgt/install.sh
Enter fullscreen mode Exit fullscreen mode

The installer symlinks the CLI into ~/.local/bin, adds one clearly marked block to your shell rc, and timestamps a backup of every file it touches first. If the machine already has gh logins, it finds them and prints a ready-to-edit ghidmgt add command for each.

Then, once per identity:

ghidmgt add work --root ~/work --email you@company.com
ghidmgt keygen work     # prints the public key to paste into that GitHub account
ghidmgt login work      # gh auth login, into this identity's own config dir
ghidmgt check work
Enter fullscreen mode Exit fullscreen mode

Omitted options get sensible defaults — that first line also picks up ~/.ssh/id_ed25519_work and ~/.config/gh-work.

After that, nothing is special anymore:

cd ~/work/api
git commit -m "..."   # authored as you@company.com
git push              # authenticated with ~/.ssh/id_ed25519_work
gh pr create          # opened by the work account
Enter fullscreen mode Exit fullscreen mode

Verification that doesn't trust itself

ghidmgt check deliberately does not believe the config files it just generated. It asks GitHub who you actually are:

$ ghidmgt check
~/work/api resolves to identity work
  ✓ shell: GHIDMGT_IDENTITY=work
  ✓ gh:    config dir ~/.config/gh-work
  ✓ ssh:   key ~/.ssh/id_ed25519_work
  ✓ git:   includeIf wired in ~/.gitconfig
  ✓ git:   commits here are authored as you@company.com
  ✓ git:   pushes here use ~/.ssh/id_ed25519_work
  ✓ git:   HTTPS credentials come from ~/.config/gh-work
  ✓ live:  gh authenticates as octo-work
  ✓ live:  ssh authenticates as octo-work

identity 'work' is wired correctly
Enter fullscreen mode Exit fullscreen mode

The two live lines run gh api user and ssh -T git@github.com. They exist because config-level verification structurally cannot catch the most common real-world mistake: a key that is configured perfectly and was pasted into the wrong GitHub account. Only GitHub can answer that.

A companion command, ghidmgt doctor, audits the whole machine for the things that defeat per-directory identities no matter how carefully everything else is configured — an exported GH_TOKEN, a GIT_CONFIG_GLOBAL that replaces ~/.gitconfig along with every includeIf in it, a global credential helper with a shared store, a stray IdentityFile for github.com in ~/.ssh/config, a shell rc that was never wired, or an identity with no key and no login.


Who this is for

  • Freelancers and agencies running several contracts. One root per client. Credentials never overlap, and offboarding is ghidmgt rm acme --purge, which logs that account out of gh so its token actually leaves the system keyring instead of lingering in the Keychain or Secret Service.
  • Maintainers with a dedicated project account kept separate from whatever else they push with.
  • Teams running a second account for bots, automation or review that must never be mistaken for the account that signs off on the work.
  • Anyone deliberately keeping two identities distinct, for whatever reason that's their own business.
  • Anyone running AI coding agents. Agents issue git commands in non-interactive shells, which is exactly where shell-hook solutions evaporate. Git-level isolation means an agent working under one client's root structurally cannot commit as a different account.
  • Dotfiles and provisioning scripts. ghidmgt add is declarative and idempotent — re-running it with the same flags changes nothing, and re-running it with fewer flags preserves the omitted fields — so the whole setup is safe to drop into a script that runs on every new machine.

And what it isn't

Better to find the limits here than after installing:

  • git worktree keeps its original identity. includeIf "gitdir:" matches the repository's git directory, not the worktree's path, and git offers no condition on the worktree path. A worktree of a ~/work repo checked out under ~/clients/acme still commits as work. ghidmgt check reports this explicitly rather than hiding it, but it can't fix it.
  • Roots match by path. A repo moved outside every root falls back to the global identity, silently.
  • Bash scripts don't get the gh layer automatically. Bash has no .zshenv equivalent, so non-interactive bash needs ghidmgt exec work gh .... Git-level isolation still applies either way.
  • github.com only. GitHub Enterprise Server isn't managed yet.
  • Commit signing isn't managed yet. You can add user.signingkey to the generated include by hand, but sync will overwrite it. A --signing-key option is near the top of the list and would make a very welcome PR.

The boring details that should earn some trust

One 1,300-line bash script. No runtime dependencies beyond git, ssh and gh. Runs on macOS, Linux and WSL2, under zsh or bash — including bash 3.2, the version macOS still ships.

It edits files only between clearly marked # >>> ghidmgt: ... >>> blocks, and everything outside those markers is left byte-for-byte alone. If a marker pair is ever broken — a half-deleted block, a merge conflict — it refuses to touch the file rather than guessing where the block ends. A symlinked ~/.gitconfig (stow, chezmoi, home-manager) is edited through the link instead of being replaced by a regular file.

There are 46 test cases covering the resolver, the generated config, nested roots, symlinked rc files, injection attempts through hand-edited registry values, and the --purge safety checks. They run in Docker with no network, no host mounts and no capabilities, under both bash 5 and bash 3.2, plus a macOS job in CI for the BSD userland. The suite flatly refuses to run on a normal machine — a tool that rewrites ~/.gitconfig and ~/.ssh/config has no business testing itself against your real home directory.

MIT licensed. No telemetry; the only network calls are the two live checks you explicitly ask for.


Try it, break it, say so

→ github.com/bert-bae/ghidmgt

The config needed to keep GitHub accounts genuinely separated isn't long, but it has at least three non-obvious details that fail silently when you get them wrong — which is the worst possible failure mode for anything touching credentials. This packages them, and then checks the result against GitHub instead of against itself.

If it breaks, open an issue. Isolation bugs are security bugs here, so there's a SECURITY.md for reporting those privately. If you want commit signing or GitHub Enterprise support, say so — or send a PR; both are on the roadmap and neither is hard.

And if you've solved this a different way, that's worth hearing. The includeIf approach holds up well in practice, but "it works on my machine" is the weakest form of evidence there is.

Top comments (0)