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, 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

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"--branch B --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

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 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.
logoffGate: is every repo + submodule committed and pushed?
stmbSwitch 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:

  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

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]

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. Submodules → init + switch each onto its .gitmodules branch (--no-submodule-branch to skip).
  5. .envrcdirenv allow (trust-only, no evaluation; --no-direnv to 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

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.

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 --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 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:

  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.)

  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.

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.
  • -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 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

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 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

  1. Resolve one base branch for the whole tree: --basegit config gkit.baseBranchorigin/HEAD. (Used for every repo, so a submodule’s base is never mis-resolved.)
  2. 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).
  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): checkout basepull --rebase origin base → delete feature → remote prune origin.
  5. 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

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