gkit
A transparent, stateless git/ssh toolkit.
gkit is one small binary for the repetitive git/ssh chores a developer juggling
many repos and identities runs all the time:
clone— clone a fleet of repos from a config file, with built-in submodule branch-switching,.envrctrust, and pre/post-clone flag passthrough.logoff— a log-off check: is every repo and submodule committed and pushed? It’s a real pass/fail gate (exit code), recursive, and greppable.stmb— switch to main branch: finish a feature branch by returning to the base branch, updating it, and safely deleting the feature — recursively.key— generate ssh keys and manage the gkit-owned~/.ssh/git_users.
The niche
Multi-repo managers (mani, tsrc, gita) clone and run commands across repos. Per-repo
identity tooling (includeIf, ssh host aliases) handles keys. But nothing ships a
submodule-recursive “is everything committed and pushed” gate, and no fleet tool
offers real clone hooks. gkit fills exactly that gap — and shells out to plain git,
so there’s nothing magic to reverse-engineer.
What makes it different
- Transparent — no magic. Every side effect is printed;
gkit cloneshows the exactgit … clone …it runs. - Stateless. No
~/.gkitregistry. Your conf file plus each repo’s own metadata (.gitmodules,.envrc, git config) are the only state. --dry-run+ confirm on anything that mutates.
Continue to Installation.
Why gkit (and what we tried first)
gkit exists to fill a specific gap. Before writing it, we evaluated the established multi-repo tools and even adopted one for a while. Here’s what we tried and why we ended up building our own — including what gkit deliberately does not do.
The need
Working across many repos in several orgs, with multiple ssh identities and lots of submodules, a few chores repeat constantly:
- clone a fleet of repos to set local paths;
- before stepping away, confirm everything is committed and pushed — including every submodule;
- finish a feature branch (switch back to base, update it, delete the branch);
- provision ssh keys per identity.
What the existing tools do — and don’t
- gita / tsrc / ghq — solid multi-repo managers (clone, sync, run commands,
groups). But they are status reporters / runners: none ship a
submodule-recursive “committed and pushed” pass/fail gate. The closest
reporter,
mgitstatus, explicitly--ignore-submodules. The “is my work safe to walk away from?” check — recursive, with an exit code — isn’t in any of them. - git
includeIf+ sshHostaliases — handle per-repo identity well; gkit builds on them rather than replacing them.
The mani trial
mani is a genuinely good repo manager + task runner, so we adopted it first:
- clone via
mani sync(a manifest of repos → local paths); - log-off / setup by wrapping our existing zsh check as a
mani runtask.
Two things kept it from being the whole solution:
- No clone hooks. Our clone needs per-repo work during cloning — toggle
direnv(some.envrcs launch a pager likeglowand corrupt command output), switch submodules onto their branch, stamp config. mani has no pre/post-clone hook and no global default clone command. The only workaround was a bespokesafecloneshell wrapper aroundmani sync— which isn’t shippable to other users, defeating the purpose. - It’s a general task runner. mani’s other half (
run/exec/tui) is powerful, but it’s a general multi-repo runner; our real needs are a handful of specific operations. Depending on a general runner — and re-implementing the parts it lacks — wasn’t the right fit.
And crucially, the log-off gate itself is domain logic no tool provides (five checks + submodule recursion + a real exit code). Running it through mani still left that logic as ours.
The decision
Build gkit: a small, transparent, stateless binary that shells out to git
and does exactly the specific jobs — a config-driven clone with the pre/post hooks
mani lacks, the submodule-recursive log-off gate, stmb, and key.
We deliberately do not rebuild mani’s general task runner / parallel exec / TUI — that would be reinventing the wheel. gkit is a set of specific tools, not a fleet runner; for “run any command across many repos,” reach for mani or similar.
In short
- Reuse where tools fit — git itself, ssh
Hostaliases, and a general runner if you want one. - Build only the genuine gaps — clone-with-hooks and the submodule-recursive
log-off gate (plus
stmb/keyto round out the workflow). - Stay transparent (print every command) and stateless (the conf file and each repo’s own metadata are the only state).
Installation
gkit is a single binary. It needs git on your PATH (and ssh-keygen/ssh-add
for gkit key).
Homebrew (macOS / Linux)
brew install teeckoo/tap/gkit
winget (Windows)
winget install teeckoo.gkit
Shell installer (macOS / Linux)
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/teeckoo/gkit/releases/latest/download/gkit-installer.sh | sh
PowerShell installer (Windows)
irm https://github.com/teeckoo/gkit/releases/latest/download/gkit-installer.ps1 | iex
From source
cargo install --git https://github.com/teeckoo/gkit gkit
Verify
gkit --version
gkit --help
Quick start
1. Write a clone conf
A TOML file — host/namespace at the top (not the filename), so one ssh key can
back many per-namespace confs. A [[repo]] block per repo; the name is the dir’s
basename.
host = "tlbb" # ssh Host alias -> clone URL = host:namespace/repo.git
namespace = "example-org" # org / group / user
[[repo]]
dir = "$HOME/work/cp-conf"
[[repo]]
dir = "$HOME/work/cosp"
depth = 1 # shallow
[[repo]]
dir = "$HOME/work/big-lib"
branch = "dev" # single branch
2. Clone the fleet
gkit clone repos.toml # one conf
gkit clone a.toml b.toml # or: several confs
gkit clone *.toml # or: every conf in the cwd (shell glob)
gkit prints the exact command for each repo, clones missing ones, switches their
submodules onto the right branch, and trusts any .envrc:
+ git clone --recurse-submodules tlbb:example-org/cp-conf.git /Users/you/work/cp-conf
cloned cp-conf /Users/you/work/cp-conf
+ git clone --depth 1 --recurse-submodules tlbb:example-org/cosp.git /Users/you/work/cosp
cloned cosp /Users/you/work/cosp
3. Before you log off — is everything safe?
gkit logoff ~/work/cp-conf
/Users/you/work/cp-conf/submodule-a dev true
/Users/you/work/cp-conf dev true
Exit code 0 means every repo and submodule is committed and pushed. Add
-v for a per-check breakdown you can grep, or -vv to also print why each
failing check failed. gkit logoff -e lists the numbered rules.
4. Done with a feature branch?
gkit stmb ~/work/cp-conf
Switches back to the base branch, pulls, then deletes the feature branch only
after verifying it’s merged (by reachability or patch-id content match, so
squash/rebase merges are recognized) — printing a readable reason; it refuses an
unverified branch rather than offering a force flag. Recursive across submodules, and
runs a verifying log-off check. Use --dry-run to preview.
Next: the full Configuration reference.
Configuration
A clone conf is a TOML file: host/namespace, optional global flags and
hooks, and a [[repo]] block per repo. It is the only state gkit clone needs.
host = "tlbb" # ssh Host alias (from ~/.ssh/config)
namespace = "example-org" # GitHub org / GitLab group / user (optional — see below)
# global, all optional
git-flags = ["-c", "http.lowSpeedLimit=1000"] # raw, BEFORE `clone`
clone-flags = ["--filter=blob:none"] # raw, AFTER `clone`
pre-clone = "echo starting $GKIT_REPO" # string OR list of strings
post-clone = ["direnv allow ."]
[[repo]]
dir = "$CP_HOME/cp-conf"
[[repo]]
dir = "$CP_COMMON_LIBS/cosp"
namespace = "other-org" # overrides the global namespace for THIS repo
depth = 1
branch = "dev"
clone-flags = ["--no-tags"]
pre-clone = "echo prepping cosp"
post-clone = ["mill compile"]
The clone URL is <host>:<namespace>/<repo>.git, where repo = basename(dir).
Because host/namespace live in the file (not the filename), one ssh key can
back many conf files — e.g. one per namespace.
Namespace: global or per-repo
namespace may be set globally, per [[repo]], or both. A repo’s effective
namespace is its own namespace if present, otherwise the global one — so a
single conf can span repos from different orgs/users (same host):
host = "gh" # one ssh alias; global namespace omitted
[[repo]]
dir = "$HOME/work/foo"
namespace = "alice" # -> gh:alice/foo.git
[[repo]]
dir = "$HOME/work/bar"
namespace = "bob-org" # -> gh:bob-org/bar.git
The global namespace is optional, but every repo must resolve one (its
own or the global). If any repo has neither, gkit clone errors before cloning
anything, naming the offending dir.
Top-level keys
| Key | Meaning |
|---|---|
host | ssh Host alias. Required. |
namespace | org/group/user; the URL’s owner segment. Optional — a [[repo]] may set its own; every repo must resolve one. |
git-flags | raw flags injected before clone (git-level). |
clone-flags | raw flags injected after clone, every repo. |
pre-clone / post-clone | global hook commands (string or list). |
[[repo]] keys
| Key | Meaning |
|---|---|
dir | local destination; $VAR/${VAR}/~ expanded. |
namespace | org/group/user for this repo; overrides the global namespace. Required only if there’s no global one. |
name | remote repo name (URL’s last segment). Defaults to basename(dir); set it to clone a repo into a differently-named dir (e.g. dir = ".../cosp-mirror", name = "cosp"). |
depth = N | shallow clone (--depth N, implies single-branch). |
branch = "B" | check out branch/tag B after clone (--branch B). Full clone by default (all branches fetched); add single-branch = true to fetch only B. |
single-branch = true | add --single-branch. With branch → fetch only that branch; on its own → clone only the remote’s default branch (bare git clone --single-branch). |
clone-flags | per-repo raw flags after clone. |
pre-clone / post-clone | per-repo hook commands (string or list). |
Execution order (per repo)
- global
pre-clone - repo
pre-clone git <git-flags> clone [--depth N] [--branch B] [--single-branch] --recurse-submodules <clone-flags> <repo clone-flags> <url> <dir>— printed, output captured- built-ins (unless disabled): git identity (
user.name/user.emailon the repo and recursively on every submodule, if resolved — printed), submodule init + branch-switch,direnv allow - global
post-clone - repo
post-clone
Each hook command runs as its own sh -ec '<cmd>' (so set -e is on), output
shown live, with $GKIT_REPO, $GKIT_DIR, $GKIT_URL, $GKIT_HOST,
$GKIT_NAMESPACE set — plus $GKIT_USER_NAME / $GKIT_USER_EMAIL (the resolved
identity, empty when unset). Pre runs in the parent of the target dir; post runs
inside the cloned repo. Fail-fast at two levels: a hook line stops at its first
failing step (set -e — e.g. cd sub; cmd won’t run cmd if cd sub fails), and
the first hook line that exits non-zero fails that repo (the rest don’t run). So you
don’t need defensive || true to stop a bad step from doing damage — write
cmd || true only on the rare command you deliberately want to tolerate.
cd is per line. Each line starts fresh from the repo root, so a cd on one
line does not carry into the next (no cross-line leak). Keep a cd with its
command: cd sub && git config … (or just use git -C sub config …).
Git identity is not a conf key (the conf is shared across a team): it comes
from the clone --user-name/--user-email flags, or an interactive prompt when
omitted — see gkit clone.
The post-clone hooks run only at clone time. To re-apply them over repos that
already exist (e.g. to stamp gkit.baseBranch/gkit.solo on a submodule added
after the initial clone), run gkit stamp.
The gkit.conf git-config key
gkit clone stamps gkit.conf — the absolute path of the conf that cloned the
repo — into each repo’s local git config (and gkit stamp --conf
back-fills it on older clones). It lets gkit stamp run inside a repo with no
argument: it reads gkit.conf, re-parses that conf, and re-applies this repo’s
post-clone. The value is an absolute, machine-local path — if the tree moves,
re-anchor it with git config gkit.conf <new-path> or gkit stamp --conf.
Project-specific config that isn’t a gkit concept (e.g. core.hooksPath .githooks)
belongs in post-clone, not in a gkit built-in — so gkit stamp re-applies it like
any other hook.
The ssh alias vs checked-in URLs (insteadOf routing)
The conf’s host is an ssh Host alias (tlbb) — purely local key-selection
(tlbb ≡ git@bitbucket.org + ~/.ssh/id_tlbb). It must not end up in checked-in
URLs: a teammate without that alias can’t resolve tlbb:org/repo.git. So submodule URLs
in .gitmodules should be canonical — git@<hostname>:<ns>/repo.git — and each gkit
developer gets a local rewrite that routes them through the alias’s key:
git config url."tlbb:codogenics/".insteadOf "git@bitbucket.org:codogenics/"
gkit clone writes this rule for you (see clone → SSH-alias
routing), namespace-scoped so multiple aliases on the same host (different clients)
keep their own keys, into a gkit-owned ~/.gitconfig-gkit that ~/.gitconfig
[include]s. The rules are derived (alias + namespace from the conf, hostname from
git_users), so they’re regenerable. Migrating an existing repo’s .gitmodules from
alias to canonical URLs is a one-time manual git op (git config -f .gitmodules submodule.<n>.url git@<host>:<ns>/x.git + git submodule sync).
Built-in, stateless post-clone
Derived from each repo’s own on-disk metadata — no config needed:
- git identity →
git config user.name/user.emailwhen resolved from--user-name/--user-emailor the prompt (your input, not the conf), applied to the repo and every submodule (submodule foreach --recursive). Printed. - submodules →
update --init --recursive, then each switched onto its.gitmodulesbranch (no detached HEAD). Disable with--no-submodule-branch. .envrc→direnv allow(trust-only; it does not evaluate the file, so an.envrcthat runs e.g.glow ReadMe.mdwon’t taint output). Disable with--no-direnv.
Commands
gkit uses noun-style subcommands, in two layers.
SSH key layer — start here
Every workflow begins with an ssh identity: you need a key on the host before you
can clone anything. This layer is repo-independent — it manages keys and the
gkit-owned ~/.ssh/git_users.
| Command | Summary |
|---|---|
key | Generate id_<alias> ssh keys, copy a public key, and manage ~/.ssh/git_users. |
Repo layer — the everyday loop
Once a key is in place, these act on git repositories (a single repo or a whole fleet from a conf):
| Command | Summary |
|---|---|
init | Scaffold a starter clone conf in the current directory. |
clone | Clone the repos in a conf file, with hooks and transparent commands. |
stamp | Re-apply a conf’s post-clone over existing repos (e.g. stamp config on a late-added submodule). |
logoff | Gate: is every repo + submodule committed and pushed? |
stmb | Switch to the base branch and delete a finished feature once verified merged (reachability or patch-id content match), recursively. |
fixsub | Fix submodule metadata: un-detach each onto its .gitmodules branch (report, don’t yank, a feature branch) and inherit the root identity. |
Run gkit <command> --help for the authoritative flag list.
gkit key
Manage ssh keys/identities. The single convention is the alias: it is the ssh
Host, and the key is ~/.ssh/id_<alias>. gkit owns ~/.ssh/git_users — a
generated, disposable file (regenerated, never blind-appended), Included by
~/.ssh/config.
Subcommands
gkit key add [alias] [--email <e>] [--host <hostname>] [--port N] [--dry-run] [-y]
gkit key list
add
alias, --email, and --host are all optional on the command line — when
omitted, add prompts for them (in an interactive terminal; a non-interactive
run without them is an error rather than a hang). --host is asked via a small
provider menu:
provider:
1) github.com (default)
2) bitbucket.org
3) gitlab.com
4) other (custom hostname)
choose [1-4]:
A bare Enter picks the default (github.com); option 4 (or any hostname typed
directly) sets a custom/private host (e.g. git.mycorp.com).
Then add:
ssh-keygen -t ed25519 -C <email> -f ~/.ssh/id_<alias>(skipped if it exists).- Upsert a
Host <alias>block into~/.ssh/git_users(replacing any old block for that alias). The block is OS-aware — macOS includesUseKeychain yes, Linux/Windows omit it. - Check
~/.ssh/configforInclude git_users. This is your own, hand-managed file, so gkit treats it carefully:- if the line is already there, it says so and leaves the file untouched;
- if it’s missing, it explains that ssh will ignore gkit’s host blocks without
it and asks for permission before adding the line, defaulting to yes
(a bare Enter adds it;
-y/--yesadds it without asking; declining is fine — it tells you to add it yourself).
ssh-addthe key.- Copy the public key to the clipboard, ready to paste into your provider. The
tool is chosen per OS: macOS
pbcopy, Windowsclip, Linuxwl-copy→xclip→xsel(first one installed wins). If none is found, it prints the key.
--dry-run prints the full plan (keygen command, the exact Host block, the
Include status) without touching anything or prompting.
Generated block (macOS):
Host acme
HostName github.com
User git
AddKeysToAgent yes
UseKeychain yes
IdentitiesOnly yes
IdentityFile ~/.ssh/id_acme
The HostName is now also consumed by gkit clone: it resolves the
alias → real host to write the namespace-scoped insteadOf routing rule (so canonical
git@<hostname>:… URLs route through this key). A correct HostName therefore matters
beyond ssh itself.
list
Lists the Host aliases (and their IdentityFile) gkit owns in ~/.ssh/git_users.
Cross-platform
key works on macOS, Linux, and Windows:
- Home /
~/.ssh— resolved fromHOME(Unix/macOS), falling back toUSERPROFILEthenHOMEDRIVE+HOMEPATHon Windows. UseKeychain yesandssh-add --apple-use-keychainare emitted only on macOS; Linux and Windows omit them.ssh-keygen/ssh-addcome from OpenSSH (built into modern macOS, Linux, and Windows 10+).- Clipboard for the public-key copy in
add— see the per-OS tools above.
gkit init
Write a starter clone conf in the current directory, with sensible defaults. If the
directory is a git repo with an origin like <host>:<namespace>/<repo>.git,
host and namespace are inferred from it; otherwise they’re left as
placeholders to fill in.
Synopsis
gkit init [file] [--force]
file— defaults torepos.toml.--force— overwrite an existing file (otherwiseinitrefuses).
Example
$ gkit init
created repos.toml
host/namespace inferred from origin: tlbb:example-org
Generated repos.toml:
# gkit clone config — run `gkit clone <this-file>`.
host = "tlbb" # ssh Host alias (~/.ssh/config); URL = host:namespace/repo.git
namespace = "example-org" # GitHub org / GitLab group / user (optional — a repo may set its own)
# `gkit.baseBranch` = this repo's integration branch. `gkit logoff` and `gkit stmb`
# read it as the "base": the branch stmb returns to, and the one logoff checks
# against. Stamped on every cloned repo here:
post-clone = ["git config gkit.baseBranch main"] # change to your convention: master / dev
# More optional global settings (uncomment as needed):
# git-flags = ["-c", "http.lowSpeedLimit=1000"] # raw flags BEFORE `clone`
# clone-flags = ["--filter=blob:none"] # raw flags AFTER `clone`
# pre-clone = "echo cloning $GKIT_REPO"
# One [[repo]] block per repo (name = basename of dir; $VAR/~ expanded):
[[repo]]
dir = "$HOME/work/example"
# namespace = "other-org" # override the global namespace for THIS repo
# name = "example" # remote repo name if it differs from the dir basename
# depth = 1
# branch = "dev"
# clone-flags = ["--no-tags"]
# post-clone = ["mill compile"]
Edit it, then gkit clone repos.toml. See Configuration for
every field.
gkit clone
Clone the repos listed in a conf file. Existing repos are skipped. Every git
command is printed (transparency); all subprocess output is captured so a noisy
.envrc can’t distort it.
Synopsis
gkit clone <conf…> [--user-name <n>] [--user-email <e>] [--no-submodule-branch] [--no-direnv] [--no-insteadof]
conf… are explicit conf file(s) — at least one is required, and a directory
is not accepted (use a shell glob for “every conf here”):
gkit clone example-org.toml acme.toml # explicit list
gkit clone *.toml # every conf in the cwd (shell glob)
gkit clone confs/*.toml # every conf in confs/ (shell glob)
gkit clone with no file — or with a directory like gkit clone confs/ — is an
error. This matches how logoff --conf takes confs. When several
confs are given they’re processed in turn (with a == <conf> == header); each has
its own host/namespace. A conf that fails to parse is reported and skipped; the
rest still run and the exit code is non-zero if anything failed.
What it does, per repo
- Build and print
git <git-flags> clone [tokens] --recurse-submodules <clone-flags> <-- flags> <url> <dir>. - Skip if the directory already exists; otherwise clone (output captured).
- Git identity →
git config user.name/user.emailon the repo and every submodule (recursive — a submodule is its own repo with its own config), if resolved (see below; printed, since the values are your explicit input). gkit.conf→git config gkit.conf <absolute conf path>on the repo, so a latergkit stamprun inside the repo (no arg) can find the conf that drives it. (Older clones get this back-filled bygkit stamp --conf.)- Submodules → init + switch each onto its
.gitmodulesbranch (--no-submodule-branchto skip). .envrc→direnv allow(trust-only, no evaluation;--no-direnvto skip).
SSH-alias routing (insteadOf) — once per conf
The ssh alias (the conf’s host, e.g. tlbb) is local key-selection, so it
shouldn’t appear in checked-in URLs (a teammate without that alias can’t resolve
tlbb:org/repo.git). Submodule URLs in .gitmodules should therefore be canonical
— git@<hostname>:<ns>/repo.git — which anyone can clone with their own key. To keep
your clones routing through the alias’s key, gkit clone writes a namespace-scoped
insteadOf rule (printed, idempotent), once per namespace in the conf:
git config -f ~/.gitconfig-gkit --replace-all url."tlbb:codogenics/".insteadOf "git@bitbucket.org:codogenics/"
so git rewrites a canonical git@bitbucket.org:codogenics/x.git → tlbb:codogenics/x.git
→ ~/.ssh/id_tlbb. The namespace scope (codogenics/) means multiple aliases on
the same host (different clients) each keep their own key. The hostname is resolved
from the Host <alias> block gkit wrote in ~/.ssh/git_users.
gkit writes these rules to a gkit-owned file (~/.gitconfig-gkit) and ensures one
[include] line in ~/.gitconfig — mirroring how it owns ~/.ssh/git_users and adds
one Include to ~/.ssh/config. So the rules are regenerable (delete the file → a
re-clone rebuilds them) and gkit never edits your ~/.gitconfig otherwise. Skip with
--no-insteadof; if the alias has no HostName in git_users, gkit warns and
skips (your clone still works via the alias).
Git identity (--user-name / --user-email)
Identity is per-invocation, never in the conf — the conf is shared across a team, so writing one person’s name/email into it would stamp everyone’s clones. Instead you supply it when you run the command:
- pass
--user-name/--user-email, or - omit them and gkit prompts (in a terminal), defaulting to your current
git config user.name/user.email(Enter keeps the default; empty with no default skips that field).
With no flag and no terminal (e.g. CI) the field is left unset, so the clone inherits your global git identity — the command never hangs waiting for input.
The resolved identity is applied to the superproject and recursively to every submodule (each is a separate repo, so commits there use the same identity rather than your global one).
The resolved values are also exported to hooks as $GKIT_USER_NAME /
$GKIT_USER_EMAIL (empty when unset).
Flags
| Flag | Effect |
|---|---|
--user-name <n> | git config user.name to stamp on each cloned repo (prompted if omitted in a terminal). |
--user-email <e> | git config user.email to stamp on each cloned repo (prompted if omitted in a terminal). |
--no-submodule-branch | Leave submodules detached (don’t switch to their branch). |
--no-direnv | Don’t direnv allow repos that have an .envrc. |
--no-insteadof | Don’t write the namespace-scoped insteadOf routing rule for the conf’s ssh alias. |
Per-repo customization (depth, branch, clone-flags), global
git-flags/clone-flags, and pre-clone/post-clone hooks live in the
conf file. The full step order (global/repo pre → clone →
built-ins → global/repo post) is documented there.
Example
host = "tlbb"
namespace = "example-org"
clone-flags = ["--filter=blob:none"]
[[repo]]
dir = "$HOME/work/cosp"
branch = "dev"
clone-flags = ["--no-tags"]
post-clone = ["echo done $GKIT_REPO"]
$ gkit clone repos.toml --user-name "Jane Dev" --user-email jane@example-org.com
+ git clone --branch dev --recurse-submodules --filter=blob:none --no-tags tlbb:example-org/cosp.git /Users/you/work/cosp
+ git config user.name Jane Dev
+ git config user.email jane@example-org.com
+ git submodule foreach --recursive git config user.name 'Jane Dev'; git config user.email 'jane@example-org.com'
+ echo done $GKIT_REPO
done cosp
cloned cosp /Users/you/work/cosp
gkit stamp
Re-apply a conf’s post-clone over existing repos. gkit clone runs a conf’s
post-clone hooks once, right after cloning. gkit stamp re-runs those same hooks
on repos that are already on disk — no cloning, no fetching.
Synopsis
gkit stamp [paths…] [--dry-run] [-y|--yes] # repo-mode (default)
gkit stamp --conf <conf…> [--dry-run] [-y] # conf-mode
Two modes, with the same --conf shape as logoff:
- repo-mode (default): the positional
pathsare repo paths (default: the current directory). Each repo reads its owngkit.confgit-config key — the absolute conf path thatclone(or a prior conf-mode run) stamped — to find the conf that drives it, and re-applies that repo’spost-clone. Fails clearly ifgkit.confis unset rather than guessing. - conf-mode (
--conf): the positional args are conf file(s) (explicit files, same rule asclone). Re-applies each conf to every repo it lists, and back-fillsgkit.conf(set-if-unset) so those repos become usable in repo-mode.
Why it exists
post-clone is where a team stamps the per-repo git config the gate reads —
git config gkit.baseBranch …, git config gkit.solo …, usually with a
git submodule foreach --recursive '…' so submodules get it too:
post-clone = [
"git config gkit.baseBranch dev",
"git submodule foreach --recursive 'git config gkit.baseBranch dev'",
"git config gkit.solo true",
"git submodule foreach --recursive 'git config gkit.solo true'",
]
But clone runs that once, over the submodules that existed then. A submodule
added later — e.g. on a feature branch that pins a new submodule — is never
stamped: it comes up with no gkit.baseBranch (so logoff’s base
falls back to origin/main/master) and no gkit.solo (so it uses the team rule).
Re-running stamp re-applies the conf’s post-clone so those values converge. It’s
safe to re-run: the hooks are git config writes, which are idempotent. (A submodule
must be initialized for the submodule foreach to reach it — run
git submodule update --init first, then gkit fixsub to un-detach
it.)
What it does
repo-mode (per repo path / cwd): resolve the repo’s own gkit.conf; parse that
conf; find the [[repo]] whose dir matches this repo (else fall back to the conf’s
global post-clone); print the plan; confirm (skip with -y); run the hooks in the
repo dir. A repo with no gkit.conf fails with an actionable hint; a non-git dir
fails.
conf-mode (--conf): read + validate every conf; print the plan; confirm; per
repo (in conf order) back-fill gkit.conf where missing, then run the effective
post-clone. A missing/non-git dir fails; a repo with no hooks is skipped.
stamp does not clone, fetch, run pre-clone, or run clone’s built-ins. Git
identity and the submodule branch-switch are not stamp’s job — identity is a
clone concern, and the branch-switch lives in gkit fixsub. So
$GKIT_USER_NAME/$GKIT_USER_EMAIL are empty under stamp.
Flags
| Flag | Effect |
|---|---|
--conf | Treat the args as clone conf file(s) (conf-mode) and back-fill gkit.conf. Without it, stamp is repo-mode. |
--dry-run | Print the plan without changing anything. |
-y, --yes | Skip the confirmation prompt. |
Example
# conf-mode: re-apply + stamp gkit.conf on each repo
$ gkit stamp --conf repos.toml -y
stamp plan (conf mode):
superproject (/home/you/work/superproject):
+ git config gkit.baseBranch dev
+ git config gkit.conf /home/you/confs/repos.toml (/home/you/work/superproject)
+ git config gkit.baseBranch dev
stamped superproject /home/you/work/superproject
# later, from inside the repo — no conf needed (reads gkit.conf):
$ cd /home/you/work/superproject && gkit stamp -y
stamp plan (repo mode):
/home/you/work/superproject (conf: /home/you/confs/repos.toml)
+ git config gkit.baseBranch dev
+ git config gkit.baseBranch dev
stamped superproject /home/you/work/superproject
gkit logoff
The log-off check: is every repo and submodule committed and pushed? A real
pass/fail gate — exit 0 when all clear, non-zero when something is pending.
Recursive into submodules; output is deterministic and greppable.
Synopsis
gkit logoff [path…] [-v|-vv] [--no-fetch] [--base-branch <b>]
gkit logoff --conf <conf…> # check every repo listed in the conf(s)
gkit logoff -e # explain: print the static rule catalog
gkit logoff -e <N> [path] # explain rule R<N> in depth, with this repo's live state
gkit logoff— the repo at the current directory.gkit logoff <path>…— those repo(s) + their submodules.gkit logoff --conf <conf…>— fleet mode: check every repo listed in the given clone conf(s). Takes explicit conf file(s) (required — a bare--conferrors, and a directory is not accepted); files may be from different directories. Use a shell glob for “all in here” (same rule asgkit clone):
gkit logoff --conf *.toml # every repo in the cwd's confs
gkit logoff --conf ~/a/x.toml ~/b/y.toml # confs from different directories
In fleet mode each repo resolves its own base branch (gkit.baseBranch, then
remote origin/main/origin/master). Exit is non-zero if any repo fails a check
or any conf fails to parse.
The seven checks
For each repo and submodule, all must pass. Each rule has a stable id (R1..R7),
shown as a line prefix at -vv and looked up with -e:
-
R1 committed —
git status -sis empty. -
R2 all-commits-pushed — no local commit is missing from a remote.
-
R3 branches-have-remote — every local branch has a remote counterpart.
-
R4 not-behind-remote — the current branch tracks a remote and isn’t behind
origin/<branch>. Fail-closed: if behind-ness can’t be determined — a detached/unborn HEAD, or no remote-tracking branch to compare against — the check fails rather than passing vacuously. (It stays independent of R3: a branch with no upstream fails both.) gkit fetches the repo first (unless--no-fetch) soorigin/<branch>is fresh, not a stale local copy. -
R5 correct-branch — are you parked on a safe branch? Shared preamble for both rules: detached HEAD → fails (a risky resting state); on a feature branch → passes (you’re actively on your work). On an integration branch (
base/main/master), one of two mutually exclusive rules runs, selected bygkit.solo:- team (default,
gkit.solooff) — fails only if a local branch has commits not merged into base (your own unfinished work). Branches that exist only on the remote (others’ work, stale branches) are ignored, so cleanly sitting onmain/devin a shared repo passes. - solo (
gkit.soloon) — fails if the remote has any feature branch. For a solo developer every remote branch is yours, so a leftover one means unfinished/uncleaned work. Setgit config gkit.solo true(per repo) orgit config --global gkit.solo true(your default).
The base is resolved from
--base-branch→git config gkit.baseBranch→ a remote-tracking branch (origin/main, elseorigin/master);main/masterare always integration too. If none of those yield a base (e.g. a single-branch clone of a feature branch), the base is unresolved and this check fails rather than passing vacuously. Setgit config gkit.baseBranch <b>to fix.The active rule is surfaced on a
branch-ruleline at-vv(always, team or solo); the bare-vscan and the default output print nothing about it. - team (default,
-
R6 not-behind-base — the base-side twin of R4: on a feature branch, is it behind base? Fails when the branch has fallen behind the integration base (
git rev-list --left-rightagainstgkit.baseBranch/origin/main/master), in either form:- diverged — also ahead of base (you have unique commits): history has split, rebase onto base.
- merged / stale — not ahead (no unique commits): the branch is done,
switch to base & delete (that’s
gkit stmb).
A feature branch that’s ahead of base but not behind (on top of base, ready to PR) passes. Integration branches are skipped (comparing base to itself is vacuous). Fail-closed, and independent of R5: a detached HEAD, an unresolved base, or a base whose ref can’t be located all fail R6 with their own reason (they don’t defer to R5). Accuracy depends on a fresh
origin/<base>: under--no-fetchR6 compares against your last-fetched base.Tolerate it with
gkit.allowDiverged.git config gkit.allowDiverged true(per repo, or--global) downgrades an R6 behind-base failure to a pass — but the default output still carries a marker (e.g.… true (diverged, allowed by gkit.allowDiverged)), so it stays visible and greppable (gkit logoff | grep allowDivergedaudits the tolerated repos). It does not suppress R6’s fail-closed cases (unresolved/absent base, detached) — those are config errors to fix, not divergence to tolerate. -
R7 submodule-pins — for each repo, are its direct submodules’ committed pins durable? A superproject records each submodule by commit (the gitlink), not by branch. R7 checks that gitlink is reachable from
origin/<the submodule's.gitmodulesbranch>— i.e. it lives on the branch the superproject claims to track. If not, the pin dangles: once that feature branch is squash-merged and deleted, a freshgit submodule update --initcan’t find the commit and fails.- Only direct submodules are checked, and the verdict attaches to the parent that owns the pin. Nesting is automatic: a submodule-of-a-submodule is verified by its immediate parent when that parent is itself evaluated, so the failure surfaces on the line of whichever repo actually committed the bad pin.
- Fail-closed: a submodule with no
branchdeclared in.gitmodulesfails (there’s no tracked branch to certify the pin against — declare one withgit submodule set-branch), as does one whoseorigin/<branch>can’t be located. - Only initialized submodules are checked; a repo with none is not-applicable
(passes). There is no suppression flag — a dangling pin has no healthy state
to tolerate; the fix is to merge the submodule’s work into its declared branch
(or deliberately re-point
.gitmodules). Uses the submodule’s own freshorigin/<branch>(refreshed unless--no-fetch).
Rule philosophy
The checks follow a few deliberate principles — worth knowing, because they explain “why did that fail?”:
- Rules are independent. Each rule asks for exactly the inputs it needs and renders its own verdict; none defers to another. So an unresolved base makes both R5 (correct-branch) and R6 (not-behind-base) fail — each with its own reason. That’s by design, not a double-report bug: every rule reports honestly on its own terms.
- Fail-closed — never pass vacuously. If a rule can’t determine its answer (no base resolved, a base ref that can’t be located, a detached HEAD, a missing upstream), it fails with an actionable reason rather than passing silently. A green check means “verified safe,” never “couldn’t tell.” This is why R4 was tightened: a branch with no remote-tracking ref now fails R4 instead of vacuously passing.
- Durability vs. integration are different questions. R2 (all-commits-pushed)
answers “is my work safe?” — once everything is on a remote, nothing is lost. R6
answers a separate question, “is my feature branch current with base?” A branch can
be perfectly pushed (R2 green) yet badly behind
dev(R6 red). So ahead + pushed is fine (you’re on top of base, ready to PR); behind base is the defect — whether diverged (rebase) or merged/stale (delete). - R4 and R6 are twins on different refs. R4 compares HEAD to its own upstream
(
origin/<branch>— “did I pull my branch?”); R6 compares HEAD to the integration base (dev/main— “did my branch keep up with the trunk?”). Neither subsumes the other. - R7 checks pins, and the defect attaches to the owner. R1–R6 are about a repo’s
own branch/commits; R7 is about the pins that repo commits for its direct
submodules. Because the bad pin is a fact of the parent’s tree, R7 fails on the
parent’s line — so
gkit logoff <superproject>goes red directly, naming the offending submodule. A defect deeper in the tree fails on its own immediate parent’s line; either way the overall run is non-zero if any repo fails (the exit code aggregates every repo), so a nested failure never hides. -vvis the “why did it fail” view. Per-failureR<n> reasonlines live only at-vv; the bare-vscan and the default output stay pure pass/fail. The single exception is thegkit.allowDivergedmarker, which rides the default line so a repo tolerating divergence is still visible to someone who isn’t drilling in with-vv.- The default line is an API. It’s
path branch status [marker]with fixed field positions, so fleet greps stay stable across releases.
Output
Default (one line per repo, post-order: submodules before their parent):
/path/repo/submodule-a dev true
/path/repo dev false
A repo tolerating divergence (gkit.allowDiverged) passes but the line carries a
trailing marker after the boolean (never before it, so path branch status
field positions stay stable):
/path/repo SCB-283 true (diverged, allowed by gkit.allowDiverged)
-v — a pure pass/fail scan: one fact per line, path-first, tab-separated,
fixed order. Just the seven checks + RESULT (no contextual metadata):
/path/repo committed true
/path/repo all-commits-pushed false
/path/repo branches-have-remote true
/path/repo not-behind-remote true
/path/repo correct-branch true
/path/repo not-behind-base true
/path/repo submodule-pins true
/path/repo RESULT dev false
Filtering repos with grep
The default line is a stable, greppable contract — path branch status [marker]:
the branch name is always field 2, the status (true/false) always field 3,
and the only optional addition is the gkit.allowDiverged marker, appended as a
trailing field on passing lines (never shifting the first three). No marker
contains the substrings true/false, so grep true/grep false stay clean. That
makes the everyday fleet slices just work:
gkit logoff ~/work/* | grep false # repos that need attention
gkit logoff ~/work/* | grep true | grep SCB- # clean feature branches parked but unmerged
gkit logoff ~/work/* | grep allowDiverged # repos tolerating divergence (audit)
gkit logoff -v ~/work/* | awk -F'\t' '$NF=="false"' # -v: failing checks, by column
gkit logoff -vv ~/work/* | grep 'not-behind-base.*false' # -vv: who's behind base
Because R6 flips a stale/diverged feature branch from true to false, grep true | grep SCB- now returns only branches that are also current with base — the sharper
answer than before R6 existed (a branch silently rotting behind dev no longer hides
in the true set).
-vv is -v plus context + why: each check line gains its R<n> rule id; the
base-branch and branch-rule metadata lines appear (only here, not at -v); and
every failing check is followed by an R<n> reason line (R5 names the offending
branch, R6 the base + ahead/behind counts). Passing checks get no reason line:
/path/repo R1 committed true
/path/repo R4 not-behind-remote true
/path/repo base-branch dev (from git config gkit.baseBranch)
/path/repo branch-rule team (gkit.solo off) — flags a local branch unmerged into base
/path/repo R5 correct-branch true
/path/repo R6 not-behind-base false
/path/repo R6 reason diverged from base 'dev': 1 ahead, 2 behind — rebase onto base
/path/repo RESULT SCB-283 false
The gkit.allowDiverged marker is the one thing that rides the default/-v
output (on the RESULT line and the default line); the per-failure R<n> reason
lines remain -vv-only — -vv is the “why did it fail” view.
The base-branch line shows the resolved base and how it was derived —
(from --base-branch), (from git config gkit.baseBranch), or
(derived from remote origin/main); when it can’t be resolved it reads
UNRESOLVED — … and correct-branch is false.
Explaining the rules
Two forms, both exit 0 (informational, never the gate):
Bare -e — the static rule catalog (one line per rule: id, key, description).
Read-only; ignores paths and never touches git:
gkit logoff -e # R1..R7, one per line
-e <N> — a repo-aware deep dive on one rule: what it checks, this repo’s
live state (actual branch values, the resolved base, the active rule, the failing
verdict), and a few teaching examples. Reads a single repo — the cwd, or the
path you give — with no submodule recursion and no fetch. The natural follow-up
when -vv flags a rule and you want the full picture:
$ gkit logoff -e 5
R5 correct-branch [this repo: FAIL]
What it checks
parked on a safe branch: a feature branch always passes; on an
integration branch the team rule (default) flags a local branch unmerged
into base, …
This repo now
branch main
base main (derived from remote origin/main)
rule team (gkit.solo off) — flags a local branch unmerged into base
local branches feat-x, main
verdict FAIL — local branch 'feat-x' is not merged into base …
Examples
on a feature branch PASS (actively on your work)
on base/main, all local branches merged PASS (parked clean)
on base/main, local 'wip' unmerged FAIL (team: unfinished work)
detached HEAD FAIL (risky resting state)
An out-of-range rule number (not 1..7) errors with a non-zero exit.
A path that isn’t a git repository (or doesn’t exist) fails the gate rather
than passing — the reason is shown where the branch would be, so the line still
ends in false and the exit code is non-zero:
/path/not-a-repo not a git repository false
/path/missing no such directory false
(Without this, a non-repo would pass every check vacuously: an empty git status
reads as “nothing pending”.)
Flags
| Flag | Effect |
|---|---|
-v | Per-check breakdown (greppable). Repeat (-vv) to add R<n> rule ids and a reason line for each failing check. |
-e | Explain (exits 0). Bare = static rule catalog (no repo). -e <N> = repo-aware deep dive on rule RN: what it checks + this repo’s live state + examples (single repo: cwd or the given path). |
--no-fetch | Don’t fetch first (root and submodules; faster / offline). The behind checks (R4, R6) then compare against your last-fetched refs and can pass vacuously on a stale local copy. |
--base-branch <b> | Override the base branch (root repo only). |
Parallelized for speed, but results are buffered and emitted in a fixed order, so output never depends on which thread finishes first.
gkit stmb
Switch to main branch. You’ve finished a feature branch — stmb returns to the
base branch, updates it, and deletes the feature only after verifying it’s merged,
recursively across submodules, then runs a verifying log-off check.
Synopsis
gkit stmb [path] [--base <b>] [--no-recursive] [-y|--yes] [--dry-run]
What it does
- Resolve each repo’s own base:
--baseapplies to the root only; every submodule resolves its owngit config gkit.baseBranch→origin/HEAD(the same per-repo resolutionlogoffuses). This matters for a tree where a submodule tracks a different integration branch (e.g. one ondevwhile the root is onmain): a single tree-wide base would switch that submodule onto the root’s base and treat its real base as a deletable feature. A repo whose base can’t be resolved is skipped (never forced onto another repo’s base). - Walk the repo + submodules (post-order) and build a plan per repo, showing each
repo’s current branch (
[on <branch>]) so a wrong base is obvious before you confirm:- on a feature branch → switch to base, pull, delete the feature if merged;
- already on base → update (pull) only;
- dirty working tree or detached HEAD → skip (reported).
- Print the plan. With
--dry-run, stop here. Otherwise confirm (skip with-y). - Execute, printing each git command under a per-repo header (transparency,
like
clone):switch base(skipped if already on base — just pull) →pull --rebase origin base→ (verify, then) delete feature →remote prune origin. (git switch, notcheckout, so a worktree path named like the base — e.g. amain/dir — can’t make the branch switch ambiguous.) - Automatically run
logoff(recursive) to confirm everything is clean — after a blank line.
Verified deletion (and why there’s no --force)
Before deleting a feature branch, stmb proves it is merged into base and prints a
readable reason + a one-line conclusion (=> deleting … / => NOT deleting …). The
check has three stages — base is pulled first, so all of them see fresh history:
- Reachability — is the feature tip in base’s history? Catches merge-commit
and fast-forward merges. Deletes with
git branch -d(git agrees). - Patch-id equivalence — if not reachable, are all of the branch’s commits already
in base by content? Catches rebase and single-commit squash merges (which
rewrite the commit hash, so reachability alone wrongly reports “not merged”). Deletes
with
git branch -D— stmb has vouched for it viagit rev-list --cherry-pick. - Tree equality — if patch-id still reports unique commits, is the feature tip’s
tree byte-identical to base’s (
git diff --quiet <base> <feature>)? Catches a multi-commit squash: several commits collapsed into one changes the combined patch-id so it matches none of the originals, yet the branch introduces nothing not already in base. Identical tree ⇒ zero unique content, so it deletes withgit branch -D. (This is exactly the by-handgit diffthat comes back empty.)
If none hold — the branch has commits and content not in base, or stmb hits a git error — it is fail-closed: stmb refuses to delete, explains why, and tells you to discard it yourself:
'feat-x' has 1 commit(s) not present in main (by content)
=> NOT deleting 'feat-x'. If you're sure, discard it with: git branch -D feat-x
There is deliberately no --force flag. A force flag that’s needed on every
squash/rebase merge trains people to pass it reflexively — which is exactly how real
work gets deleted. By verifying content instead, the common squash/rebase case deletes
automatically, and the only branches stmb won’t touch are ones it couldn’t prove are
merged. For those rare cases (e.g. a squash that resolved conflicts, so patch-ids no
longer match) run plain git branch -D <branch> yourself — discarding unverified work
is a raw-git operation, not fleet cleanup.
Flags
| Flag | Effect |
|---|---|
--base <b> | Base branch to switch to (root only). |
--no-recursive | Only the top repo; don’t recurse into submodules. |
-y, --yes | Skip the confirmation prompt. |
--dry-run | Print the plan without changing anything. |
Example
$ gkit stmb --yes ~/work/repo # no --base → each repo resolves its own base
stmb plan (2 repo(s)):
a-submodule-on-dev [on dev] -> update 'dev' (pull)
. [on feat-x] -> switch to 'main', pull, delete 'feat-x' if merged
.:
+ git switch main
+ git pull --rebase origin main
'feat-x' introduces no changes not already in main — its work is already there (squash/rebase-merged)
=> deleting 'feat-x' (verified merged by content).
+ git branch -D feat-x
+ git remote prune origin
--- logoff ---
/home/you/work/repo main true
Each repo resolves its own base from git config gkit.baseBranch (then origin/HEAD); the
[on <branch>] prefix shows where each repo currently sits. --base <b> overrides the base
for the root only (gkit stmb --base dev); submodules keep their own — so a submodule
that tracks dev is updated on dev, not switched to the root’s main.
--dry-run prints just the plan (the first block) and stops.
gkit fixsub
Fix submodule metadata over an existing tree. Un-detaches each submodule onto its
declared .gitmodules branch, reports (never yanks) a submodule deliberately on a
different branch, and inherits the root repo’s git identity into submodules that lack one —
recursively, idempotently, printing every per-submodule outcome.
Synopsis
gkit fixsub [path] [--dry-run] [-y|--yes] [--no-direnv]
path defaults to the current directory (like logoff/stmb).
Why it exists
git submodule update --init checks out each submodule at the pinned commit in
detached HEAD — a gitlink is a SHA, not a branch, so plain update ignores
.gitmodules branch=. A submodule added after gkit clone (e.g. pinned on a feature
branch) therefore comes up detached and, having missed clone’s built-ins, with no git
identity. clone fixes both at clone time; fixsub applies the same fixes to a tree
that’s already on disk.
What it does
Over every initialized submodule in the tree (recursively):
- Un-detach — and only un-detach. It looks at each submodule’s HEAD and the branch
its
.gitmodulesdeclares (falling back tomain), and prints one outcome per submodule:- detached HEAD → switch onto the declared branch (
git switch, printed); - already on the declared branch → kept (no-op);
- on a different named branch (active feature work) → left as-is, with the
divergence reported —
fixsubnever silently moves a named branch.
- detached HEAD → switch onto the declared branch (
- Identity inherit (set-if-unset): copy the root repo’s local
user.name/user.emailinto each submodule that has no local identity — never clobbering a deliberately-different one. - direnv (unless
--no-direnv):direnv alloweach submodule that has an.envrc, re-trusting it after the un-detach re-points the working tree (trust-only, no evaluation).
Every git command and every per-submodule decision is printed (nothing is swallowed), and
it’s safe to re-run (idempotent). Run it after git submodule update --init brings a new
submodule down detached.
What it deliberately does not do: project-specific config such as core.hooksPath
(put those in the conf’s post-clone and re-apply with gkit stamp), and it
does not yank a submodule off a feature branch.
Why there’s no --force / --switch-all
fixsub only ever moves a submodule that’s in detached HEAD. A submodule sitting on a
named branch is your active work, so it’s reported and left alone. There is deliberately
no flag to force-switch named branches: a flag needed routinely trains reflexive use,
and bulk-switching scatters in-progress submodules off their branches at once (the same
footgun removed from stmb). To move one submodule onto its branch yourself,
that’s a deliberate, per-submodule git switch <branch> inside it.
When a submodule is reported as diverged, you have two real choices (not symmetric — see
logoff and the submodule-pin notes): merge the branch into the
configured one (the normal answer, keeps the team’s tracked branch stable), or update
.gitmodules to track the branch you’re on (a deliberate policy change for everyone).
Caveat — pinned gitlink
The un-detach moves a submodule to its branch tip. If that tip differs from the superproject’s pinned gitlink commit, the superproject will then show the submodule as changed (a gitlink bump). Review and commit that intentionally — it means your submodule pointer was behind its branch.
Flags
| Flag | Effect |
|---|---|
--no-direnv | Don’t direnv allow submodules that have an .envrc. |
--dry-run | Print the plan (per-submodule would-switch/keep/leave + the identity/direnv command) without changing anything. |
-y, --yes | Skip the confirmation prompt. |
Example
$ git submodule update --init --recursive # brings new-child down detached
$ gkit fixsub -y
fixsub plan:
new-child: detached HEAD → switching to 'dev'
+ git switch dev
shared-lib: on 'main' (matches .gitmodules) — kept
my-feature-sub: on 'SCB-554-x'; .gitmodules tracks 'main' — left as-is (merge it into 'main', or update .gitmodules)
+ git submodule foreach --recursive git config --local user.name >/dev/null 2>&1 || git config user.name 'Jane Dev'; …
After fixsub, detached submodules are back on their branch with the inherited identity —
and gkit stamp can re-apply their gkit.baseBranch/gkit.solo. A submodule
on a feature branch is untouched and flagged for you to reconcile.
Design principles
gkit follows a few rules deliberately.
Transparent — no magic
Every side effect is observable and printed. gkit clone prints the exact
git … clone … it runs; stmb/key print their plan and (for mutating actions)
confirm first. You should never have to guess what gkit did.
Stateless
gkit keeps no global state — there is no ~/.gkit registry, no remembered
profiles. The state lives where it belongs and is already version-controlled or
derivable:
- the clone conf file (which repos, where, with what flags);
- each repo’s own metadata —
.gitmodules(submodule branches),.envrc(direnv), andgit config(e.g.gkit.baseBranch).
The one file gkit owns is ~/.ssh/git_users: generated, disposable,
.gitignore-friendly, and rebuilt (deduped) from your inputs — never blind-appended.
Plain tools for plain steps
gkit shells out to git, ssh-keygen, ssh-add, direnv. It reimplements no git
internals, which keeps it small, auditable, and cross-platform — and means its
behavior matches the tools you already know.
--dry-run + confirm on mutation
Anything that changes your system supports --dry-run (print the plan) and prompts
for confirmation before acting (skip with --yes).
The alias convention (key)
One short alias ties an identity together: it is the ssh Host, and the key is
~/.ssh/id_<alias>. Choosing the alias chooses the key name — no separate
--key-name to keep in sync.
Scope
gkit is a set of specific tools (clone-with-hooks, the log-off gate, stmb, ssh keys) — not a general multi-repo task runner. For “run any command across many repos” use a dedicated tool; gkit deliberately doesn’t reinvent that.