One machine, several git identities: typically personal vs work, GitHub vs GitLab, or two accounts on the same provider. This setup makes every repository pick the right name, email, and keys on its own, so no work commit ever goes out under your personal address (or the reverse). In a hurry: run the Appendix B script once per identity.
β οΈ Read this first. A misconfigured git identity fails silently: git keeps committing, just with the wrong author. Go through Part 1 step by step and verify at the end. That is what step 5 is for.
| Feature | Minimum git |
|---|---|
includeIf "gitdir:" / gitdir/i: (identity per folder) |
2.13 |
includeIf "onbranch:" |
2.23 |
SSH commit signing (gpg.format = ssh) |
2.34 |
includeIf "hasconfig:remote.*.url:" (identity per provider) |
2.36 |
Check yours with git --version. Every command and pattern in this document
was executed against git 2.55 before publication.
- Part 1. Make it work, make it quick
- Part 2. Make me learn
- Appendix A. The manual editing workflow
- Appendix B. Idempotent bootstrap script
- Appendix C. Credits
Five steps, all through git config commands: no config file is edited by
hand, so no typo can silently break your whole git setup. Prefer editing the
files yourself? See Appendix A.
gitdir: (identity per folder) |
hasconfig:remote.*.url: (identity per remote) |
|
|---|---|---|
| The identity follows | where you cloned | who hosts the repo |
| Needs discipline about | keeping clones inside the right folder | nothing |
| Breaks when | you clone outside the tree (/tmp, a scratch dir) |
a repo has no remote yet (git init) |
They combine: gitdir: for the general case, hasconfig: to force the
right identity wherever a repo happens to sit. Start with one. Trade-offs:
2.4. Neither fits?
2.8.
SSH is optional: it authenticates ssh remotes (git@...). Repos cloned over
https authenticate with a credential helper instead; in that case skip to
step 3.
Generate one key per identity:
ssh-keygen -t ed25519 -C "you@personal.example" -f ~/.ssh/github_personal
ssh-keygen -t ed25519 -C "you@work.example" -f ~/.ssh/gitlab_workAdd each public key (*.pub) to the matching provider account, then map
one key per host in ~/.ssh/config:
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_personal
IdentitiesOnly yes
AddKeysToAgent yes
Host gitlab.com
HostName gitlab.com
User git
IdentityFile ~/.ssh/gitlab_work
IdentitiesOnly yes
AddKeysToAgent yesIdentitiesOnly yes is not optional: without it, ssh offers every key it
knows until the server rejects it, and you may authenticate as the wrong
account. On macOS add UseKeychain yes to unlock the passphrase from the
keychain.
Keys authenticate, git config names the author: two independent layers, detailed in 2.5.
Two accounts on the same provider? See Same provider, two accounts.
git config --file="$HOME/.gitconfig-personal" --replace-all user.name "personal_username"
git config --file="$HOME/.gitconfig-personal" --replace-all user.email "personal@users.noreply.github.com"
git config --file="$HOME/.gitconfig-work" --replace-all user.name "work_username"
git config --file="$HOME/.gitconfig-work" --replace-all user.email "you@company.example"Use --replace-all, not --add. --add appends a new line every time you
run it, and you end up with a file holding three different emails.
The β¦@users.noreply.github.com address comes from GitHub β Settings β Emails,
with "Keep my email addresses private" enabled.
Per folder (~/code/personal/, ~/code/work/):
git config --global --replace-all "includeif.gitdir:~/code/personal/.path" "~/.gitconfig-personal"
git config --global --replace-all "includeif.gitdir:~/code/work/.path" "~/.gitconfig-work"Per provider, one rule per URL shape - the ssh and the https form of a remote need their own pattern (2.3 explains why):
git config --global --replace-all "includeif.hasconfig:remote.*.url:https://github.com/**.path" "~/.gitconfig-personal"
git config --global --replace-all "includeif.hasconfig:remote.*.url:git@github.com:*/**.path" "~/.gitconfig-personal"
git config --global --replace-all "includeif.hasconfig:remote.*.url:https://gitlab.com/**.path" "~/.gitconfig-work"
git config --global --replace-all "includeif.hasconfig:remote.*.url:git@gitlab.com:*/**.path" "~/.gitconfig-work"Reading the key: includeif . <the condition> . path. The condition
is the part between the quotes in the file syntax, and it keeps its own colons
and slashes, which is why the whole key is quoted for the shell. Include
ordering and precedence: 2.1.
The trailing slash in
gitdir:is mandatory.~/code/personal/matches every repository below that folder;~/code/personal(no slash) matches nothing. This one line is the single most common cause of "it doesn't work"; see 2.3.
Remove the global identity and forbid guessing, so a repo that matches nothing fails loudly instead of committing as the wrong person:
git config --global --unset-all user.email
git config --global --unset-all user.name
git config --global user.useConfigOnly true(If the user.* keys were already absent, git config exits with status 5 and
prints nothing; that is the "nothing to unset" code, not an error.)
user.useConfigOnly carries the guarantee: without it, git builds an identity
from your OS username and hostname and commits with it. With it, a commit in an
unmatched repo stops with:
fatal: no email was given and auto-detection is disabled
That error is the setup working: fix it by moving the repo under the right
folder, or with a local git config user.email β¦.
cd ~/code/personal/some-repo # β οΈ a real git repository, not its parent
git config --show-origin --get user.email
git config --show-origin --get user.name
git var GIT_AUTHOR_IDENT # the identity the next commit will use
ssh -T git@github.com # says which account you authenticate as--show-origin prints which file the value came from; that is the whole
verification. Expected output:
file:/Users/you/.gitconfig-personal personal@users.noreply.github.com
Nothing at all? You are almost certainly outside a repository: a conditional
include is evaluated against the repository git dir, so git config --get user.email in a plain folder legitimately returns nothing; the full evaluation
rules (symlinks, worktrees) are in 2.2. Wrong
file, or the global one? Go to 2.7.
SSH key, no GPG install:
git config --file="$HOME/.gitconfig-personal" --replace-all gpg.format ssh
git config --file="$HOME/.gitconfig-personal" --replace-all user.signingkey "~/.ssh/github_personal.pub"
git config --file="$HOME/.gitconfig-personal" --replace-all commit.gpgsign trueThen register that same public key on the provider as a signing key (a GitHub authentication key is not automatically a signing key). GPG instead, and the local-verification setup, are in 2.6.
~/.gitconfig includeIf rules + user.useConfigOnly, no identity
~/.gitconfig-personal name, email, keys of the personal identity
~/.gitconfig-work name, email, keys of the work identity
~/.ssh/config one Host block per key
~/.ssh/github_personal + .pub, one key pair per identity
Everything above is four mechanisms in a trench coat. This part explains them, so that the next time something is off you can diagnose instead of retry.
Git reads configuration from several files and concatenates them in order; for a plain key, the last value read wins:
| Scope | File | Flag |
|---|---|---|
| system | /etc/gitconfig |
--system |
| global | ~/.gitconfig or ~/.config/git/config |
--global |
| local | <repo>/.git/config |
--local |
| worktree | <repo>/.git/config.worktree |
--worktree |
Consequences worth remembering:
- An included file is read at the point of the
includeIfline, so it overrides what the global file set before it. That is why step 4 works. - A local value always beats an included one: a
user.emailset once in a repo will quietly outrank your whole scheme.git config --show-originis what exposes it. git config --list --show-scopelabels values from an included file asglobal(that is the scope that pulled them in). Use--show-originwhen you want the actual file.
The condition is evaluated when the configuration is loaded, against the repository git directory git resolved for the current command. Three consequences:
- Outside a repository there is no git dir, so no
gitdir:condition can match andgit config --get user.emailprints nothing. Nothing is broken. - Matching uses the resolved, absolute path. Symlinked trees (
/varβ/private/varon macOS, a symlinked~/code) match the real path, not the one you typed. - Git checks the path of the git dir, so a linked worktree or a submodule
whose git dir lives under
.git/modules/β¦may not match a pattern written for the working tree.
Behavior against a repo in ~/code/perso/deep/nested/r1:
| Pattern | Matches | Why |
|---|---|---|
gitdir:~/code/perso/ |
β | a pattern ending with / gets ** appended: everything below, recursively |
gitdir:~/code/perso |
β | no trailing slash: this matches that exact path only |
gitdir:perso/ |
β | a pattern that does not start with ~/, ./, / or **/ gets **/ prepended |
gitdir:~/code/perso/**/.git |
β | explicit globbing also works, but it is not required |
gitdir:~/CODE/perso/ |
β | conditions are case-sensitive |
gitdir/i:~/CODE/perso/ |
β | /i makes the match case-insensitive (Windows, macOS) |
The trailing slash does the recursive match on its own; a /.git suffix adds
nothing. Add /i on Windows, where the path case you see is not always the
path case git resolves.
Other conditions:
onbranch:<glob>: identity (or any config) per branch name, e.g.onbranch:release/*.hasconfig:remote.*.url:<glob>: matches if any remote URL of the repo matches the glob. Careful:**is only special when it touches a slash (**/,/**); anywhere else it silently degrades to*, and*never crosses/. So one pattern cannot cover both URL shapes. Use one rule per shape:https://github.com/**for https clones,git@github.com:*/**for ssh (scp-style) clones. Note it needs a remote to exist: a freshgit initmatches nothing untilgit remote add.
gitdir: states "repos in this folder are personal". It is stable, works
offline and predates everything, but it only holds while you clone where you
said you would.
hasconfig:remote.*.url: states "repos hosted there are personal", which is
what you actually mean, and it keeps working for a clone in /tmp.
A robust combination: hasconfig: rules for the providers you use, plus one
gitdir: rule for the work tree, listed after them so the folder wins when
both apply.
Two independent layers, two failure modes:
- SSH decides which account authenticates: the key ends up mapped to a provider account. Wrong key β permission denied, or a push landing on the wrong account.
- git config decides whose name is written in the commit. Wrong identity β the push succeeds and the commit shows the wrong author.
They are set independently and can disagree, which is exactly how you commit as
personal and push as work.
SSH cannot pick a key from the URL path, so
give each account its own Host alias:
Host github-personal
HostName github.com
User git
IdentityFile ~/.ssh/github_personal
IdentitiesOnly yes
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/github_work
IdentitiesOnly yesthen clone with git clone git@github-personal:me/repo.git. Alternatively, keep
real URLs and bind the key to the identity file instead:
git config --file="$HOME/.gitconfig-work" --replace-all \
core.sshCommand "ssh -i ~/.ssh/github_work -o IdentitiesOnly=yes"The key now follows the same condition as the name and email (one rule instead
of two), and git clone of an existing repo keeps its normal URL. (Rewriting
URLs globally with url.<base>.insteadOf is a third option; it is the least
readable of the three when something goes wrong.)
SSH signing reuses a key you already have; see Part 1. To verify signatures locally, git needs to know which key belongs to whom:
echo "personal@users.noreply.github.com $(cat ~/.ssh/github_personal.pub)" >> ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile "~/.ssh/allowed_signers"
git log --show-signature -1GPG signing, per identity:
git config --file="$HOME/.gitconfig-personal" --replace-all user.signingkey "<GPG_KEY_ID>"
git config --file="$HOME/.gitconfig-personal" --replace-all commit.gpgsign true
git config --global --replace-all gpg.program "/path/to/gpg"The config key is
gpg.program. A[program] pgp = β¦block is ignored by git, which silently keeps calling the firstgpgin yourPATH.
Whichever you use, the signature is only shown as verified by the provider when the commit email matches an email registered on that account. This is why per-identity signing and per-identity email have to move together: sign with the work key while committing with the personal noreply address and the web UI shows the commit as unverified.
Start here, always:
git rev-parse --git-dir # am I in a repo, and which git dir?
git config --list --show-origin --show-scope # every value and the file it came from| Symptom | Cause | Fix |
|---|---|---|
git config --get user.email prints nothing |
you are not inside a repository | cd into a repo |
| Identity not applied in a repo under the folder | missing trailing / in gitdir: |
gitdir:~/code/perso/ |
| Not applied, path looks right | case mismatch, or symlinked path | use gitdir/i:, or the resolved path, see 2.2 |
| Applied, but the value is wrong | a user.email sits in .git/config |
git config --local --unset user.email, see 2.1 |
| Three emails in an identity file | --add used repeatedly |
git config --file=β¦ --replace-all β¦ |
| Right author, wrong account on push | SSH key, not git config | ssh -T git@host, IdentitiesOnly yes, see 2.5 |
hasconfig: never matches |
repo has no remote yet | add the remote, or add a gitdir: rule |
hasconfig: matches ssh clones but not https, or the reverse |
one glob cannot cover both URL shapes | one rule per shape, see 2.3 |
| Commit shows as unverified | signing key β commit email on the account | align email and key, see 2.6 |
- Per-project include. Keep a
.gitconfiginside the project and pull it in on demand with a global alias:git config --global alias.set-config '!git config --local include.path "$(git rev-parse --show-toplevel)/.gitconfig"', then rungit set-configonce per clone. Useful when the identity is a property of the project rather than of a folder or a host. - Third-party tools (identity switchers, clone hooks) exist. They solve the same problem with an extra dependency and a state to keep in sync; the built-in conditions cover the cases above without either.
- Do not use
includeIffor a one-off: a single repository with a different author is justgit config --local user.email β¦.
The same setup through hand-edited files. Same result; the risk is a typo in
~/.gitconfig, which makes all git config fail silently.
~/.ssh/config:
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_private_key
IdentitiesOnly yes~/.gitconfig: replace the [user] block with:
[user]
useConfigOnly = true
[includeIf "gitdir:~/code/personal/"]
path = ~/.gitconfig-personal
[includeIf "gitdir:~/code/professional/"]
path = ~/.gitconfig-professionalProvider rules (hasconfig:) take the same shape, with the patterns from
step 4.
~/.gitconfig-personal:
[user]
email = personal@users.noreply.github.com
name = personal_username
signingkey = SIGNING_KEY_ID
[commit]
gpgsign = true~/.gitconfig-professional: same shape, work values. signingkey here is the
GPG variant; the SSH variant is in 2.6. Then verify
as in step 5.
Re-runnable: --replace-all overwrites instead of appending.
#!/usr/bin/env bash
set -euo pipefail
# usage: ./git-identity.sh personal ~/code/personal/ "personal_username" "personal@users.noreply.github.com" ~/.ssh/github_personal
profile="$1"; name="$3"; email="$4"; key="${5:-}"
dir="${2%/}/" # normalize the mandatory trailing slash
file="$HOME/.gitconfig-$profile"
git config --file="$file" --replace-all user.name "$name"
git config --file="$file" --replace-all user.email "$email"
if [ -n "$key" ]; then
git config --file="$file" --replace-all core.sshCommand "ssh -i $key -o IdentitiesOnly=yes"
fi
git config --global --replace-all "includeif.gitdir:${dir}.path" "$file"
git config --global user.useConfigOnly true
git config --global --unset-all user.name || [ $? -eq 5 ] # status 5 = already absent
git config --global --unset-all user.email || [ $? -eq 5 ]
echo "wrote $file and its includeIf rule for ${dir}"
echo "verify from inside a repo: git config --show-origin --get user.email"Thanks to the commenters this guide is improved on:
- @slmg: the
git configcommands - @slmingol:
--replace-allover--add - @igorbrites: the trailing slash
- @YaoC, @MGREMY: verification only works inside a repository
- @ThierryBerger:
gitdir/i:on Windows - @cbbdev: the per-project alias
- @offwork: agent & keychain
- @shelllee: identity per provider
- @shellheim:
hasconfig:remote.*.url:

Thanks for the gist. To partially address your roadmap, here's how to deal with the
includeIfsection only usinggit configcommands:git config --file=.gitconfig-personal --add user.name personal_username git config --file=.gitconfig-personal --add user.email user.personal@users.noreply.github.com git config --global --add includeif.gitdir:~/code/personal/.path .gitconfig-personal