Automatically use the right git identity and SSH key for each project. This setup uses git's includeIf "hasconfig:remote.*.url" feature to switch identities based on remote URL, so the correct key is selected regardless of where a repo is cloned.
Why this approach?
- Solves SSH agent key exhaustion (servers reject connections after trying too many keys)
- Scales well with multiple keys and identities
- Handles multiple accounts on the same host (e.g. github.com, gitlab.com, gitea.example.com) without host aliases
- Works with
git clone-- git writes the remote URL to.git/configbefore the fetch, socore.sshCommandis resolved correctly for the initial fetch - Identity follows the remote URL, not the clone location -- repos cloned anywhere get the right identity automatically
- No wrapper scripts, environment variables, or external dependencies
- Works with submodules
- Requires git 2.36+
Organize by git server, then by organization/group within that server:
mkdir -p ~/git/{github.com,gitlab.com,bitbucket.org,gitea.example.com}
mkdir -p ~/.config/git/identities~/git/
├── github.com/
│ ├── personal-account/
│ │ ├── dotfiles/
│ │ └── side-projects/
│ ├── work-org/
│ │ ├── backend-service/
│ │ └── infrastructure/
│ └── client-org/
│ └── client-project/
├── gitlab.com/
│ └── work-group/
│ ├── web-platform/
│ └── deployments/
├── bitbucket.org/
│ └── work-workspace/
│ ├── backend-service/
│ └── infrastructure/
└── gitea.example.com/
└── team/
└── internal-tools/
This mirrors how git hosting services organize repositories (host > org/group > repo), making clone URLs predictable and navigation intuitive. Adding a new server or organization is just mkdir. The directory structure is for navigation -- identity is resolved from the remote URL.
~/.config/git/config
# Default identity (used when no includeIf matches)
[user]
name = Your Name
email = your-default@email.com
# Identity per org — matched on remote URL, not clone path
[includeIf "hasconfig:remote.*.url:git@github.com:work-org/**"]
path = ~/.config/git/identities/work.gitconfig
[includeIf "hasconfig:remote.*.url:git@gitlab.com:work-group/**"]
path = ~/.config/git/identities/work.gitconfig
[includeIf "hasconfig:remote.*.url:git@bitbucket.org:work-workspace/**"]
path = ~/.config/git/identities/work.gitconfig
[includeIf "hasconfig:remote.*.url:git@gitea.example.com:*/**"]
path = ~/.config/git/identities/work.gitconfig
# Personal account on github.com overrides the default identity
[includeIf "hasconfig:remote.*.url:git@github.com:personal-account/**"]
path = ~/.config/git/identities/personal.gitconfig
# Client with separate identity
[includeIf "hasconfig:remote.*.url:git@github.com:client-org/**"]
path = ~/.config/git/identities/client.gitconfigThe pattern after hasconfig:remote.*.url: is matched against the remote URL using fnmatch glob syntax. Use org/** to match all repos in an org, or */** to match all repos on a server.
~/.config/git/identities/work.gitconfig
[user]
name = Your Professional Name
email = you@company.com
[core]
sshCommand = ssh -i ~/.ssh/id_work~/.config/git/identities/personal.gitconfig
[user]
name = Your Username
email = you@personal.com
[core]
sshCommand = ssh -i ~/.ssh/id_personal~/.config/git/identities/client.gitconfig
[user]
name = Your Professional Name
email = you@client.com
[core]
sshCommand = ssh -i ~/.ssh/id_clientPersonal account email: GitHub and GitLab both offer a noreply address that keeps your real email private while still linking commits to your account. Find yours in account settings under the email section:
- GitHub:
123456+username@users.noreply.github.com - GitLab:
123456+username@users.noreply.gitlab.com(or justusername@users.noreply.gitlab.comfor older accounts)
Using the noreply address is recommended for personal accounts if your commits are public. Work accounts typically use the company email directly.
ssh-keygen -t ed25519 -f ~/.ssh/id_work
ssh-keygen -t ed25519 -f ~/.ssh/id_personal
ssh-keygen -t ed25519 -f ~/.ssh/id_clientImportant: Public git hosting services (GitHub, GitLab, Bitbucket) enforce SSH key uniqueness per account -- each public key can only be registered to a single account on that service. You cannot reuse the same key pair across multiple accounts on the same host. Generate a separate key pair for each account.
Azure DevOps exception: As of 2026, Azure DevOps only accepts RSA keys. Use ssh-keygen -t rsa -b 3072 for any key intended for Azure DevOps. See the Azure DevOps SSH section for details.
~/.ssh/config
# Optional: company or client specific includes
Include ~/.ssh/config.d/company-vpn
Include ~/.ssh/config.d/client-servers
# git hosting services - prevent SSH agent key exhaustion
Host github.com gitlab.com bitbucket.org
IdentityAgent none
IdentitiesOnly yes
# Self-hosted git servers - same agent exhaustion prevention applies
# Gitea/Gogs may use 'gogs' user instead of 'git'
Host gitea.example.com
User gogs
IdentityAgent none
IdentitiesOnly yes
# Remote server administration (separate from git auth)
Host *.company.internal
User admin
IdentityFile ~/.ssh/id_work_admin
# Default settings
Host *
ServerAliveInterval 60
ServerAliveCountMax 3During git clone, the remote URL determines which SSH key is used:
- git creates the target directory and runs
git init - git writes the remote URL to
.git/config - git evaluates
includeIf "hasconfig:remote.*.url:..."rules against the remote URL - The matching identity config is loaded, providing
core.sshCommandfor the fetch
This means the key selection is tied to the remote URL -- the same identity applies regardless of where on disk the repo lives.
Note: hasconfig:remote.*.url requires git 2.36+ (released April 2022).
ghq organizes cloned repositories at {root}/{host}/{org}/{repo} -- exactly the structure this guide uses. Set GHQ_ROOT=~/git (environment variable) or ghq.root = ~/git (git config) and ghq integrates with no additional setup:
# In git config
git config --global ghq.root ~/git
# Or as an environment variable (e.g. in ~/.bashrc or config.fish)
export GHQ_ROOT=~/git # bash
set -gx GHQ_ROOT ~/git # fishRepos already cloned under ~/git/ are immediately visible to ghq:
ghq list # lists all repos ghq finds under ~/git
ghq list --full-path # with absolute pathsCloning via ghq places repos in the right directory automatically, so hasconfig identity rules apply from the first fetch:
ghq get git@github.com:work-org/repo.git # → ~/git/github.com/work-org/repo/
ghq get git@gitlab.com:work-group/project.git # → ~/git/gitlab.com/work-group/project/ghq look repo (or a wrapper that does cd $(ghq list --full-path repo | head -1)) jumps directly to any repo by name.
git whoami is a custom command from the git-whoami tool. Install it first before using it -- see the Related Tools section below.
# Check identity that would apply in this repo
git whoami
# Verify which SSH key git actually uses during clone
GIT_TRACE=1 git clone git@github.com:work-org/repo.git
# Check identity inside an existing repo
cd ~/git/github.com/work-org/some-repo && git whoamiGIT_TRACE=1 logs the exact SSH command git uses. Look for the run_command: line -- it shows which -i key file is passed to ssh. This is preferable to GIT_SSH_COMMAND for verification, because that variable overrides core.sshCommand and bypasses the configuration you are trying to test.
When a new client or account needs a separate identity:
# 1. Create directory for the org (for navigation)
mkdir -p ~/git/github.com/new-client-org
# 2. Generate SSH key
ssh-keygen -t ed25519 -f ~/.ssh/id_new_client
# 3. Create config file
cat > ~/.config/git/identities/new-client.gitconfig << EOF
[user]
name = Your Professional Name
email = you@new-client.com
[core]
sshCommand = ssh -i ~/.ssh/id_new_client
EOF
# 4. Add to ~/.config/git/config
echo '[includeIf "hasconfig:remote.*.url:git@github.com:new-client-org/**"]' >> ~/.config/git/config
echo ' path = ~/.config/git/identities/new-client.gitconfig' >> ~/.config/git/config
# 5. Add public key to the git hosting account
cat ~/.ssh/id_new_client.pubBitbucket organizes repositories under workspaces. The SSH URL format is:
git@bitbucket.org:{workspace}/{repo}.git
Bitbucket also has an optional "projects" layer for grouping repos within a workspace, but projects are not part of the clone URL. A single hasconfig rule per workspace covers all repos in it:
# ~/.config/git/config
[includeIf "hasconfig:remote.*.url:git@bitbucket.org:work-workspace/**"]
path = ~/.config/git/identities/work.gitconfigAzure DevOps has two quirks compared to other git hosting services.
As of 2026, Azure DevOps still only accepts RSA SSH keys. Ed25519 keys (the current best-practice default) are silently rejected during key upload or authentication. Generate a dedicated RSA key for Azure DevOps:
ssh-keygen -t rsa -b 3072 -f ~/.ssh/id_rsa_azuredevopsThe corresponding gitconfig uses it via core.sshCommand:
[user]
name = Your Name
email = you@company.com
[core]
sshCommand = ssh -i ~/.ssh/id_rsa_azuredevopsAzure DevOps organizations can be accessed through two different URL formats:
dev.azure.com (current format):
git@ssh.dev.azure.com:v3/contoso/Platform/k8s-infra
- SSH hostname:
ssh.dev.azure.com - SSH user:
git - Web URL:
https://dev.azure.com/contoso/
{org}.visualstudio.com (legacy format):
contoso@vs-ssh.visualstudio.com:v3/contoso/Platform/k8s-infra
- SSH hostname:
vs-ssh.visualstudio.com - SSH user: the organization name (
contoso@) - Web URL:
https://contoso.visualstudio.com/
Both use the same path structure: v3/{org}/{project}/{repo}. The "project" level (Platform) maps to the second directory level under your org root.
An organization can be reachable on both URLs simultaneously (controlled via Organization Settings → Overview). Whichever URL you clone from becomes the remote URL stored in the repo, which determines which hasconfig rule matches.
# ~/.ssh/config
# Azure DevOps (dev.azure.com)
Host ssh.dev.azure.com
IdentityFile ~/.ssh/id_rsa_azuredevops
IdentitiesOnly yes
IdentityAgent none
# Azure DevOps (visualstudio.com legacy)
Host vs-ssh.visualstudio.com
IdentityFile ~/.ssh/id_rsa_azuredevops
IdentitiesOnly yes
IdentityAgent none# ~/.config/git/config
# dev.azure.com organization (matches git@ssh.dev.azure.com:v3/contoso/...)
[includeIf "hasconfig:remote.*.url:git@ssh.dev.azure.com:v3/contoso/**"]
path = ~/.config/git/identities/contoso.gitconfig
# visualstudio.com organization (legacy URL, matches contoso@vs-ssh.visualstudio.com:...)
[includeIf "hasconfig:remote.*.url:contoso@vs-ssh.visualstudio.com:*/**"]
path = ~/.config/git/identities/contoso.gitconfigIf you work with multiple Azure DevOps organizations, each gets its own hasconfig rule and gitconfig. Because hasconfig matches on the remote URL, you don't need per-org SSH host entries -- the core.sshCommand in each gitconfig handles key selection.
git evaluates configuration in this order:
- Environment variables (highest precedence) --
GIT_SSH_COMMAND,GIT_AUTHOR_EMAIL - Repository config --
.git/config - Global config --
~/.config/git/config - System config (lowest precedence) --
/etc/gitconfig
git whoami requires the git-whoami tool to be installed first.
# Check active git identity and config resolution
git whoami
# Show where individual config values come from
git config --show-origin user.email
git config --show-origin core.sshCommand
# List all git config in effect
git config --list# Test SSH connectivity to git server
ssh -T git@github.com
# Verbose SSH debugging
ssh -vvv git@github.com
# Test a specific key explicitly
ssh -i ~/.ssh/id_work -T git@github.com
# Print all SSH config that would apply for a given host
ssh -G github.com
ssh -G gitea.example.com# Show SSH command used by git
GIT_TRACE=1 git fetch
GIT_TRACE=1 git clone git@github.com:work-org/repo.git
# Show git config loading during operations
GIT_TRACE_SETUP=1 git clone git@github.com:work-org/repo.git
# Force specific SSH key for a single operation
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_work -vvv" git clone git@github.com:work-org/repo.gitSSH Agent Key Exhaustion: After an SSH agent offers several keys, some servers reject further attempts -- commonly configured at a limit of 5 keys. Ubuntu ships with an ssh-agent bundled into GNOME Keyring that is difficult to disable without breaking other applications. WSL on Windows has no agent by default but can use the native Windows ssh-agent service. Solution: set IdentityAgent none and IdentitiesOnly yes in ~/.ssh/config for git hosting services.
Wrong Identity: hasconfig rules match on the remote URL stored in .git/config. Use git whoami (git-whoami, requires installation) or git config --show-origin user.email to verify which identity is active. Check that the remote URL matches your hasconfig pattern exactly with git remote -v.
No remote URL: hasconfig rules only fire inside repos that have a remote configured. For local-only repos with no remote, fall back to the default identity or use gitdir-based rules (see Alternative Approaches).
SSH directory and file permissions: SSH refuses to use keys with overly permissive permissions and warns with UNPROTECTED PRIVATE KEY FILE. Ensure:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_* # private keys
chmod 644 ~/.ssh/id_*.pub # public keys
chmod 644 ~/.ssh/authorized_keys
chmod 644 ~/.ssh/configHTTPS URL instead of SSH: If git clone prompts for a password, you likely copied the HTTPS URL from the web interface instead of the SSH URL. Use git@host:org/repo.git, not https://host/org/repo.git.
Wrong SSH user or port: Most git hosting services use git as the SSH user (e.g., git@github.com). Gitea/Gogs instances may use gogs or a custom user. Some self-hosted instances run SSH on a non-standard port -- use ssh://git@host:2222/org/repo.git or configure the port in ~/.ssh/config.
GitHub Desktop overwrites gitconfig: GitHub Desktop may silently add a [user] section to your global ~/.config/git/config, overriding your includeIf-based identities. Check ~/.config/git/config after installing or updating GitHub Desktop.
The host/org/repo directory structure from this guide extends naturally to git worktrees, where multiple branches of the same repository live as sibling subdirectories under a shared bare clone. See git_worktrees.md for the layout, the tools involved (ghq-wt and git-wt), and how identity rules continue to work without changes.
git-whoami -- shows your effective git identity and SSH key for the current directory. Works both inside and outside git repositories.
gitdir-based identity (git built-in, all versions): Uses includeIf "gitdir:~/git/..." to select identity based on clone path rather than remote URL. Requires a consistent directory structure and a separate rule for any repo cloned outside the standard tree. Useful for local-only repos with no remote.
[includeIf "gitdir:~/git/github.com/work-org/"]
path = ~/.config/git/identities/work.gitconfig
[includeIf "gitdir:~/git/github.com/personal-account/"]
path = ~/.config/git/identities/personal.gitconfigSSH Match with exec: Uses Match host github.com exec "pwd | grep /path/" but doesn't work reliably across environments and has PATH dependencies.
Multiple host aliases: Creates SSH aliases like github-work, github-personal to distinguish multiple accounts on the same host. This clutters SSH config and can break with submodules, but avoids any directory structure requirements.
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_work
Host github-personal
HostName github.com
User git
IdentityFile ~/.ssh/id_personalEnvironment variables with direnv: Sets git credentials and SSH command per directory using .envrc files. Requires direnv installation.
# ~/git/github.com/work-org/.envrc
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_work -o IdentitiesOnly=yes"
export GIT_AUTHOR_NAME="Your Professional Name"
export GIT_AUTHOR_EMAIL="you@company.com"
export GIT_COMMITTER_NAME="Your Professional Name"
export GIT_COMMITTER_EMAIL="you@company.com"