This is the full reference. For a quick introduction, see the README.
- How it works
- Which profile wins
- Copying profiles into repositories
- AI agents and scripts
- Configuration reference
- Commands
- Migrating an existing setup
- Keeping it honest
- FAQ
- Development and releases
gitident never runs in the background and doesn't wrap git. gitident sync
turns profiles.yaml into ordinary git config, and then git does the matching
on its own every time it runs.
flowchart TB
subgraph you["You edit"]
Y["profiles.yaml<br/>profiles · rules · repos"]
end
subgraph sync["gitident sync writes"]
F1["~/.gitconfig.d/gitident/work.gitconfig<br/>name · email · signing key · SSH key"]
F2["~/.gitconfig.d/gitident/personal.gitconfig"]
B["Managed block in ~/.gitconfig<br/>includeIf gitdir:~/work/ → work<br/>includeIf hasconfig:remote.*.url:… → work<br/>includeIf gitdir:~/code/ → personal"]
end
subgraph git["Every git command"]
R["git commit in ~/work/api"]
M{"Does an includeIf<br/>condition match?"}
I["Includes work.gitconfig<br/>→ commits as work"]
N["No match: strict mode<br/>refuses to commit"]
end
Y -- "gitident sync" --> F1 & F2 & B
R --> M
M -- yes --> I
M -- no --> N
B -. read by .-> M
F1 -. included .-> I
sync writes two things:
-
One fragment per profile at
~/.gitconfig.d/gitident/<profile>.gitconfig, containing[user],[commit] gpgsign,[gpg] format,[core] sshCommandand yourextrakeys. -
A managed block at the end of your global gitconfig (
$GIT_CONFIG_GLOBAL, else~/.gitconfig, else~/.config/git/config). Everything outside the block is left byte-for-byte untouched. The file is written atomically, and if it is a symlink into a dotfiles repo, the target is updated in place.The block contains no
[user]section of its own. Strict mode lives in_strict.gitconfiginstead, so values you set withgit config --global user.…always land outside the block. If you do put settings inside the block,syncstops and lists them rather than deleting them.
# >>> gitident managed block — edit profiles.yaml and run `gitident sync` >>>
# strict_identity: user.useConfigOnly = true
[include]
path = ~/.gitconfig.d/gitident/_strict.gitconfig
# rule 1 → personal
[includeIf "gitdir:~/code/"]
path = ~/.gitconfig.d/gitident/personal.gitconfig
# rule 2 → work
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig.d/gitident/work.gitconfig
[includeIf "hasconfig:remote.*.url:git@github.com:company/**"]
path = ~/.gitconfig.d/gitident/work.gitconfig
# repos → work
[includeIf "gitdir:~/misc/legacy-thing/.git"]
path = ~/.gitconfig.d/gitident/work.gitconfig
[includeIf "hasconfig:remote.*.url:git@github.com:company/infra"]
path = ~/.gitconfig.d/gitident/work.gitconfig
[includeIf "hasconfig:remote.*.url:git@github.com:company/infra.git"]
path = ~/.gitconfig.d/gitident/work.gitconfig
# <<< gitident managed block <<<Because the fragments are included, not copied, editing a profile and running
sync updates every repository at once.
For every setting, git keeps the last value it reads. gitident orders the block so that reading order is the priority order:
flowchart LR
S["Strict mode<br/>(no identity)"] --> R1["Rule 1"] --> RN["… Rule N<br/>later rules win"] --> L["repos lists<br/>in any profile"] --> P["Pin<br/>gitident use"]
classDef low fill:#eee,stroke:#999,color:#333
classDef high fill:#d4f4dd,stroke:#2a7,color:#133
class S low
class P high
Lowest priority on the left, highest on the right.
- Rules apply in file order, so a later rule overrides an earlier one. Within
a rule,
dirscome beforeremotes. reposentries beat all rules. A path matches exactly that repository (gitdir:<path>/.git; nested repos don't inherit it). A URL matches the remote with or without.git.- Pins made with
gitident uselive in the repository's own.git/config. git reads that after the global config, so pins beat everything. - Strict mode (
strict_identity: true, the default) setsuser.useConfigOnly, so git refuses to commit in a repository that matches nothing instead of guessing an identity from your hostname.
Remote globs follow git's own rules: * matches anything except /, and **
matches anything including / when it is a whole path component
(git@github.com:company/**).
The includeIf rules only work where git reads your global gitconfig and the fragments it includes. Some places don't:
- Dev containers and remote machines. VS Code copies
~/.gitconfiginto the container, but not~/.gitconfig.d/gitident/, so the includes point nowhere. - Git clients built on libgit2, which may not evaluate
includeIf "hasconfig:remote.*.url:…", soremotesrules and URLreposentries are invisible to them.
For these, gitident can copy a repository's profile straight into its own
.git/config, which travels with the repository:
gitident apply # the current repository
gitident apply ~/work/api # or any others
gitident apply --all # every repository a profile applies toThe copy holds everything the fragment would: user.name, user.email, the
signing settings, core.sshCommand and extra keys. Which profile is copied
follows the usual precedence: the pin if there is one,
else rules and repos lists.
To have this happen automatically, turn on materialize. sync then copies
the profile into every matching repository it finds under the default roots,
including new clones:
materialize: true # every profile
profiles:
work:
materialize: true # or per profile (overrides the top-level value)Copies stay honest:
- gitident records what it wrote in a
[gitident]section of.git/config(profile, keys and a hash of the values), and lists the repository in~/.gitconfig.d/gitident/materialized.list. syncrewrites every copy when you changeprofiles.yaml, switches it when a pin or rule change gives the repository another profile, and removes it when no profile applies any more.useandunuseupdate the copy at once.- Values changed by hand since, and local settings gitident didn't write (a
user.emailyou set yourself), are never overwritten.syncandapplyreport them;--forceoverwrites. checkreports a copy that is out of date, changed by hand, or missing whilematerializeis on asSTALE;doctorchecks every copy too.gitident unapply [dir…|--all]removes copies, anduninstallremoves them all. Turningmaterializeoff keeps existing copies (and keeps them current) until you rununapply.
One trade-off: a copy only exists after apply or sync has run, so a fresh
clone relies on the includeIf rules until then (or fails in strict mode inside
a container). Run gitident sync after cloning when that matters.
Coding agents commit without anyone watching. When an identity is missing they
tend to "fix" it with git config user.email …, which quietly defeats strict
mode, and a signing key that needs a passphrase makes their commit hang on a
prompt nobody sees. gitident gives them three things.
One command that says whether a commit in a directory would go through with the right identity, unattended:
$ gitident preflight ~/Projects/wb/analytics
identity kunst.kirill <kunst.kirill@wb.ru> (profile wb)
FAIL signing_failed: commits here are signed (openpgp key A4A7100D728A87D5), but a test signature failed: gpg: signing failed: No pinentry
→ ask the user to unlock the GPG key in a terminal (…)It checks that git has a user.email, that it belongs to a profile, and that
it is the profile the rules, repos lists or pin choose. It also flags an
identity no rule chose, such as a global user.email. When commits are signed,
it makes a test signature with the same program and key git would use, with
passphrase prompts disabled, so a locked key fails at once instead of hanging.
--json gives ok, the identity, the signing result and a list of problems,
each with a code and a fix. The exit status is 0 when a commit would go
through, 1 for an identity problem and 2 when only signing would fail.
--no-sign skips the signing test.
| Code | Meaning |
|---|---|
no_identity |
No user.email: no profile applies, or sync hasn't run. |
unknown_email |
The email isn't in any profile. |
unmatched |
The email belongs to a profile, but no rule, repos entry or pin chose it (e.g. a global user.email). |
mismatch |
A different profile than the rules say. |
stale_copy |
The profile copy in .git/config is out of date or was edited. |
signing_failed |
Commits are signed, but the test signature failed or would need a passphrase. |
not_a_repo, no_config |
Not in a repository, or profiles.yaml is missing or invalid. |
gitident agent install # every supported agent found on this machine
gitident agent install cursor codex # or name them
gitident agent install --scope project # files in this repository, shared with the team
gitident agent list # what is supported, what was found
gitident agent status # what is installed whereEach agent gets two things:
- A hook that runs before every shell command the agent executes. It
blocks
git commit,merge,rebase,cherry-pick,revert,am,pulland annotated tags whenpreflightfinds a problem, and blocks setting the identity by hand (git config user.email,git -c user.name=…,GIT_AUTHOR_*variables). The agent sees the problem and its fix and can ask you. It understandscd dir && git …andgit -C dir …. Commands made with--no-gpg-signskip the signing test. - Guidance to run
preflightbefore committing and how to handle each problem code: a skill, a rule file, or a marked section in the agent's instructions file that gitident keeps up to date and removes cleanly.
| Agent | Hook (user scope) | Guidance (user / project scope) |
|---|---|---|
claude Claude Code |
PreToolUse in ~/.claude/settings.json |
skill ~/.claude/skills/gitident/SKILL.md / .claude/skills/… |
codex OpenAI Codex |
PreToolUse in ~/.codex/hooks.json |
section in ~/.codex/AGENTS.md / AGENTS.md |
cursor Cursor |
beforeShellExecution in ~/.cursor/hooks.json |
— (user rules are UI-only) / .cursor/rules/gitident.mdc |
gemini Gemini CLI |
BeforeTool in ~/.gemini/settings.json |
section in ~/.gemini/GEMINI.md / GEMINI.md |
copilot GitHub Copilot CLI |
preToolUse in ~/.copilot/hooks/gitident.json |
section in ~/.copilot/copilot-instructions.md / .github/copilot-instructions.md |
factory Factory Droid |
PreToolUse in ~/.factory/hooks.json |
— / AGENTS.md |
windsurf Windsurf |
pre_run_command in ~/.codeium/windsurf/hooks.json |
— / AGENTS.md |
--scope project puts the hooks in the project's own agent folders
(.claude/, .codex/, .cursor/, …). --scope local is Claude Code's
.claude/settings.local.json. CLAUDE_CONFIG_DIR, CODEX_HOME,
COPILOT_HOME and GEMINI_CLI_HOME are honoured. Existing hooks and settings
of other tools are left as they are, and gitident agent uninstall removes
only what gitident added.
For any other agent, gitident agent instructions prints the same guidance
to paste into its rules file.
Agents often clone into fresh directories. With clone_hook: true, sync
installs a post-checkout hook in git's template directory, so every
git clone (and git worktree add) ends with one line such as:
gitident: profile "work" (Kirill Kunst <kirill@company.com>), copied into .git/config
gitident: no profile applies to this repository, so commits will fail; run `gitident use <profile>` here (profiles: …)With materialize on for the profile, the new clone also gets its copy right
away, before the first commit. If you already set init.templateDir, the hook
goes into that directory instead, and an existing post-checkout hook there is
never replaced (call gitident __on-clone from it yourself). A global
core.hooksPath stops git from running template hooks; doctor flags that.
The config lives at $XDG_CONFIG_HOME/gitident/profiles.yaml
(~/.config/gitident/profiles.yaml). Set GITIDENT_CONFIG to use a different file.
version: 1 # required
strict_identity: true # user.useConfigOnly (default true)
materialize: false # also copy profiles into each repo's .git/config
clone_hook: false # post-checkout hook in git's clone template
profiles:
work: # letters, digits, . _ - (becomes a file name)
name: Kirill Kunst # required
email: kirill@company.com # required
signing_key: ~/.ssh/id_work.pub # GPG key id, or SSH key path / "key::…"
gpg_format: ssh # openpgp | ssh | x509
gpgsign: true # commit.gpgsign
ssh_key: ~/.ssh/id_work # → core.sshCommand "ssh -i … -o IdentitiesOnly=yes"
extra: # any other git setting, "section.key: value"
pull.rebase: "true"
url.git@github.com:.insteadOf: https://github.com/
repos: # explicit repos; beat all rules
- ~/misc/legacy-thing # a path (absolute or ~/…)
- git@github.com:company/infra.git # or a remote URL
materialize: true # per-profile override of the top-level value
rules: # later rules override earlier ones
- profile: work
dirs: ["~/work"] # every repo below these directories
remotes: # every repo with a matching remote URL
- git@github.com:company/**
- https://gitlab.company.com/**
scan:
ignore: ["archive", "~/work/tmp"] # extra dirs `check` and `import` skipsync refuses to run when the config has errors, such as missing name/email, an
unknown gpg_format, a rule pointing at an unknown profile, or the same repo
listed in two profiles. Warnings, such as a missing key file, are printed but
don't block sync.
| Command | What it does |
|---|---|
gitident init [--force] |
Write a commented sample profiles.yaml, pre-filled with your current global name and email. |
gitident import [roots…] [--write|--merge|--force] [--from-gitkraken] [--interactive] |
Build profiles.yaml from your existing git setup. Prints to stdout unless --write is given. |
gitident sync [--dry-run] [--no-prune] |
Validate, write fragments, delete fragments of removed profiles, update the managed block, and bring profile copies in .git/config up to date. Running it twice changes nothing. |
gitident which [dir] [--json] |
Show the identity in a directory, where user.email comes from, which profile that is, and which profile is expected. |
gitident preflight [dir] [--json] [--no-sign] |
Check that a commit would go through with the right identity, and that signing works without a prompt. See AI agents and scripts. |
gitident check [roots…] [--json] [-q] |
Check every repository under the roots (default: all rule dirs plus the parents of listed repos). Exits 1 on problems. |
gitident use <profile> [dir] [--save] |
Pin a repository to a profile. --save also adds it to the profile's repos (keeping your YAML comments) and syncs. |
gitident unuse [dir] |
Remove the pin. Warns if a repos entry will keep applying. |
gitident apply [dir…|--all] [--force] [--dry-run] |
Copy the repository's profile into its .git/config. See Copying profiles into repositories. |
gitident unapply [dir…|--all] [--force] [--dry-run] |
Remove copies made by apply. |
gitident agent install|uninstall|status [agent…] [--scope user|project|local] |
Install hooks and guidance for coding agents (Claude Code, Codex, Cursor, Gemini CLI, Copilot CLI, Factory, Windsurf). agent list shows support; agent instructions prints the guidance. |
gitident doctor |
Check the git version, config, key files, GPG/SSH signing setup, and whether the block and fragments are current. |
gitident uninstall [--dry-run] |
Remove the managed block, the fragments and the profile copies in repositories. Keeps profiles.yaml. |
gitident completion bash|zsh|fish |
Print shell completions. Profile names are completed for use. |
gitident version |
Print the version. |
Every command is also available as git ident <command>.
| Status | Meaning |
|---|---|
ok <profile> |
The identity matches profiles.yaml. The line adds (copied into .git/config) for copies made by apply, and (set outside gitident: <file>) when the identity comes from anywhere else, e.g. a user.email you set in .git/config. |
NO IDENTITY |
The repository has no user.email. In strict mode, commits fail here. |
UNKNOWN EMAIL |
The email isn't in any profile. |
MISMATCH |
The effective profile isn't what the rules, repos list or pin say. This is usually a leftover user.email in .git/config, or sync hasn't run yet. |
STALE |
The profile copy in .git/config (see apply) is out of date, was changed by hand, or is missing although materialize is on. |
MISSING |
A path in repos doesn't exist or isn't a repository. |
NOT CLONED |
A URL in repos has no clone under the scanned roots. Informational only. |
While scanning, a directory with a .git directory or file counts as a repository,
and the scan doesn't go inside it. The scan skips hidden directories,
node_modules, .build, DerivedData, Pods, Carthage and your
scan.ignore entries, and it doesn't follow symlinks.
echo 'source <(gitident completion bash)' >> ~/.bashrc # bash (also completes `git ident`)
gitident completion zsh > "${fpath[1]}/_gitident" # zsh
gitident completion fish > ~/.config/fish/completions/gitident.fishHomebrew installs these for you.
gitident import reads the setup you already have and proposes a
profiles.yaml. It never touches your gitconfig; changing that is sync's job.
flowchart LR
G["~/.gitconfig<br/>[user] + includeIf"] --> I
L["Repos with their own<br/>user.email"] --> I
K["GitKraken profiles<br/>(--from-gitkraken)"] --> I
I["gitident import"] --> Y["profiles.yaml<br/>(review it!)"]
Y --> S["gitident sync"] --> C["gitident check"]
- Global config:
[user]plus everyincludeIf "gitdir:…"andincludeIf "hasconfig:remote.*.url:…"fragment it references. import works out the identity each condition actually produces, in the order git reads them. - Repositories under the roots you pass: identities set in their own
.git/config. These becomereposentries, unless the imported rules already give the repository that identity. - GitKraken (
--from-gitkraken):~/.gitkraken/profiles/*/profile. This is best effort. When a GitKraken profile has the same name and email as an identity found in git config, the two are merged.
Identical identities become one profile. A profile is named after its fragment
file (~/.gitconfig-work → work), its GitKraken profile name, or its email
domain (kirill@welltory.com → welltory). To choose the names yourself, pass
--interactive. Every profile gets a # source: comment.
To migrate:
gitident import ~/work ~/code --from-gitkraken # 1. look at the proposal
gitident import ~/work ~/code --from-gitkraken --write # and save it
$EDITOR ~/.config/gitident/profiles.yaml # 2. rename profiles, turn long repo
# lists into `dirs` rules (import hints)
gitident sync --dry-run && gitident sync # 3. apply
gitident check # 4. fix until it's cleanWhen check is clean:
- Delete the old
[user]section and your hand-writtenincludeIfs from~/.gitconfig.syncwarns while a globaluser.nameoruser.emailis left, because either one quietly defeats strict mode. - Clear the identity settings in GitKraken's profiles. GitKraken reads the effective git config anyway.
import --merge adds only new profiles, repos and rules to an existing
profiles.yaml, and keeps your comments.
To catch drift automatically, run check on a schedule. For example, with cron:
0 9 * * 1 /opt/homebrew/bin/gitident check -q || osascript -e 'display notification "gitident check found problems" with title "gitident"'Does GitKraken (or my IDE) respect this? Yes. They run git, and git reads the config. After you migrate, clear the identity fields in GitKraken's profiles so they don't override it.
Worktrees?
dirs rules match the worktree's git dir, which lives inside the main repository
(main/.git/worktrees/<name>). A worktree therefore follows the rules for the
main repository's location, not for the directory it's checked out in.
gitident use inside a worktree pins the whole repository, and use --save
records the main repository's path.
Submodules?
A submodule's git dir lives in the superproject (.git/modules/<name>), so the
submodule follows the superproject's dirs rules, and path entries can't match
it. List the submodule by its remote URL instead. use --save does that
automatically.
macOS symlinked paths (/var → /private/var, or a symlinked ~/work)?
git resolves symlinks in the repository path but not in the pattern. So when a
dirs entry or repos path resolves to a different location, sync adds a
second includeIf for the resolved path.
git older than 2.36?
Older versions ignore includeIf "hasconfig:remote.*.url:…", so remotes rules
and URL repos entries do nothing. sync and doctor warn about this. Everything
else still works.
Why is user.useConfigOnly in the block?
Without it, in a repository no rule matches, git invents an identity from your
username and hostname, and you only notice after pushing. To turn it off, set
strict_identity: false.
Can I still edit ~/.gitconfig by hand?
Yes. Anything outside the markers is yours. If the markers are damaged, sync
won't touch the file and tells you how to fix it.
How do I undo everything?
Run gitident unuse in any pinned repositories, then run gitident uninstall.
make test # go test ./... (tests run real git in a throwaway $HOME)
make lint # gofmt, go vet, staticcheck
make build # bin/gitident (+ bin/git-ident symlink)
make snapshot # local goreleaser dry runTo release, push a tag such as v0.1.0. The release workflow runs goreleaser,
which publishes the archives and updates the Homebrew cask in
leoru/homebrew-tap. It needs a HOMEBREW_TAP_GITHUB_TOKEN repository secret.
Out of scope: gh/glab credentials (that's a credential helper's job),
Windows, and a TUI.