One machine, several git identities (personal vs work, GitHub vs GitLab, two accounts on one provider). Each repository picks its own name, email and credentials. In a hurry: Appendix B, once per rule.
A wrong identity does not fail: git commits and pushes under the wrong author. Part 1 ends with the check that catches it.
| Feature | Minimum git |
|---|---|
user.useConfigOnly |
2.8 |
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 remote) |
2.36 |
Every command and pattern below was executed in 2026-09 on git 2.55 (macOS) and git 2.47 (Debian 13, container). Conditions and their minimum versions: conditional includes.
- Part 1. Setup
- Part 2. How it works
- Try it in a container
- Appendix A. The resulting files
- Appendix B. Idempotent bootstrap script
- Appendix C. Credits
Five steps, all through git config.
βΉοΈ Do not edit the config files by hand. A syntax error in
~/.gitconfigstops every git command (fatal: bad config line N); a wrong value matches nothing and says nothing.git configvalidates the syntax, Step 5 checks the values. The files, for reading: Appendix A.
gitdir: (per folder) |
hasconfig:remote.*.url: (per remote) |
|
|---|---|---|
| The identity follows | where the repo is cloned | who hosts the repo |
| Does not match | a clone outside the folder (/tmp) |
a repo without remote (git init) |
Both can be combined (2.4).
Authentication (which account accepts the push) and authorship (which name goes in the commit) are two independent layers (2.5). Default: 2a. 2b for servers, CI runners, hosts without a CLI, and commit signing.
Login: gh auth login, glab auth login. The CLI then acts as git credential helper (gh auth setup-git, glab auth git-credential). Providers' overview: GitHub.
gh auth login
gh auth setup-git # helper for github.com
glab auth login
git config --global "credential.https://gitlab.com.helper" '!glab auth git-credential'
glab auth statusgh auth setup-git writes an empty helper = before its own: an empty value resets the helper list for that host (gitcredentials).
Token lifetime, refresh and revocation: GitHub, GitLab. Scripts and CI: a GitHub fine-grained personal access token or a GitLab personal access token, through GH_TOKEN / GITLAB_TOKEN. Other forges: tea (Gitea), git-credential-oauth (Forgejo, Codeberg, self-hosted; Forgejo OAuth2).
ssh-keygen -t ed25519 -C "you@personal.example" -f ~/.ssh/github_personal
ssh-keygen -t ed25519 -C "you@work.example" -f ~/.ssh/gitlab_workRegister each *.pub on the matching account, then one Host per key 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: ssh offers this key only, never the other known keys. macOS: UseKeychain yes.
Two accounts on the same provider: 2.5.
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"--replace-all overwrites; --add appends one line per run.
Private commit address, one per provider:
- GitHub:
β¦@users.noreply.github.com, setting your commit email address. - GitLab:
β¦@users.noreply.gitlab.com, use an automatically-generated private commit email.
Per remote, one rule per URL shape (2.3). CLI setup of step 2 clones over https: the two https rules are enough.
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"Per folder, after the remote rules: the rule read last wins (2.4).
git config --global --replace-all "includeif.gitdir:~/git/personal/.path" "~/.gitconfig-personal"
git config --global --replace-all "includeif.gitdir:~/git/work/.path" "~/.gitconfig-work"Key shape: includeif . <condition> . path. The condition keeps its colons and slashes, hence the quotes.
gitdir:~/git/personal/ matches every repository below the folder. gitdir:~/git/personal (no trailing slash) matches nothing (2.3).
No global identity, no guessing:
git config --global --unset-all user.email # exit 5 = already absent
git config --global --unset-all user.name
git config --global user.useConfigOnly trueWithout user.useConfigOnly, git builds an identity from the OS user name and the host name (root@laptop.local) and commits with it. With it, a commit in a repo that matches no rule stops:
fatal: no email was given and auto-detection is disabled
Fix: move the repo under the right folder, add the remote, or git config --local user.email <EMAIL>.
cd ~/git/personal/some-repo # a repository, not its parent folder
git config --show-origin --get user.email
git config --show-origin --get user.name
git var GIT_AUTHOR_IDENT # identity of the next commit
ssh -T git@github.com # account that authenticates (same for git@gitlab.com)
git config --list --show-origin --show-scope # the whole configuration, one file per valueExpected for user.email:
file:/Users/you/.gitconfig-personal personal@users.noreply.github.com
Empty output: you are outside a repository (2.2). Wrong file: 2.7. Docs: git var, testing your SSH connection.
SSH key, no GPG:
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 trueRegister the same public key on the provider as a signing key: GitHub, GitLab. GPG and local verification: 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 only)
~/.ssh/github_personal + .pub, one key pair per identity (ssh only)
Files are read in order; for a plain key, the last value read wins (git-config, FILES).
| 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 |
- An included file is read at the
includeIfline: it overrides what the global file set before it. - A local value beats an included one. A
user.emailin.git/configoutranks the rules;--show-originexposes it. git config --list --show-scopelabels included values asglobal.--show-origingives the file.
Rules (git dir, resolved paths, symlinks): conditional includes. Outside a repository there is no git dir: no gitdir: rule matches, git config --get user.email prints nothing.
Measured, not in the doc: a linked worktree has its git dir under the main repository (<main>/.git/worktrees/<name>), a submodule under <superproject>/.git/modules/<name>: the rule follows the main repository. A worktree created outside ~/git/personal/ from a repo inside it matches gitdir:~/git/personal/; the reverse does not (its hasconfig: rule does, the remote is shared).
Grammar: conditional includes. Measured against a repo in ~/git/perso/deep/nested/r1:
| Pattern | Matches | Rule |
|---|---|---|
gitdir:~/git/perso/ |
β | trailing / appends **: everything below |
gitdir:~/git/perso |
β | no trailing slash: that exact path only |
gitdir:perso/ |
β | no leading ~/, ./, / or **/: **/ is prepended |
gitdir:~/git/perso/**/.git |
β | explicit glob, not required |
gitdir:~/GIT/perso/ |
β | case-sensitive |
gitdir/i:~/GIT/perso/ |
β | /i: case-insensitive (Windows, macOS) |
onbranch:<glob>: config per branch name.hasconfig:remote.*.url:<glob>: matches if any remote URL matches.**is special only next to a slash (**/,/**); elsewhere it degrades to*, and*never crosses/. One rule per URL shape:https://github.com/**(https),git@github.com:*/**(ssh).
When a repository matches both, the rule listed last wins (2.1). hasconfig: rules for the providers, then one gitdir: rule for the work tree: the folder wins where both apply.
- ssh (or the credential helper) decides which account authenticates. Wrong key: permission denied, or a push on the wrong account.
- git config decides which name is written in the commit. Wrong identity: the push succeeds with the wrong author.
Set independently, they can disagree: commit as personal, push as work.
The host name is the same, both layers look at the path.
Authorship, one rule per account prefix:
git config --global --replace-all "includeif.hasconfig:remote.*.url:https://github.com/my-org/**.path" "~/.gitconfig-work"
git config --global --replace-all "includeif.hasconfig:remote.*.url:https://github.com/me/**.path" "~/.gitconfig-personal"Authentication over https: the helper receives the path and a user name per prefix (gitcredentials); a helper storing one secret per user and path (OS keychain, Git Credential Manager) keeps the accounts apart. gh and glab serve the account they are logged in as: gh auth switch before pushing to the other one. Helpers: Git Credential Manager.
git config --global credential.useHttpPath true
git config --global "credential.https://github.com/my-org.username" "work_username"Authentication over ssh: one Host alias per account.
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 yesClone with git clone git@github-personal:me/repo.git. Alternative with real URLs: bind the key to the identity file with core.sshCommand, the key then follows the same rule as the name.
git config --file="$HOME/.gitconfig-work" --replace-all \
core.sshCommand "ssh -i ~/.ssh/github_work -o IdentitiesOnly=yes"Rewriting URLs with url.<base>.insteadOf is a third option.
SSH signing, local verification:
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"Keys: gpg.program, gpg.ssh.allowedSignersFile.
Signing key and commit email move together: the provider verifies a signature against the emails of the account that owns the key (GitHub, GitLab).
git rev-parse --git-dir (which git dir, if any), then the Step 5 check.
| Symptom | Cause | Fix |
|---|---|---|
git config --get user.email prints nothing |
2.2 | cd into a repo |
Commits carry you@your-machine.local |
no rule matched, user.useConfigOnly unset |
Step 4, then fix the rule |
| Not applied in a repo under the folder | no trailing / in gitdir: |
2.3 |
| Not applied, path looks right | case or symlink | gitdir/i:, or the resolved path, 2.2 |
| Applied, wrong value | user.email in .git/config |
git config --local --unset user.email, 2.1 |
| Three emails in an identity file | --add |
--replace-all |
| Right author, wrong account on push | ssh key or helper, not git config | ssh -T git@host, IdentitiesOnly yes, 2.5 |
hasconfig: never matches |
no remote yet (Step 1) | add the remote, or a gitdir: rule |
hasconfig: matches ssh clones but not https, or the reverse |
one glob per URL shape | 2.3 |
| Commit shown as unverified | signing key β commit email on the account | 2.6 |
- Per-project include. A
.gitconfigin the project, pulled in with a global alias:git config --global alias.set-config '!git config --local include.path "$(git rev-parse --show-toplevel)/.gitconfig"', thengit set-configonce per clone. - Sandboxes, agents, CI.
GIT_CONFIG_GLOBAL=/path/to/filereplaces~/.gitconfig(test a setup without touching yours).GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL/GIT_COMMITTER_*in the environment beat every config file (environment variables). An agent cloning into/tmpor a linked worktree is a clone outside the tree:hasconfig:rules anduser.useConfigOnlyapply. - Audit every clone; a repository matching no rule prints
Author identity unknown:for d in ~/git/*/*; do printf '%s\t' "$d"; git -C "$d" var GIT_AUTHOR_IDENT 2>&1 | head -1; done
lab.sh and lab.Dockerfile replay Part 1 on a bare Debian (git, gh, glab, no config, no key) against two empty public repositories of yours, one per provider.
docker build -f lab.Dockerfile -t git-identity-lab .
docker run --rm -it --hostname laptop.local --env-file tokens.env git-identity-lab
./lab.sh # 8 steps, pause between each; ./lab.sh 4 plays one stepEvery command is printed before it runs. Steps 4 and 7 end with the Step 5 check, ssh -T included (denied: no key in the container, the push goes through the CLI).
tokens.env: GH_TOKEN, GITLAB_TOKEN, LAB_GITHUB_REPO, LAB_GITLAB_REPO (header of lab.sh). Short-lived tokens limited to those two repositories, revoked afterwards. Steps 1 to 5 and 7 run offline. --hostname with a domain: without one, git refuses to guess an e-mail (fatal: unable to auto-detect email address) and step 1 shows no root@laptop.local. Replay: delete main on both repositories (steps 6 and 8 push -u origin main), or point the variables at fresh repositories.
What Part 1 writes, for reading.
~/.ssh/config (2b only):
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_personal
IdentitiesOnly yes
AddKeysToAgent yes~/.gitconfig (Step 4):
[user]
useConfigOnly = true
[includeIf "hasconfig:remote.*.url:https://github.com/**"]
path = ~/.gitconfig-personal
[includeIf "hasconfig:remote.*.url:git@github.com:*/**"]
path = ~/.gitconfig-personal
[includeIf "hasconfig:remote.*.url:https://gitlab.com/**"]
path = ~/.gitconfig-work
[includeIf "hasconfig:remote.*.url:git@gitlab.com:*/**"]
path = ~/.gitconfig-work
[includeIf "gitdir:~/git/personal/"]
path = ~/.gitconfig-personal
[includeIf "gitdir:~/git/work/"]
path = ~/.gitconfig-work~/.gitconfig-personal (Step 3, signing):
[user]
name = personal_username
email = personal@users.noreply.github.com
signingkey = ~/.ssh/github_personal.pub
[gpg]
format = ssh
[commit]
gpgsign = true~/.gitconfig-work: same shape, work values.
One call per rule. Re-runs overwrite, never append. Authentication (step 2) and signing stay CLI actions.
#!/usr/bin/env bash
set -euo pipefail
# usage: ./git-identity.sh <profile> <name> <email> <rule>
# rule ending with / β folder (~/git/work/)
# anything else β remote (https://github.com/**, git@github.com:*/**)
profile="$1"; name="$2"; email="$3"; rule="$4"
file="$HOME/.gitconfig-$profile"
git config --file="$file" --replace-all user.name "$name"
git config --file="$file" --replace-all user.email "$email"
case "$rule" in
*/) git config --global --replace-all "includeif.gitdir:$rule.path" "$file" ;;
*) git config --global --replace-all "includeif.hasconfig:remote.*.url:$rule.path" "$file" ;;
esac
git config --global user.useConfigOnly true
git config --global --unset-all user.name || [ $? -eq 5 ] # 5 = already absent
git config --global --unset-all user.email || [ $? -eq 5 ]./git-identity.sh personal "personal_username" "personal@users.noreply.github.com" "https://github.com/**"
./git-identity.sh personal "personal_username" "personal@users.noreply.github.com" "git@github.com:*/**"
./git-identity.sh work "work_username" "you@company.example" "https://gitlab.com/**"
./git-identity.sh work "work_username" "you@company.example" ~/git/work/Revisions:
- 2018-10 first version
- 2026-08 rewrite, every command validated
- 2026-09 authentication through the provider CLI, container lab, references.
Contributors, from the comments:
- @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