Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, .envrc trust, 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.
  • stmbswitch 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 clone shows the exact git … clone … it runs.
  • Stateless. No ~/.gkit registry. 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 + ssh Host aliases — 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 run task.

Two things kept it from being the whole solution:

  1. No clone hooks. Our clone needs per-repo work during cloning — toggle direnv (some .envrcs launch a pager like glow and 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 bespoke safeclone shell wrapper around mani sync — which isn’t shippable to other users, defeating the purpose.
  2. 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 Host aliases, 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/key to 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

KeyMeaning
hostssh Host alias. Required.
namespaceorg/group/user; the URL’s owner segment. Optional — a [[repo]] may set its own; every repo must resolve one.
git-flagsraw flags injected before clone (git-level).
clone-flagsraw flags injected after clone, every repo.
pre-clone / post-cloneglobal hook commands (string or list).

[[repo]] keys

KeyMeaning
dirlocal destination; $VAR/${VAR}/~ expanded.
namespaceorg/group/user for this repo; overrides the global namespace. Required only if there’s no global one.
nameremote 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 = Nshallow 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 = trueadd --single-branch. With branch → fetch only that branch; on its own → clone only the remote’s default branch (bare git clone --single-branch).
clone-flagsper-repo raw flags after clone.
pre-clone / post-cloneper-repo hook commands (string or list).

Execution order (per repo)

  1. global pre-clone
  2. repo pre-clone
  3. git <git-flags> clone [--depth N] [--branch B] [--single-branch] --recurse-submodules <clone-flags> <repo clone-flags> <url> <dir>printed, output captured
  4. built-ins (unless disabled): git identity (user.name/user.email on the repo and recursively on every submodule, if resolved — printed), submodule init + branch-switch, direnv allow
  5. global post-clone
  6. 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 (tlbbgit@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 canonicalgit@<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 cloneSSH-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 identitygit config user.name/user.email when resolved from --user-name/--user-email or the prompt (your input, not the conf), applied to the repo and every submodule (submodule foreach --recursive). Printed.
  • submodulesupdate --init --recursive, then each switched onto its .gitmodules branch (no detached HEAD). Disable with --no-submodule-branch.
  • .envrcdirenv allow (trust-only; it does not evaluate the file, so an .envrc that runs e.g. glow ReadMe.md won’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.

CommandSummary
keyGenerate 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):

CommandSummary
initScaffold a starter clone conf in the current directory.
cloneClone the repos in a conf file, with hooks and transparent commands.
stampRe-apply a conf’s post-clone over existing repos (e.g. stamp config on a late-added submodule).
logoffGate: is every repo + submodule committed and pushed?
stmbSwitch to the base branch and delete a finished feature once verified merged (reachability or patch-id content match), recursively.
fixsubFix 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:

  1. ssh-keygen -t ed25519 -C <email> -f ~/.ssh/id_<alias> (skipped if it exists).
  2. Upsert a Host <alias> block into ~/.ssh/git_users (replacing any old block for that alias). The block is OS-aware — macOS includes UseKeychain yes, Linux/Windows omit it.
  3. Check ~/.ssh/config for Include 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/--yes adds it without asking; declining is fine — it tells you to add it yourself).
  4. ssh-add the key.
  5. Copy the public key to the clipboard, ready to paste into your provider. The tool is chosen per OS: macOS pbcopy, Windows clip, Linux wl-copyxclipxsel (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 from HOME (Unix/macOS), falling back to USERPROFILE then HOMEDRIVE+HOMEPATH on Windows.
  • UseKeychain yes and ssh-add --apple-use-keychain are emitted only on macOS; Linux and Windows omit them.
  • ssh-keygen / ssh-add come 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 to repos.toml.
  • --force — overwrite an existing file (otherwise init refuses).

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

  1. Build and print git <git-flags> clone [tokens] --recurse-submodules <clone-flags> <-- flags> <url> <dir>.
  2. Skip if the directory already exists; otherwise clone (output captured).
  3. Git identitygit config user.name/user.email on 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).
  4. gkit.confgit config gkit.conf <absolute conf path> on the repo, so a later gkit stamp run inside the repo (no arg) can find the conf that drives it. (Older clones get this back-filled by gkit stamp --conf.)
  5. Submodules → init + switch each onto its .gitmodules branch (--no-submodule-branch to skip).
  6. .envrcdirenv allow (trust-only, no evaluation; --no-direnv to 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 canonicalgit@<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.gittlbb: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

FlagEffect
--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-branchLeave submodules detached (don’t switch to their branch).
--no-direnvDon’t direnv allow repos that have an .envrc.
--no-insteadofDon’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 paths are repo paths (default: the current directory). Each repo reads its own gkit.conf git-config key — the absolute conf path that clone (or a prior conf-mode run) stamped — to find the conf that drives it, and re-applies that repo’s post-clone. Fails clearly if gkit.conf is unset rather than guessing.
  • conf-mode (--conf): the positional args are conf file(s) (explicit files, same rule as clone). Re-applies each conf to every repo it lists, and back-fills gkit.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

FlagEffect
--confTreat the args as clone conf file(s) (conf-mode) and back-fill gkit.conf. Without it, stamp is repo-mode.
--dry-runPrint the plan without changing anything.
-y, --yesSkip 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 --conf errors, and a directory is not accepted); files may be from different directories. Use a shell glob for “all in here” (same rule as gkit 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:

  1. R1 committedgit status -s is empty.

  2. R2 all-commits-pushed — no local commit is missing from a remote.

  3. R3 branches-have-remote — every local branch has a remote counterpart.

  4. 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) so origin/<branch> is fresh, not a stale local copy.

  5. 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 by gkit.solo:

    • team (default, gkit.solo off) — 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 on main/dev in a shared repo passes.
    • solo (gkit.solo on) — 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. Set git config gkit.solo true (per repo) or git config --global gkit.solo true (your default).

    The base is resolved from --base-branchgit config gkit.baseBranch → a remote-tracking branch (origin/main, else origin/master); main/master are 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. Set git config gkit.baseBranch <b> to fix.

    The active rule is surfaced on a branch-rule line at -vv (always, team or solo); the bare -v scan and the default output print nothing about it.

  6. 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-right against gkit.baseBranch / origin/main/master), in either form:

    • diverged — also ahead of base (you have unique commits): history has split, rebase onto base.
    • merged / stalenot 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-fetch R6 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 allowDiverged audits 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.

  7. 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 .gitmodules branch> — 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 fresh git submodule update --init can’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 branch declared in .gitmodules fails (there’s no tracked branch to certify the pin against — declare one with git submodule set-branch), as does one whose origin/<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 fresh origin/<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.
  • -vv is the “why did it fail” view. Per-failure R<n> reason lines live only at -vv; the bare -v scan and the default output stay pure pass/fail. The single exception is the gkit.allowDiverged marker, 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

