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, safe-deletes the feature branch
(refuses if unmerged unless you pass --force), recursively 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" | --branch B --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
Hooks run via sh -c, 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. A hook that exits
non-zero fails that repo.
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.
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. |
logoff | Gate: is every repo + submodule committed and pushed? |
stmb | Switch to the base branch and safe-delete a finished feature, recursively. |
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
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]
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). - Submodules → init + switch each onto its
.gitmodulesbranch (--no-submodule-branchto skip). .envrc→direnv allow(trust-only, no evaluation;--no-direnvto skip).
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. |
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 --single-branch --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 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 six checks
For each repo and submodule, all must pass. Each rule has a stable id (R1..R6),
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.) -
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.
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. -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 six 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 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..R6, 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..6) 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 submodules first (faster / offline). |
--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 safely deletes the feature, recursively across
submodules, then runs a verifying log-off check.
Synopsis
gkit stmb [path] [--base <b>] [--no-recursive] [--force] [-y|--yes] [--dry-run]
What it does
- Resolve one base branch for the whole tree:
--base→git config gkit.baseBranch→origin/HEAD. (Used for every repo, so a submodule’s base is never mis-resolved.) - Walk the repo + submodules (post-order) and build a plan per repo:
- on a feature branch → switch to base, pull, delete the feature;
- already on base → switch/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):checkout base→pull --rebase origin base→ delete feature →remote prune origin. - Automatically run
logoff(recursive) to confirm everything is clean — after a blank line.
Safe deletion
The feature branch is deleted with git branch -d, which refuses to delete an
unmerged branch — so you can’t silently lose unpushed work. Pass --force to use
-D (and accept the loss). This is the key improvement over a blind git branch -D.
Flags
| Flag | Effect |
|---|---|
--base <b> | Base branch to switch to (root only). |
--no-recursive | Only the top repo; don’t recurse into submodules. |
--force | Force-delete an unmerged feature branch. |
-y, --yes | Skip the confirmation prompt. |
--dry-run | Print the plan without changing anything. |
Example
$ gkit stmb --base dev --yes ~/work/repo
stmb plan (1 repo(s)):
. -> switch to 'dev', pull, delete 'feat-x'
.:
+ git checkout dev
+ git pull --rebase origin dev
+ git branch -d feat-x
+ git remote prune origin
--- logoff ---
/home/you/work/repo dev true
--dry-run prints just the plan (the first block) and stops.
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.