zsh-git-profile - Git identity manager for Zsh
April 10, 2026 · View on GitHub
Manage multiple Git accounts in Zsh by auto-switching user.name,
user.email, and signing keys based on directory path or repository remote
URL. Never push a commit under the wrong identity again.
Demo:

Works with Zinit, Oh My Zsh, Antidote, Antigen, Zplug, Zgenom, Sheldon, and plain Zsh.
Installation
Add the line for your plugin manager to .zshrc, then open a new shell or
source ~/.zshrc.
Zinit
zinit light nemezo/zsh-git-profile
Oh My Zsh
git clone https://github.com/nemezo/zsh-git-profile.git \
"${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-git-profile"
Then add zsh-git-profile to the plugins list in .zshrc:
plugins=(... zsh-git-profile)
Antidote
Add to .zsh_plugins.txt:
nemezo/zsh-git-profile
Plain Zsh (no plugin manager)
git clone https://github.com/nemezo/zsh-git-profile.git "$HOME/.zsh/zsh-git-profile"
echo 'source "$HOME/.zsh/zsh-git-profile/zsh-git-profile.plugin.zsh"' >> ~/.zshrc
Other plugin managers
| Manager | Line to add |
|---|---|
| Antigen | antigen bundle nemezo/zsh-git-profile |
| Zplug | zplug "nemezo/zsh-git-profile" |
| Zgenom | zgenom load nemezo/zsh-git-profile |
| Zgen | zgen load nemezo/zsh-git-profile |
| Sheldon | github = "nemezo/zsh-git-profile" under [plugins.zsh-git-profile] in plugins.toml |
Quick Start
# Register your identities once
gp register work --user 'Work Name' --email you@company.com --sign none
gp register personal --user 'Your Name' --email you@example.com --sign ssh --key 'ssh-ed25519 AAAA...'
# Switch manually
gp work # apply to this repo
gp global personal # apply globally
# Auto-switch by directory — every cd applies the right profile
gp add ~/work work
gp add ~/open-source personal
# Auto-switch by remote URL — routes by origin, not by where you cloned it
gp remote '*github.com/company-org/*' work # GitHub HTTPS — by org
gp remote '*github.com:company-org/*' work # GitHub SSH — by org
gp remote '*client-a@dev.azure.com*' client # Azure DevOps — by tenant
Inspect and manage at any time:
gp show # active identity and matching profile
gp list # all registered profiles
gp routes # all path + remote routes
Import a repo's existing local identity as a named profile:
cd ~/projects/existing-repo
gp import work
Features
- Named profiles — register work, personal, and client identities once; reuse everywhere
- Path routing —
gp addmaps a directory tree to a profile, applied automatically oncd - Remote URL routing —
gp remoteroutes by origin URL; the right identity follows a repo regardless of where you cloned it - SSH & GPG signing — every profile carries its own signing key, applied automatically
- Auto-show — prints the active identity on
cdso you always know which hat you're wearing - Zero startup cost — no Git calls at shell init; the single
git config --listfires only inside a git root
Routing
The plugin auto-applies profiles when you cd. There are two complementary
strategies that together cover every workflow.
Route by directory path
Map a directory tree to a profile. Any cd into that tree — including
subdirectories — applies the profile automatically.
gp add work # route $PWD → work (PATH defaults to $PWD)
gp add ~/projects/personal personal # explicit path
gp add --default personal # fallback outside all routed trees
gp rm # unroute $PWD
gp add -f work # force-overwrite without prompting
gp routes # list all active routes
Routes use longest-prefix matching: routing ~/projects/client automatically
covers ~/projects/client/api and every subdirectory. When two routes overlap,
the more specific path wins.
Route by remote URL
When repos from multiple clients or identities share the same directory — contractor
work, OSS contributions, or personal side-projects all cloned into ~/code/ —
path routing can't tell them apart. gp remote solves this by routing on what
actually differs: the origin URL.
# GitHub — HTTPS clone URLs (most common)
gp remote '*github.com/company-org/*' work # all repos under one org
gp remote '*github.com/personal-user/*' personal # all repos under your personal account
gp remote '*github.com/*/oss-*' oss # any org, repo name starts with oss-
# GitHub — SSH clone URLs (git@github.com:org/repo.git)
gp remote '*github.com:company-org/*' work
gp remote '*github.com:personal-user/*' personal
# GitLab
gp remote '*gitlab.com/company-group/*' work
gp remote '*gitlab.com/personal-user/*' personal
# Azure DevOps — match on the tenant user (@org) in the URL
# HTTPS clone URL looks like: https://org@dev.azure.com/org/Project/_git/Repo
gp remote '*client-a@dev.azure.com*' client-a # everything under client-a tenant
gp remote '*client-b@dev.azure.com*' client-b
# Bitbucket
gp remote '*bitbucket.org/company-team/*' work
# Self-hosted / on-premise Git servers
gp remote '*git.internal.company.com/*' work
# Maintenance
gp remote -f '*github.com/company-org/*' newprofile # overwrite an existing entry
gp rmremote '*github.com/company-org/*' # remove a remote route
gp remote -- rm work # route the literal pattern 'rm'
gp routes # list all path + remote routes
Pattern matching uses Zsh glob syntax. The longest matching pattern wins, so
broad catch-alls and specific org-level patterns coexist safely. gp remote
also accepts --session to keep a route in memory only. Use
gp remote -- PATTERN PROFILE to force route mode when PATTERN is literally
rm, remove, or starts with -.
On cd into a git root the plugin reads remote.origin.url from the local git
config (already loaded as part of the auto-show pass — no extra subprocess),
finds the longest-matching pattern, and applies the mapped profile. A single
confirmation line is printed:
git profile [remote] → work
gp routes shows the full picture:
git profile routes [3]
[path] /Users/you/projects/personal → personal
[remote] *github.com/company-org/* → work
[remote] *client-a@dev.azure.com* → client
default: personal
Precedence: remote route > path route > default. Local repo config
(git config --local user.name) always wins over all routing.
Both route types persist across shell sessions and are replayed on every new shell.
Commands
| Command | Description |
|---|---|
gp register NAME --user U --email E --sign none|ssh|gpg [--key K] | Register a persistent profile |
gp register --session NAME ... | Register for this session only (not saved) |
gp import PROFILE | Import repo-local identity as a persistent profile |
gp import --session PROFILE | Import for this session only |
gp remove NAME | Remove a profile |
gp global PROFILE | Apply profile to global Git config |
gp local PROFILE / gp repo PROFILE / gp PROFILE | Apply to the current repo (equivalent forms) |
gp list | List all registered profiles |
gp show | Show active identity and matching profile |
gp add [-f] [--session] [PATH] PROFILE | Route a directory tree; PATH defaults to $PWD |
gp add [-f] [--session] --default PROFILE | Set a fallback profile |
gp rm [PATH] | Remove a path route; PATH defaults to $PWD |
gp rm --default | Clear the fallback |
gp remote [-f] [--session] [--] PATTERN PROFILE | Route repos by origin URL glob |
gp remote rm PATTERN / gp remote remove PATTERN | Remove a remote URL route (alias of gp rmremote) |
gp rmremote PATTERN | Remove a remote URL route |
gp routes | List all routes (path + remote) and the default |
gp remotes | List remote URL routes only |
gp route [-f] [--session] PATH PROFILE | Long form of gp add |
gp unroute PATH / gp unroute --default | Long form of gp rm |
gp help | Print usage |
gp is short for git_profile. The long form works everywhere:
git_profile repo work.
Command output uses labelled blocks:
git profiles [2]
personal
user: Personal User
email: personal@example.com
signing: ssh
key: ssh-ed25519 AAAA...
saved: yes
work
user: Work User
email: you@company.com
signing: none
saved: yes
Configuration
Profiles are saved to a private Zsh file and loaded automatically on every subsequent shell:
${ZSH_GIT_PROFILE_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/zsh-git-profile/profiles.zsh}
Plain Zsh syntax — no JSON, no database. Fast to source, easy to audit, zero dependencies.
| Variable | Default | Effect |
|---|---|---|
ZSH_GIT_PROFILE_FILE | ~/.config/zsh-git-profile/profiles.zsh | Profile storage path |
ZSH_GIT_PROFILE_AUTO_LOAD | 1 | Load saved profiles on startup; set 0 to disable |
ZSH_GIT_PROFILE_AUTO_SHOW | 1 | Print identity on cd; set 0 to disable |
Set variables before loading the plugin. Session-only profiles
(gp register --session) exist in memory only and are never written to disk.
Auto Show
On cd into a git root, the plugin prints the active identity:
git profile [local]
profile: work
user: Work Name
email: you@company.com
signing: none
An unregistered identity surfaces a hint:
git profile [local]
profile: <unregistered>
user: Max Mustermann
email: max@example.com
signing: none
hint: run gp import PROFILE to save this repo identity
The hook skips directories without .git, suppresses repeated output for the
same directory, and costs nothing outside a git root. Disable with
ZSH_GIT_PROFILE_AUTO_SHOW=0.
Performance
The chpwd hook makes exactly one git call and pays zero cost outside a
git root.
just profile # run the benchmark yourself
Sample results on Apple M-series (zsh 5.9, git 2.53.0):
▶ Plugin source time (cold, subprocess)
source zsh-git-profile.plugin.zsh (n=5) 7.3 ms
▶ chpwd hook
__zgp_auto_show (n=20) 4.9 ms
▶ gp command latency
gp show (inside repo) (n=10) 5.1 ms
gp list (n=20) 0.1 ms
gp routes (n=20) 4.8 ms
gp register --session (no persist) (n=20) 0.1 ms
gp import --session (no persist) (n=10) 4.7 ms
▶ git subprocess count per operation
__zgp_auto_show (hook) 1
gp show (inside repo) 1
gp list 0
gp register --session 0
gp import --session (inside repo) 1
Every operation is 0 or 1 git subprocess — there are no multi-fork paths. The
floor for any operation that touches git config is one git fork (~4–7 ms on
Apple Silicon; higher on x86 or network-mounted home directories). The plugin
adds under 1 ms of Zsh overhead on top.
Development
just syntax # check zsh syntax
just test # run the test suite
just ci # syntax + shellcheck + test
Install pre-commit hooks once per clone:
just hooks-install
Tests live in tests/git_profile.zunit. A standalone
smoke script at tests/smoke.zsh covers environments without
the zunit runner. Completion reads the in-memory registry and updates immediately
after gp register — no shell restart needed.