git config in Depth: includeIf for Work/Personal Identities, Two GitHub Accounts, url.insteadOf and Recommended Team Settings
With includeIf "gitdir:~/work/" the work identity applies only to repos in that folder; forget the trailing / and the condition silently never matches. We try identity by remote URL with hasconfig, two GitHub accounts with an SSH host alias and url.insteadOf, useHttpPath in the credential helper and recommended team settings with Git 2.56.
Version note: The commands in this post were tested with Git 2.56.1. hasconfig:remote.*.url has existed since Git 2.36, push.autoSetupRemote since Git 2.37 and includeIf "worktree:..." since Git 2.56; git config get/set needs Git 2.46 or later.
Guess First
Ada works on the team that builds Bookstore, the online bookstore used as the shared example across this site's series, and in the evenings she also contributes to open source projects from her company laptop. She wants her work email on company commits and her own email on personal projects. Her global config looks like this:
[user]
name = Ada Lovelace
email = ada@example.org
[init]
defaultBranch = main
[includeIf "gitdir:~/work"]
path = ~/.gitconfig-work
~/.gitconfig-work contains email = ada@example.com. Ada creates a new repo under ~/work. Which email do you think she gets?
$ git init -q work/bookstore-api
$ cd work/bookstore-api
$ git config get user.email
ada@example.org
The personal one. The condition never matched, and Git did not say so. In this post we split the config by folder, by remote URL and by account, and then go through the recommended team settings with the reasons behind them.
includeIf gitdir: Identity by Folder
The gitdir: condition is a glob pattern matched against the path of the .git folder. This repo's .git path is ~/work/bookstore-api/.git; ~/work does not match it. The rule in the documentation is this: if the pattern ends with /, Git appends **, which turns it into "this folder and everything below it". Let's delete the old section from the file and write the correct one:
$ git config set --global includeIf.gitdir:~/work/.path '~/.gitconfig-work'
$ cat ~/.gitconfig
[user]
name = Ada Lovelace
email = ada@example.org
[init]
defaultBranch = main
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
$ git config get user.email
ada@example.com
$ git config list --show-origin --show-scope | grep -E 'user|gpg|commit'
global file:/home/user/.gitconfig user.name=Ada Lovelace
global file:/home/user/.gitconfig user.email=ada@example.org
global file:/home/user/.gitconfig-work user.email=ada@example.com
global file:/home/user/.gitconfig-work user.signingkey=~/.ssh/id_ed25519_work.pub
global file:/home/user/.gitconfig-work gpg.format=ssh
global file:/home/user/.gitconfig-work commit.gpgsign=true
--show-origin shows which value comes from which file. There are two user.email lines and the one read last wins; since includeIf is at the end of the file, the work email applies. This order matters: if you put the includeIf section before [user], the personal email overrides it. The work file does more than set the email; it also applies the SSH commit signing settings we saw in the hooks and commit signing post, only to work repos. Nothing changes in personal repos:
$ cd ~/oss && git init -q bookstore-docs-fork && cd bookstore-docs-fork
$ git config get user.email
ada@example.org
On Windows, Git for Windows expands ~ to the user folder, so gitdir:~/work/ works there too. If you write the full path, use the gitdir/i:C:/Users/ada/work/ form to avoid tripping over the case of the drive letter; /i makes the match case-insensitive. There is also the worktree: condition that arrived with Git 2.56: it looks at the path of the working folder instead of .git, which gives more natural results with linked worktrees, the extra working folders we opened in the worktree post.
hasconfig: When the Repo Is in the Wrong Folder
gitdir: trusts the folder. If Ada one day clones a company repo into ~/Downloads in a hurry, the condition again does not match. As a second safeguard, let's add a condition that looks at the remote URL:
[includeIf "hasconfig:remote.*.url:https://github.com/bookstore-example/**"]
path = ~/.gitconfig-work
[includeIf "hasconfig:remote.*.url:git@github-work:bookstore-example/**"]
path = ~/.gitconfig-work
Before and after:
$ cd ~/Downloads && git init -q bookstore-api && cd bookstore-api
$ git remote add origin https://github.com/bookstore-example/bookstore-api.git
$ git config get user.email
ada@example.org
$ git config get user.email
ada@example.com
$ git config list --show-origin | grep user.email
file:/home/user/.gitconfig user.email=ada@example.org
file:/home/user/.gitconfig-work user.email=ada@example.com
hasconfig:remote.*.url: includes the file if the URL of any remote of the repo matches the pattern. Since the remote URL usually lives in the repo's own config, Git scans the config files ahead of time to resolve this condition. To avoid a chicken-and-egg problem, files included this way are not allowed to define remote URLs. Using both conditions together is the most robust setup: even if the folder is wrong, every repo cloned from the company organization gets the work identity.
Two GitHub Accounts: an SSH Host Alias
Separating the identity is half the job. Ada has two GitHub accounts, and GitHub only lets you attach an SSH key to one account. On the SSH side, the fix is to connect to the same server under two different aliases. ~/.ssh/config:
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_personal
IdentitiesOnly yes
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
ssh -G prints the settings that would apply to a host without connecting; it is a good check that the right key is picked:
$ ssh -T -G -F ~/.ssh/config github-work | grep -E '^(hostname|user|identityfile|identitiesonly) '
user git
hostname github.com
identitiesonly yes
identityfile ~/.ssh/id_ed25519_work
IdentitiesOnly yes matters: without it, all the keys in ssh-agent are tried in turn and GitHub logs in with the account of the first key it accepts, which is often the wrong account. Company repos now work from git@github-work:bookstore-example/bookstore-api.git. But your teammates, the docs and .gitmodules files all say https://github.com/bookstore-example/.... Instead of changing every URL by hand, tell Git to rewrite them:
$ git config set --global url.git@github-work:bookstore-example/.insteadOf https://github.com/bookstore-example/
$ git remote get-url origin
git@github-work:bookstore-example/bookstore-api.git
$ git remote -v
origin git@github-work:bookstore-example/bookstore-api.git (fetch)
origin git@github-work:bookstore-example/bookstore-api.git (push)
url.<base>.insteadOf <prefix> replaces the <prefix> at the start of any URL with <base> when the URL is used. The URL in the repo's config does not change; the rewrite happens again on every command. Because we limited the prefix to the organization name, Ada's personal repos keep using the github.com host, that is, the personal key.
Other Uses of url.insteadOf
The same mechanism is not only for SSH. Say the Bookstore team uses a Git mirror on the internal network; in the example below the local Git HTTP server on 127.0.0.1 stands in for that mirror:
$ git config set --global url.http://127.0.0.1:52030/git/.insteadOf https://git.bookstore.example/
$ git clone https://git.bookstore.example/bookstore-api.git api-mirror
Cloning into 'api-mirror'...
$ git -C api-mirror config get remote.origin.url
https://git.bookstore.example/bookstore-api.git
$ git -C api-mirror remote get-url origin
http://127.0.0.1:52030/git/bookstore-api.git
The URL written in the config and the URL Git actually connects to are different: config get shows the raw value, remote get-url the rewritten one. If a clone "goes to the wrong place", the first thing to check is git config list --show-origin | grep insteadof. pushInsteadOf rewrites only pushes: you can fetch over HTTPS and push over SSH. This is also the counterpart of the advice in the submodule post: write an HTTPS URL into .gitmodules, and let whoever wants SSH rewrite it with insteadOf on their own machine.
Two Accounts over HTTPS: the Credential Helper and useHttpPath
If you use HTTPS, the split happens in the credential helper. When you connect to GitHub, a personal access token is sent instead of a password, and we saw in the post on connecting to GitHub that it is the credential helper, not Git, that supplies it. To see what is sent to the helper, in this lab we used a test helper that prints the request it receives and returns obviously fake values:
$ git config set --local credential.helper /home/user/src/fake-helper.sh
$ printf 'url=https://github.com/bookstore-example/bookstore-api.git\n\n' | git credential fill
--- helper received:
protocol=https
host=github.com
--- end
protocol=https
host=github.com
username=ada-personal
password=fake-token-for-ada-personal
By default the helper only sees the protocol and the host; the repo path is not sent. In the helper's eyes every repo on github.com is the same, and a single token comes back. There are two fixes. The first is to send the path as well:
$ git config set --global credential.https://github.com.useHttpPath true
$ printf 'url=https://github.com/bookstore-example/bookstore-api.git\n\n' | git credential fill
--- helper received:
protocol=https
host=github.com
path=bookstore-example/bookstore-api.git
--- end
protocol=https
host=github.com
path=bookstore-example/bookstore-api.git
username=ada-personal
password=fake-token-for-ada-personal
Now the helper can keep a separate entry per repo; the price is that it asks for credentials once more for every new repo. The second is to put the user name in the URL:
$ printf 'url=https://ada-work@github.com/bookstore-example/bookstore-api.git\n\n' | git credential fill
--- helper received:
protocol=https
host=github.com
path=bookstore-example/bookstore-api.git
username=ada-work
--- end
protocol=https
host=github.com
path=bookstore-example/bookstore-api.git
username=ada-work
password=fake-token-for-ada-work
On Windows, Git Credential Manager, which ships with Git for Windows, supports several GitHub accounts, and its documentation recommends exactly these two routes: git config --global credential.https://github.com.username <account> for the default account, and https://ada-work@github.com/... in the URL for specific repos. You can see the stored accounts with git credential-manager github list. You can also combine this with insteadOf: url.https://ada-work@github.com/bookstore-example/.insteadOf https://github.com/bookstore-example/.
Recommended Settings for a Team
The Bookstore team uses a small file that everyone includes from their global config:
[init]
defaultBranch = main
[push]
autoSetupRemote = true
[pull]
rebase = true
[rebase]
autoStash = true
updateRefs = true
[fetch]
prune = true
pruneTags = true
[rerere]
enabled = true
[merge]
conflictStyle = zdiff3
[diff]
algorithm = histogram
colorMoved = default
[help]
autocorrect = prompt
The reasons, one by one: push.autoSetupRemote sets the upstream by itself on the first git push of a new branch, so the --set-upstream error is gone. pull.rebase true settles the "divergent branches" question from the pull post in the rebase direction and avoids needless merge commits. rebase.autoStash stashes unfinished work before a rebase and puts it back afterwards; rebase.updateRefs moves stacked branches together. fetch.prune and fetch.pruneTags remove branches and tags locally once they are deleted on the remote. rerere.enabled remembers a conflict you resolved once and resolves it the same way when it comes back; it saves time for teams that rebase long-lived branches often. merge.conflictStyle zdiff3 adds the common ancestor to the conflict markers. diff.algorithm histogram produces more readable diffs for moved and repeated lines; diff.colorMoved shows moved lines in a separate colour. Each of these is a choice; pull.rebase is not a setting to impose on a team without discussing it.
$ git config set --global include.path /home/user/src/team.gitconfig
$ git config list --show-scope --show-origin | grep team.gitconfig | head -4
global file:/home/user/.gitconfig include.path=/home/user/src/team.gitconfig
global file:/home/user/src/team.gitconfig init.defaultbranch=main
global file:/home/user/src/team.gitconfig push.autosetupremote=true
global file:/home/user/src/team.gitconfig pull.rebase=true
$ git -c help.autocorrect=never stauts
git: 'stauts' is not a git command. See 'git --help'.
$ git -c help.autocorrect=immediate stauts -sb
WARNING: You called a Git command named 'stauts', which does not exist.
Continuing under the assumption that you meant 'status'.
## No commits yet on main
immediate can be dangerous for help.autocorrect: a mistyped command runs right away as whatever other command Git guesses. prompt asks first, which is why we put it in the file. Before Git 2.49 the value 1 meant "wait 0.1 seconds"; now it means "run immediately", so the behaviour of an old dotfile may have changed.
As for Git 3.0: according to Git's BreakingChanges document, the only setting in this file whose default changes in 3.0 is init.defaultBranch, which becomes main. None of the others become defaults in 3.0, so this file will still be needed after 3.0. You can add that to the checklist in the post on preparing for Git 3.0.
Common Pitfalls
The first is forgetting the trailing / on a gitdir: path. The example in the opening is exactly that, and Git gives no warning. After setting it up, check both kinds of folders with git config list --show-origin | grep user.email.
The second is noticing commits made with the wrong email too late. Ada made three commits in a work repo before setting up includeIf:
$ git log --format='%h %ae %s'
4fdbdc0 ada@example.org Document how to run
b55211d ada@example.org Add notes
6215b60 ada@example.org Add README
$ git config get user.email
ada@example.com
$ git rebase -q --root --exec 'git commit -q --amend --reset-author --no-edit'
$ git log --format='%h %ae %s'
cf8d2c0 ada@example.com Document how to run
255e08e ada@example.com Add notes
5d91916 ada@example.com Add README
--exec runs the given command after each commit; --reset-author rewrites the author from the current config. Only Ada's commits are in this repo, so we fixed all of them with --root. On a shared branch, give a base such as origin/main instead of --root so that you do not touch other people's commits. --reset-author also updates the author date. All of this rewrites history: if you have not pushed yet, no problem; if you have, the --force-with-lease rules from the rebase post apply. If you do not want to change history, you can map the wrong addresses to the right one in a .mailmap file; log and shortlog read it.
The third is setting up an SSH alias without IdentitiesOnly. If two keys are loaded in ssh-agent and the alias lacks that line, ssh tries the agent's key first and you connect with the wrong account. The symptom is usually "Repository not found": the repo exists, but the wrong account has no access to it.
The fourth is forgetting about insteadOf and looking for the problem elsewhere. An old insteadOf line in the global config can send a clone to a completely different server years later. When you move your dotfiles to a new machine, review these lines in particular.
A Small Experiment
★☆☆ Look at the output of git config list --show-origin --show-scope on your own machine. Find out which file each setting comes from and whether the same key is defined in more than one place.
★★☆ Set up an includeIf for ~/work/, first without the trailing /, then with it. In both cases compare the output of git config get user.email in the repos ~/work/x and ~/work/a/b/y.
★★★ Write a test helper that prints the incoming lines to stderr and returns a fake user name and token, and see what you get from git credential fill with useHttpPath off and on. Then add an insteadOf rule and look at how the host the helper sees changes.
In this post we tuned how Git behaves. In the next post we search inside the history: with git grep, git log -S, -G and -L we find out when, by whom and why the discount calculation in Bookstore changed.