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:

Animated terminal demo of zsh-git-profile auto-switching Git user.name, email, and signing keys by directory path and remote URL in Zsh

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

ManagerLine to add
Antigenantigen bundle nemezo/zsh-git-profile
Zplugzplug "nemezo/zsh-git-profile"
Zgenomzgenom load nemezo/zsh-git-profile
Zgenzgen load nemezo/zsh-git-profile
Sheldongithub = "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 routinggp add maps a directory tree to a profile, applied automatically on cd
  • Remote URL routinggp remote routes 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 cd so you always know which hat you're wearing
  • Zero startup cost — no Git calls at shell init; the single git config --list fires 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

CommandDescription
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 PROFILEImport repo-local identity as a persistent profile
gp import --session PROFILEImport for this session only
gp remove NAMERemove a profile
gp global PROFILEApply profile to global Git config
gp local PROFILE / gp repo PROFILE / gp PROFILEApply to the current repo (equivalent forms)
gp listList all registered profiles
gp showShow active identity and matching profile
gp add [-f] [--session] [PATH] PROFILERoute a directory tree; PATH defaults to $PWD
gp add [-f] [--session] --default PROFILESet a fallback profile
gp rm [PATH]Remove a path route; PATH defaults to $PWD
gp rm --defaultClear the fallback
gp remote [-f] [--session] [--] PATTERN PROFILERoute repos by origin URL glob
gp remote rm PATTERN / gp remote remove PATTERNRemove a remote URL route (alias of gp rmremote)
gp rmremote PATTERNRemove a remote URL route
gp routesList all routes (path + remote) and the default
gp remotesList remote URL routes only
gp route [-f] [--session] PATH PROFILELong form of gp add
gp unroute PATH / gp unroute --defaultLong form of gp rm
gp helpPrint 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.

VariableDefaultEffect
ZSH_GIT_PROFILE_FILE~/.config/zsh-git-profile/profiles.zshProfile storage path
ZSH_GIT_PROFILE_AUTO_LOAD1Load saved profiles on startup; set 0 to disable
ZSH_GIT_PROFILE_AUTO_SHOW1Print 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.