FlagEffect
-vPer-check breakdown (greppable). Repeat (-vv) to add R<n> rule ids and a reason line for each failing check.
-eExplain (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-fetchDon’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

  1. Resolve each repo’s own base: --base applies to the root only; every submodule resolves its own git config gkit.baseBranchorigin/HEAD (the same per-repo resolution logoff uses). This matters for a tree where a submodule tracks a different integration branch (e.g. one on dev while the root is on main): 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).
  2. 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).
  3. Print the plan. With --dry-run, stop here. Otherwise confirm (skip with -y).
  4. 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, not checkout, so a worktree path named like the base — e.g. a main/ dir — can’t make the branch switch ambiguous.)
  5. 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:

  1. Reachability — is the feature tip in base’s history? Catches merge-commit and fast-forward merges. Deletes with git branch -d (git agrees).
  2. 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 via git rev-list --cherry-pick.
  3. 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 with git branch -D. (This is exactly the by-hand git diff that 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

FlagEffect
--base <b>Base branch to switch to (root only).
--no-recursiveOnly the top repo; don’t recurse into submodules.
-y, --yesSkip the confirmation prompt.
--dry-runPrint 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):

  1. Un-detach — and only un-detach. It looks at each submodule’s HEAD and the branch its .gitmodules declares (falling back to main), and prints one outcome per submodule:
    • detached HEADswitch onto the declared branch (git switch, printed);
    • already on the declared branchkept (no-op);
    • on a different named branch (active feature work) → left as-is, with the divergence reported — fixsub never silently moves a named branch.
  2. Identity inherit (set-if-unset): copy the root repo’s local user.name / user.email into each submodule that has no local identity — never clobbering a deliberately-different one.
  3. direnv (unless --no-direnv): direnv allow each 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).

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

FlagEffect
--no-direnvDon’t direnv allow submodules that have an .envrc.
--dry-runPrint the plan (per-submodule would-switch/keep/leave + the identity/direnv command) without changing anything.
-y, --yesSkip 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), and git 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.