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

RSMultiGit - Rust Multi-Git

A fast CLI tool for managing multiple git repositories at once, written in Rust. RSMultiGit is a rewrite of pymultigit with native performance.

Features

  • Configured repository list — the repos to operate on come from glob patterns in ~/.config/rsmultigit/config.toml, so every command sees the same fleet no matter where it is run from
  • Native git inspection — status, dirty, untracked and sync checks use libgit2 in-process, avoiding a git subprocess per repo
  • Bulk operations — pull, push, fetch, diff, grep, clean, commit, stash, reset, and branch inspection across all repos
  • Consistency checks — check same verifies that shared files are byte-identical across the fleet, check exists verifies that required files are present, and interactive --diff, --copy and --fix-missing modes repair drift
  • Build orchestration — run make, rsconstruct, cargo, or bootstrap across all projects, with each repo’s .venv activated automatically
  • Tooling passthrough — uv lock|sync|build|publish, cargo build|clippy|fmt|test|update|..., npm install|ci|update|audit|..., cargo release, and gh cleanup on the repos where they apply
  • Parallel execution — -j N runs repos concurrently while output stays in repo order
  • Flexible output — verbose, terse, headerless, and inverted-selection modes
  • Error control — stop on the first error or continue through all projects with --no-stop

Philosophy

RSMultiGit does one thing well: it runs a single operation across a fixed set of git repositories and prints only what needs attention. The repository list lives in one config file, so a fleet of hundreds of repos is one command away from anywhere.

Installation

Install from crates.io

cargo install rsmultigit

This downloads, compiles, and installs the latest published version into ~/.cargo/bin/.

Pre-built binaries

Every release on GitHub ships a statically named binary per platform:

PlatformAsset
Linux x86_64rsmultigit-linux-x86_64
Linux aarch64rsmultigit-linux-aarch64
macOS x86_64rsmultigit-macos-x86_64
macOS aarch64rsmultigit-macos-aarch64

With the GitHub CLI (downloads from the latest release when no tag is given):

gh release download --repo veltzer/rsmultigit --pattern rsmultigit-linux-x86_64 --output rsmultigit --clobber
chmod +x rsmultigit
sudo mv rsmultigit /usr/local/bin/

Or with curl, using GitHub’s stable latest-release URL:

curl -Lo rsmultigit https://github.com/veltzer/rsmultigit/releases/latest/download/rsmultigit-linux-x86_64
chmod +x rsmultigit
sudo mv rsmultigit /usr/local/bin/

Replace the asset name with the one for your platform.

Build from source

git clone https://github.com/veltzer/rsmultigit.git
cd rsmultigit
cargo build --release

The binary will be at target/release/rsmultigit. The release profile in Cargo.toml already strips symbols and enables LTO, so no extra flags are needed.

To install it system-wide:

sudo cp target/release/rsmultigit /usr/local/bin/

Dependencies

RSMultiGit links against libgit2 (via the git2 crate) for native git repo inspection. libgit2 and OpenSSL are compiled from source during the build, so no system packages are required beyond a C compiler, CMake and Perl.

At run time, some commands shell out to external tools that must be on PATH: git for network and formatting commands, gh for git branch github and gh clean-all, uv for the uv commands, cargo and cargo-release for the Rust commands, and whichever build tool a build method names.

First run

rsmultigit needs a config file before it will do anything. Create one interactively:

rsmultigit setup interactive

or from the built-in example, then edit the repos list:

mkdir -p ~/.config/rsmultigit
rsmultigit setup config-sample > ~/.config/rsmultigit/config.toml

See Getting Started for what to run next.

Getting Started

Create the config file

Every rsmultigit command reads ~/.config/rsmultigit/config.toml to learn which repositories to operate on. There is no --config flag and no directory scanning: the file is the single source of truth.

The quickest start is the interactive setup, which asks where your repositories are (with tab completion), checks that git repos are really there, and lets you pick the default build tool from a menu:

rsmultigit setup interactive

Or bootstrap the file from the fully commented built-in example and edit the repos list by hand:

mkdir -p ~/.config/rsmultigit
rsmultigit setup config-sample > ~/.config/rsmultigit/config.toml

The minimum useful config is one glob:

repos = ["~/git/*"]

Patterns are shell-expanded, and matches that are not git repositories are ignored. Check what was picked up:

rsmultigit list repos

Checking repository status

See which repos need attention, one line per repo:

rsmultigit status        # shortcut for `rsmultigit git status`
# [/home/me/git/myrepo]
# 2 modified, 1 untracked, ahead 1

Clean, in-sync repos print nothing. Pass --verbose for the full git status -s per-file output instead of the summary.

Count repos with uncommitted changes, untracked files, or unpushed commits:

rsmultigit git count dirty
rsmultigit git count untracked
rsmultigit git count unsynchronized    # repos ahead of or behind their upstream

Each prints the matching repos followed by a matched/total line.

Pulling all repos

rsmultigit git pull
rsmultigit git pull --quiet
rsmultigit -j 8 git pull           # eight repos at a time

Searching across repos

rsmultigit git grep "TODO"
rsmultigit git grep -l "TODO"      # filenames only

Running any command

A single argument is run through the shell; several arguments are executed directly:

rsmultigit run "git log -1 --oneline"
rsmultigit run cargo check

Repos with a .venv get it activated first, so rsmultigit run pytest runs each repo’s own pytest.

Building all projects

rsmultigit build make
rsmultigit build rsconstruct       # only repos with rsconstruct.toml
rsmultigit build                   # uses default_build_method from the config

Keeping shared files identical

Declare the files that must not drift between repos, then check them:

[[check-same]]
name = "gitignore"
select = "*"
path = ".gitignore"
rsmultigit check same              # report every rule
rsmultigit check same --diff       # show what differs
rsmultigit check same --copy       # interactively copy one version over the others

See Configuration for [[check-same]] and [[check-exists]] rules.

Error handling

By default, rsmultigit stops on the first error. To report errors and keep going:

rsmultigit --no-stop git pull

Command Reference

Global Flags

These flags are accepted by every subcommand and may appear before or after the subcommand name.

FlagDescription
-v, --verbosePrint every repo, even when no action is taken (adds the header for skipped repos; git status switches to per-file output)
--terseMinimal, machine-readable output: repo names only for data commands, just the N/total line for count commands, failing rule names only for the check commands
--no-headerSuppress the [repo] header line printed before per-repo output
--no-outputSuppress command output, keeping only the [repo] headers. Action commands run their subprocesses with output captured and discarded; a failing repo’s output is still attached to its error
--print-notInvert selection: print the repos that do NOT match
--no-stopOn error, print error in <repo>: ... to stderr and continue with the next repo instead of stopping. The run still exits non-zero (N of M repos failed) if any repo failed
--short-circuitStop at the first failing rule. Off by default; honoured by check same and check exists
-j, --jobs <N>Run up to N repos in parallel (default 1; 0 means one worker per CPU). Output is still printed in repo order
--venvActivate each repo’s local .venv (prepend .venv/bin to PATH, set VIRTUAL_ENV) before running tool subprocesses. On by default; honoured by run, build, cargo and npm; not by uv
--no-venvTurn --venv off: run tool subprocesses with the ambient environment

Example:

rsmultigit --terse git count dirty               # Just print "3/50"
rsmultigit --no-stop git pull                # Pull all, report failures, keep going
rsmultigit -j 8 git fetch                    # Fetch eight repos at a time
rsmultigit --short-circuit check same        # Stop at the first broken rule

Output conventions

Most commands print a [repo] header followed by that repo’s output, and print nothing for repos where nothing happened (no data, nothing to do, or the repo was skipped by a precondition such as a missing Cargo.toml). --verbose prints the header for those repos too, --no-header drops the header everywhere, and --terse reduces the output to bare repo paths.

Commands that take an operation word (git count dirty, gh clean-all, cargo clippy, …) print their help, with every operation and its description, when the word is left out - rsmultigit gh is the quickest way to see what gh can do.

Git Commands

rsmultigit git <command>

Everything that inspects or operates on the repositories as git repositories. rsmultigit git alone lists the subcommands. They come in two kinds: the reports below (counts and per-repo data, computed with libgit2) and the operations after them, each of which runs the git command of the same name in every repo.

Counts

rsmultigit git count <what> tests each repo with libgit2 (no subprocess), prints the path of every matching repo, then a final matched/total line.

rsmultigit git count dirty

Repos with changes in the working tree or index: modified, deleted, renamed, type-changed, newly staged, or conflicted (mid-merge) files. Untracked files do not count.

rsmultigit git count dirty
rsmultigit --terse git count dirty               # Only the "N/total" line
rsmultigit --print-not git count dirty           # Clean repos instead

rsmultigit git count untracked

Repos that have untracked files.

rsmultigit git count unsynchronized

Repos that are not synchronized with their upstream: ahead of or behind it. The upstream is the branch’s configured tracking branch (as set by clone or push -u); when none is configured, origin/<branch> is used. A repo with no upstream at all has nothing to be out of sync with and is not counted, which matches how git status and git push treat it.

rsmultigit git count unsynchronized
rsmultigit --print-not git count unsynchronized    # Repos that ARE in sync

rsmultigit git count ahead / rsmultigit git count behind

The two halves of unsynchronized: repos with local commits not yet pushed (ahead) or upstream commits not yet pulled (behind), against the same upstream. The same tests as list ahead / list behind.

Reports

These commands compute a value per repo and print it under the [repo] header. Repos with nothing to report are skipped unless --verbose is set. With --terse, only the repo path is printed.

rsmultigit git status / rsmultigit status

One-line summary of every repo that needs attention: counts of conflicted, staged, modified, deleted and untracked files, plus ahead N / behind N when the branch has diverged from its upstream (see git count unsynchronized for how the upstream is chosen). Clean, in-sync repos are skipped. Computed with libgit2. rsmultigit status is a top-level shortcut for the same command.

rsmultigit status                        # Same as `rsmultigit git status`
# [/home/me/git/myrepo]
# 2 modified, 1 untracked, ahead 1
rsmultigit --verbose git status              # Full per-file `git status -s` instead

rsmultigit git dirty

git diff --stat for repos with unstaged modifications; falls back to git diff --cached --stat when only staged changes exist.

rsmultigit git age

The age of the last commit as a relative date (3 days ago).

rsmultigit git authors

git shortlog -sne HEAD: authors with commit counts and emails.

rsmultigit git size

Size of the .git directory per repo, in human-readable units. Symlinks are not followed. A linked worktree, whose .git is only a pointer file, has no git directory of its own and is skipped.

rsmultigit git last-tag

The most recent tag reachable from HEAD (git describe --tags --abbrev=0). Repos without tags are skipped.

Operations

Action commands print the [repo] header for repos where the action ran; with --verbose it is printed for skipped repos too.

rsmultigit git config <KEY>

A git config value per repo. Repos where the key is unset are skipped.

rsmultigit git config user.email
rsmultigit git config remote.origin.url

rsmultigit git pull [--quiet]

git pull in every repo. --quiet is forwarded to git.

rsmultigit git push

git push in every repo that is ahead of its upstream (the configured tracking branch, else origin/<branch>). Repos with nothing to push, or with neither, are skipped. A branch measured against origin/<branch> for want of a configured upstream is pushed there explicitly (git push origin HEAD), since plain git push refuses a branch without an upstream.

rsmultigit git fetch

git fetch in every repo.

rsmultigit git commit -m <MESSAGE>

git add -A then git commit -m <MESSAGE> in every repo that has changes. Clean repos are skipped.

rsmultigit git checkout <BRANCH>

git checkout <BRANCH> in every repo.

rsmultigit git stash push / rsmultigit git stash pop

push runs git stash push -m "rsmultigit stash" in every repo that has changes to tracked files; clean repos are skipped. pop restores the most recent stash carrying that message, wherever it sits in the stash list, and skips repos that have none. A stash made by hand is never popped, so push followed by pop round-trips exactly what push saved.

These three throw work away, so each one skips the repos where it would change nothing (no [repo] header unless --verbose), and each takes --dry-run, which lists per repo what would go and changes nothing. Run the dry run first: across a fleet, one command can discard a lot.

rsmultigit git reset hard|mixed [--dry-run]

git reset --<mode> HEAD in every repo that has something to reset.

WhatRunsSelects repos with--dry-run shows
hardgit reset --hard HEADany change to a tracked file, staged or notgit status --short --untracked-files=no
mixedgit reset --mixed HEADstaged changesgit diff --cached --name-status

There is no soft: git reset --soft HEAD changes nothing, and a soft reset to an older commit is not a fleet-wide operation.

rsmultigit git reset hard --dry-run   # What would be discarded, per repo
rsmultigit git reset hard             # Discard all changes to tracked files
rsmultigit git reset mixed            # Unstage changes

rsmultigit git restore [--dry-run]

git restore . in every repo with unstaged changes to tracked files: discards them, leaving staged changes and untracked files alone. --dry-run shows git diff --name-status instead. (Formerly clean git; it removes nothing untracked, so it was never a clean.)

rsmultigit git clean <what> [--dry-run]

WhatRunsNotes
hardgit clean -ffxdRemoves untracked and ignored files (.venv/, target/, .env, …)
softgit clean -fdRemoves untracked files only

A repo is selected when git’s own dry run (git clean -nxd / -nd) lists something, and --dry-run prints exactly that list.

cargo clean lives under the cargo command: rsmultigit cargo clean.

rsmultigit git diff

git diff in every repo.

rsmultigit git log [--count N]

git log --oneline -n N in every repo (default 10).

rsmultigit git log
rsmultigit git log --count 5

rsmultigit git blame <FILE>

git blame <FILE> in every repo that contains the file; others are skipped.

rsmultigit git grep [-l|--files] <REGEXP>

git grep -n -e <REGEXP> in every repo, so a pattern starting with - is searched for rather than read as an option. Each output line is prefixed with the repo name; -l prints matching filenames only. Repos with no match print nothing; --terse lists just the repos that matched and --print-not the ones that did not.

rsmultigit git grep "TODO"
rsmultigit git grep -l "TODO"

rsmultigit git branch local|remote|github

git branch, git branch -r, or the GitHub default branch via gh repo view (requires the gh CLI).

rsmultigit git tag local|remote|has-local|has-remote

local and remote list tags (git tag, git ls-remote --tags origin). has-local and has-remote behave like count commands: they print the repos that have any tags, then a matched/total line.

rsmultigit git remote

git remote -v in every repo.

rsmultigit git remote-prune

git remote prune origin in every repo: drops remote-tracking branches whose upstream branch is gone. Not called prune, since git prune is git’s unrelated object pruning.

rsmultigit git gc

git gc in every repo.

rsmultigit git submodule-update

git submodule update --init --recursive in every repo.

Repo Lists

The list commands print the absolute path of every repo they select, one per line, with no header and no summary line, so the output feeds straight into xargs or a shell loop. --print-not inverts the selection of every filter (list dirty --print-not prints the clean repos); -j inspects repos in parallel without changing the output order. A bare rsmultigit list lists the choices.

for repo in $(rsmultigit list dirty); do git -C "$repo" diff --stat; done
rsmultigit list ahead                  # the repos `git push` would send something from
rsmultigit list dirty --print-not      # the clean repos

rsmultigit list repos

Every configured repo. --verbose adds the [repo] header for each entry.

rsmultigit list dirty

Repos with modified, deleted, staged or conflicted files (untracked files do not count; see list untracked). The same test as git count dirty.

rsmultigit list untracked

Repos with untracked files. The same test as git count untracked.

rsmultigit list unsynchronized

Repos whose checked-out branch is ahead of or behind its upstream. A repo with no upstream (or a detached HEAD) has nothing to be out of sync with and is never listed. The same test as git count unsynchronized.

rsmultigit list ahead

Repos with local commits not yet pushed to the upstream.

rsmultigit list behind

Repos whose upstream has commits not yet pulled. This compares against the remote-tracking refs as they are; run rsmultigit git fetch first for a current answer.

Running Arbitrary Commands

rsmultigit run <COMMAND...> (alias: rsmultigit exec)

Run an arbitrary command in every repo. A single argument is run through the shell (sh -c), so pipes, redirects and quoting work; several arguments are executed directly without a shell.

By default (--venv), a repo that has a local .venv gets it activated first, so the command and anything it spawns resolve from the repo’s own venv. Pass --no-venv to run with the ambient environment everywhere.

rsmultigit run touch marker.txt
rsmultigit run "echo hello > greeting.txt"
rsmultigit exec cargo check
rsmultigit run pytest                  # each repo's own pytest, via its .venv
rsmultigit --no-venv run which python  # ambient python everywhere

Consistency Checks

The check subcommands evaluate the [[check-same]], [[check-exists]] and [[check-submodules]] rules in the config file; rsmultigit check alone lists them. See Configuration for the rule fields. Unlike every other command, output is organised by rule, not by repo: each rule prints a header line followed by its verdict. The header is [kind rule-name], the kind being the stanza the rule came from and the command that runs it (check-same, check-exists, check-submodules), followed by the rule’s properties as key=value pairs (path and select always; exclude, marker, marker_absent when set; enabled=false and must_have=true only when they differ from the default), so a failure can be read against its rule without opening the config. --no-header drops it.

rsmultigit check same

For each enabled [[check-same]] rule, hash path in every selected repo and group the repos by content. A rule passes when there is at most one group and, for must_have = true rules, no selected repo lacks the file.

[check-same gitignore] path=.gitignore select=*
ok (212 files)
[check-same workflow-build-yml] path=.github/workflows/build.yml select=* marker_absent=.noci must_have=true
34 files, 2 groups (1 missing, 3 skipped)
  group A (31 files):
    /home/me/git/alpha/.github/workflows/build.yml
    ...
  group B (3 files):
    /home/me/git/beta/.github/workflows/build.yml
    ...
  missing in:
    /home/me/git/gamma

Groups are ordered largest first, so group A is the presumed canonical version. missing counts must_have violations; skipped counts repos that lack the file under a rule without must_have.

Exit code is 1 when any rule fails, 0 otherwise. A rule that matches no files at all is reported as no files matched and, after all rules have run, turns into a hard error on stderr naming every such rule, since an empty rule almost always means a stale select or path.

FlagEffect
--checks <NAME>...Run only these rules, in the given order. Named rules run even if enabled = false. An unknown name is an error
--checks-re <REGEX>...Also run every rule whose name matches a regex (unanchored). A regex matching nothing is an error. Combines with --checks
--only-failedDrop the ok (N files) lines; print failing rules only
--allow-emptyLet a rule that matches no files pass as ok (0 files) instead of failing
--diffAfter reporting a failing rule, print a unified diff between representatives of the differing groups. With exactly two groups this is automatic; with more, it prompts for the pair and offers to diff another
--copyAfter reporting a failing rule, prompt for a “from” group and a “to” group, confirm, then overwrite every file in the “to” group with the “from” representative (preserving each destination’s file mode). Exit code is 0 even when rules still differ; a file that could not be written makes it non-zero
--fix-missingFor rules with must_have violations, prompt for a group to seed from, confirm, then create the file in each violating repo (creating parent directories as needed). Exit code is 0 even when rules still differ; a file that could not be created makes it non-zero

The interactive prompts accept a group letter (A, B, …), s to skip the rule, or q to quit the whole run; confirmations default to no. EOF on stdin is treated as quit, so the command never hangs when stdin is closed.

--terse prints only the names of failing rules, one per line, and implies --only-failed. --short-circuit stops after the first failing rule.

rsmultigit check same                         # Report every rule
rsmultigit --terse --no-stop check same       # Names of broken rules only, for scripts
rsmultigit check same --checks gitignore editorconfig
rsmultigit check same --checks-re '^rs-'      # Every rule whose name starts with rs-
rsmultigit check same --diff --only-failed    # Show what differs
rsmultigit check same --copy                  # Fix drift interactively
rsmultigit check same --fix-missing           # Create files missing from must_have repos

rsmultigit check exists

For each enabled [[check-exists]] rule, assert that every selected repo contains path. Content is never compared, which makes this the right rule type for files that must exist but legitimately differ per repo (a README, say).

[check-exists readme-present] path=README.md select=*
ok (212 repos)
[check-exists license-present] path=LICENSE select=*
212 repos, 2 missing LICENSE
  missing in:
    /home/me/git/alpha
    /home/me/git/beta

Exit code is 1 when any rule fails. A rule that selects no repos at all fails with no repos selected and becomes a hard error at the end, unless --allow-empty is passed.

--checks, --checks-re, --only-failed, --allow-empty, --terse and --short-circuit behave exactly as for check same. There are no interactive modes.

rsmultigit check submodules

For each enabled [[check-submodules]] rule, find the submodule the rule’s url names in every selected repo and assert that all of them have it checked out at the same commit. A submodule is the fleet’s way of sharing a repo, and the pin is the part that drifts when one consumer bumps it and the others do not. Only submodules with a rule are checked: one without a rule is free to differ per repo, by decision rather than by accident.

The URL is matched after normalisation on both sides, so the rule may say github.com/owner/repo while the repos added it over ssh or https, with or without .git. The commit compared is the one checked out in the submodule’s working directory, the same view check same takes of ordinary files: what is on disk and what a build uses. A submodule that is not checked out falls back to the commit recorded in the superproject’s index, then in HEAD. A selected repo that does not carry the submodule is skipped, not failed: the rule constrains the pin, not which repos use the submodule.

[check-submodules shared-themes] url=github.com/veltzer/shared-themes select=*
ok (4 repos @ aa5e688, 208 skipped)
[check-submodules shared-tags] url=github.com/veltzer/shared-tags select=teaching-*
3 repos, 2 commits
  1f2e3d4 (2 repos):
    /home/me/git/teaching-slides (shared/shared-tags)
    /home/me/git/teaching-syllabi (shared/shared-tags)
  9a8b7c6 (1 repo):
    /home/me/git/teaching-animations (shared/shared-tags)

Groups are ordered largest first. Exit code is 1 when any rule’s submodule is pinned to more than one commit, or to none. A rule whose submodule no selected repo carries fails with no repos have this submodule and becomes a hard error at the end, unless --allow-empty is passed, since finding it nowhere almost always means a stale select or a misspelled url.

--checks, --checks-re, --only-failed, --allow-empty, --terse and --short-circuit behave exactly as for check same. There are no interactive modes.

rsmultigit check all [--only-failed] [--allow-empty]

Run every enabled [[check-same]] rule, then every enabled [[check-exists]] rule, then every enabled [[check-submodules]] rule. All three always run, so a failure in one never hides the state of the others. Exit code is non-zero if any of them fails.

rsmultigit check all --only-failed

rsmultigit check list [same|exists|submodules]

Print the name of every [[check-same]] rule (the default), every [[check-exists]] rule or every [[check-submodules]] rule, one per line, including disabled ones. The kind is named after the subcommand that runs it. The bash and zsh completion scripts call this to complete check same --checks <TAB>, check exists --checks <TAB> and check submodules --checks <TAB>.

Build Commands

rsmultigit build [<method>]

Run a build tool in each project directory. Projects with a .disable file in their root are always skipped.

By default (the global --venv flag), a project that has a local .venv gets it activated before the build tool runs, so the tools the build spawns (pytest, mypy, ruff, …) resolve from the repo’s own venv, including the build tool itself when the venv provides it. Projects without a .venv build with the ambient environment. Pass --no-venv to disable the activation everywhere.

MethodWhat runsWhich projects
bootstrappython bootstrap.pyall
makemakeall
rsconstructrsconstruct --quiet buildthose with rsconstruct.toml
cargocargo build --profile dev then cargo build --releasethose with Cargo.toml

Publishing is not a build method: see rsmultigit cargo publish below.

Default build method

The method is optional when the config file sets default_build_method:

# ~/.config/rsmultigit/config.toml
default_build_method = "rsconstruct"

With that in place a bare rsmultigit build means rsmultigit build rsconstruct. The key accepts exactly the spellings the command line does (rsconstruct, not Rsconstruct), and a method given on the command line always wins over the config file. Without the key, a bare rsmultigit build is an error that says how to fix it.

rsmultigit build rsconstruct
rsmultigit build                       # same, given default_build_method = "rsconstruct"
rsmultigit build make                  # explicit method overrides the default
rsmultigit --no-venv build rsconstruct

Cargo Commands

These commands operate only on repositories that have a Cargo.toml file and no .disable file. Other repositories are skipped.

rsmultigit cargo <what> [--release | --profile <name>] [--check]

Runs one cargo operation in each Rust project. All of them honour --venv.

whatRunsNotes
buildcargo build --profile dev then cargo build --releaseEvery profile, like cargo_build.sh; --release or --profile builds just that one
checkcargo checkType-check only, no artifacts
clippycargo clippy --all-targets -- -D warningsSame invocation as the fleet’s ci.yml: warnings fail the run
fmtcargo fmt --all--check only verifies and fails on unformatted code
testcargo testcargo’s built-in runner
nextestcargo nextest runRequires cargo-nextest
doccargo doc --no-depsAPI docs for the crate itself
denycargo deny checkRequires cargo-deny
fetchcargo fetchDownload dependencies without building
updatecargo updateRefreshes Cargo.lock
cleancargo cleanRemoves the target directory
publishcargo publishUploads the crate to crates.io; release is the full release flow
releasecargo release <type> --execute --no-confirmThe full release flow, see below; --type picks the bump

--release appends --release and --profile <name> appends --profile <name> (dev, release, or any custom profile the crate’s Cargo.toml defines). Both are accepted by the operations that compile under a profile: build, check, clippy, test, nextest and doc. With neither, build compiles every profile the fleet ships, dev then release, and the other operations keep cargo’s own default. --check is accepted by fmt only, and --type by release only. A flag on any other operation is an error, and --release, --profile and --check are mutually exclusive.

rsmultigit cargo build                   # Every Rust repo, dev and release
rsmultigit cargo build --profile dev     # Only the default (dev) profile
rsmultigit cargo build --release         # Only release
rsmultigit cargo fmt --check             # Is every Rust repo formatted?
rsmultigit cargo clippy                  # Lint the fleet the way CI does
rsmultigit -j 0 cargo nextest --release  # Release-mode tests, all cores
rsmultigit cargo update                  # Refresh every Cargo.lock

rsmultigit cargo release [--type <patch|minor|major>]

Release a new version of each Rust project by running cargo release <type> --execute --no-confirm, which bumps the version in Cargo.toml, commits, tags, pushes, and publishes to crates.io (the rest of the policy is the repo’s release.toml). The default release type is patch.

A repo is released only when its root Cargo.toml declares a [package] that cargo would publish. This is cargo’s own vocabulary, so no rsmultigit-specific marker is needed:

  • A virtual workspace (a root [workspace] listing members, with no [package]) has nothing to version or publish and is skipped. This is what a repo of examples and exercises looks like.
  • A package with publish = false (or publish = []) is a scratch crate that was never meant for crates.io; it is skipped here just as cargo publish itself would refuse it. publish.workspace = true defers to [workspace.package].publish, as in cargo.
  • A repo with no Cargo.toml is not a rust project and is skipped.

Skipped repos are listed under --verbose, like every other skip.

Before the first repo is touched, two things are checked once for the whole run:

  • cargo-release must be on PATH. It is a separate crate, not part of the toolchain, so a rebuilt CARGO_HOME loses it; the error names the fix (cargo install cargo-release) instead of cargo’s own “no such command: release”.
  • The crates.io token is fetched. If CARGO_REGISTRY_TOKEN is already set in the environment it is used as is. Otherwise the first line of pass show <entry> is taken, where <entry> is the config file’s crates_io_pass_entry (default keys/crates.io). The token is handed to every cargo release process through its environment only, so it never lands in ~/.cargo/credentials or any other file.
rsmultigit cargo release                 # Patch release (default)
rsmultigit cargo release --type minor    # Minor release
rsmultigit cargo release --type major    # Major release

uv Commands

Run uv operations across every repository that has a pyproject.toml at its root; other repositories are skipped.

rsmultigit uv lock [--upgrade|--check]

Run uv lock in each Python project, re-resolving uv.lock from pyproject.toml. Without --upgrade, versions that are already locked are kept and only added or removed dependencies change; with --upgrade, locked versions may move forward to the newest allowed releases.

With --check, nothing is written: uv lock --check only asserts that the lockfile is up to date with pyproject.toml, and a stale lockfile is an error (combine with the global --no-stop to survey all projects instead of stopping at the first stale one). --check and --upgrade are mutually exclusive.

rsmultigit uv lock                    # Bring every lockfile in sync with pyproject
rsmultigit uv lock --upgrade          # Deliberately upgrade all locked versions
rsmultigit uv lock --check --no-stop  # Report which lockfiles are stale

rsmultigit uv sync

Run uv sync in each Python project, syncing its environment from the lockfile. Projects without a .venv get one created.

uv selects its own target environment from the project directory, so rsmultigit runs it with VIRTUAL_ENV (and UV_PROJECT_ENVIRONMENT) unset and leaves the choice to uv. This matters when you invoke rsmultigit from an activated shell: the inherited VIRTUAL_ENV names your venv, never the repo being synced, and passing it through makes uv sync warn (does not match the project environment path, after which the value is ignored) while the uv pip interface would silently target the wrong environment. The global --venv/--no-venv flag therefore does not apply to uv; the other uv operations behave the same way.

rsmultigit uv build

Run uv build in each Python project, producing the sdist and wheel under dist/.

rsmultigit uv publish

Run uv publish in each Python project, uploading whatever uv build left in dist/ to the package index. Credentials and the target index come from uv’s own configuration (UV_PUBLISH_TOKEN, trusted publishing, keyring, [[tool.uv.index]]); rsmultigit passes nothing through.

rsmultigit uv build                   # Build every distribution
rsmultigit uv publish                 # ... then upload them

npm Commands

Run npm operations across every repository that has a package.json at its root; other repositories are skipped. All of them honour --venv, so package.json scripts that shell out to tooling living in the repo’s .venv find it on PATH.

rsmultigit npm <what> [--fix]

whatRunsNotes
installnpm installInstalls from package.json, writing package-lock.json
cinpm ciClean install of exactly what package-lock.json says; fails if it disagrees with package.json
updatenpm updateMoves dependencies to the newest versions their declared ranges allow and rewrites package-lock.json
auditnpm auditReports known vulnerabilities; exits non-zero when any are found. --fix runs npm audit fix, which rewrites package.json and package-lock.json
outdatednpm outdatedLists dependencies with newer releases; exits non-zero when any are outdated
testnpm testRuns the project’s test script
publishnpm publishPublishes the package to the registry

--fix is accepted by audit only; on any other operation it is an error. audit and outdated report through their exit status, so combine them with the global --no-stop to survey the whole fleet instead of stopping at the first repo with findings.

rsmultigit npm update                    # Refresh every package-lock.json within ranges
rsmultigit npm ci                        # Reproduce every lockfile exactly
rsmultigit --no-stop npm outdated        # Which repos have newer releases available?
rsmultigit npm audit --fix               # Apply the automatic vulnerability fixes

GitHub Commands

These commands talk to GitHub through the gh CLI (which must be installed and authenticated) and operate only on repositories that have a remote whose URL contains github.com. Other repositories are skipped.

rsmultigit gh clean-all [--keep <N>]

Clean up GitHub deployments, releases, and workflow runs for each repository. Keeps only the --keep (default 4) most recent non-failed of each and deletes the rest; failed deployments (latest status failure/error) and failed workflow runs (failure, cancelled, timed_out, startup_failure, action_required) are always deleted, even if recent.

Note that this deletes data on GitHub permanently: releases, deployment history, and workflow run logs are gone once removed.

rsmultigit gh clean-all              # Keep the 4 most recent of each
rsmultigit gh clean-all --keep 10    # Keep the 10 most recent of each

rsmultigit gh artifacts

List the assets attached to each repository’s latest GitHub release as a NAME / SIZE / DOWNLOADS table. A data command: repositories without a release, or whose latest release has no assets, print nothing.

rsmultigit gh artifacts              # Who is downloading which binaries?

rsmultigit gh last-workflow-state

Print the conclusion of each repository’s most recent workflow run (success, failure, cancelled, …). A run that has not finished yet shows its status instead (in_progress, queued). A data command: repositories with no workflow runs print nothing.

rsmultigit gh last-workflow-state                    # One line per repo
rsmultigit --terse gh last-workflow-state            # Just the repo names

rsmultigit gh open-site

Open each repository’s GitHub Pages site in the browser with xdg-open, printing the URL first. A repository without a Pages site says no GitHub Pages site and is otherwise left alone. Every repository with a site gets a browser tab, so point the config at the repos you mean before running it across a large fleet.

rsmultigit gh open-site

rsmultigit gh sync-metadata [--dry-run]

Sync each repository’s GitHub metadata from its config/project.lua, printing only what actually differs. Repositories without that file are skipped. The file is the source of truth for two fields:

project.luaGitHub field
DESCRIPTION_SHORTdescription
KEYWORDS (a lua list)topics (order-independent)

A field that is absent or empty locally is left alone on GitHub: “unset locally” is not the same as “clear it”. The repository features follow a fixed fleet policy that is not read from the file and is always enforced: wiki off, issues on, projects off.

The repository is addressed by its github.com remote, not by the file’s NAME; a NAME that disagrees with the GitHub repository name is reported but never acted on. Only the topics that differ are sent (--add-topic / --remove-topic), so a topic present on both sides is never touched.

Only the plain assignment subset of lua that project.lua uses is understood (X = "...", X = { "...", ... }, X = Y aliases, -- comments); anything else is an error naming the line.

rsmultigit gh sync-metadata --dry-run   # Show the differences, change nothing
rsmultigit gh sync-metadata             # Show them and update GitHub

Sample output for one repository with every field out of step:

[demos-os-linux]
description
  local:  demos-os-linux is a project to demo and explore the Linux API
  github: Linux demos
topics
  local:  api c demos linux
  github: c demos linux old-topic
features
  want:   wiki=false issues=true projects=false
  github: wiki=true issues=true projects=false

--dry-run is accepted by sync-metadata only, and --keep by clean-all only; on any other gh operation either is an error rather than a silently ignored flag.

Utility Commands

These need no config file: setup is how a fresh install bootstraps one.

rsmultigit setup <command>

The two ways to get a first config file. rsmultigit setup interactive alone lists them.

rsmultigit setup interactive

Write a first ~/.config/rsmultigit/config.toml by answering two questions in the terminal:

  1. Which directory holds your git repositories. A text prompt with tab completion over directories, defaulting to ~/git when it exists. The answer is checked before it is accepted: it must be a directory with at least one git repository directly under it, the same test the repos glob applies later.
  2. Which build tool a bare rsmultigit build runs. A menu of the supported methods (bootstrap, make, rsconstruct, cargo) plus none, each row showing how many of the repos found carry that tool’s file (bootstrap.py, Makefile, rsconstruct.toml, Cargo.toml). The cursor starts on the most common one.

The config is written with repos = ["<dir>/*"] (the home directory contracted to ~) and, unless none was picked, default_build_method. An existing file is replaced only after a confirmation.

Each question is skipped when its answer is given as an option, and with every answer given nothing is asked at all, so the command also works in scripts and without a terminal:

rsmultigit setup interactive                                  # fully interactive
rsmultigit setup interactive --repos-dir ~/src                # asks only the build tool
rsmultigit setup interactive --repos-dir ~/src --build cargo  # asks nothing
rsmultigit setup interactive --repos-dir ~/src --no-build     # no default_build_method
rsmultigit setup interactive --repos-dir ~/src --build make --overwrite  # replace without asking
OptionMeaning
--repos-dir <DIR>Directory whose direct subdirectories are the repositories
--build <METHOD>Set default_build_method; excludes --no-build
--no-buildWrite no default_build_method
--overwriteReplace an existing config file without asking

Without a terminal, a question that still needs asking is an error naming the option that answers it; nothing is written in that case.

rsmultigit setup config-sample

Print a fully commented sample config to stdout.

mkdir -p ~/.config/rsmultigit
rsmultigit setup config-sample > ~/.config/rsmultigit/config.toml

rsmultigit complete <bash|zsh|fish|elvish|powershell>

Print a shell completion script. The bash and zsh scripts additionally complete check same --checks <TAB> and check exists --checks <TAB> with the rule names from your config, by calling rsmultigit check list at completion time.

rsmultigit complete bash >> ~/.bash_completion
rsmultigit complete zsh > ~/.zfunc/_rsmultigit

rsmultigit version

Print detailed version information: crate version, git describe, commit, branch, dirty state, rustc version, edition, and build timestamp.

rsmultigit version
rsmultigit --version                 # One-line "rsmultigit x.y.z by Author"

Configuration

rsmultigit is driven by one file, ~/.config/rsmultigit/config.toml, plus a handful of global command-line flags. The file says which repositories to operate on and which invariants to check; the flags say how to run and how much to print.

The location is fixed and there is no --config flag. RSMULTIGIT_CONFIG overrides the path (the test suite uses it). A missing or unparsable file is an error for every command except setup, setup config-sample, complete and version.

Write a first config interactively with rsmultigit setup interactive, or print a fully commented starting point with:

rsmultigit setup config-sample

Top-level keys

repos = ["~/git/*"]
default_build_method = "rsconstruct"
crates_io_pass_entry = "keys/crates.io"
KeyRequiredMeaning
reposyesList of shell-expanded glob patterns. Matches that are not git repositories are dropped; the rest are deduplicated and sorted. See Repository Discovery
default_build_methodnoWhat a bare rsmultigit build runs: bootstrap, make, rsconstruct or cargo, spelled as on the command line. A method given on the command line always wins. Without the key, rsmultigit build with no method is an error
crates_io_pass_entrynoThe pass(1) entry rsmultigit cargo release reads the crates.io token from when CARGO_REGISTRY_TOKEN is not set in the environment. Default keys/crates.io

[[check-same]] rules: files that must be identical

Each [[check-same]] block names one file that must have byte-identical content in every repo it applies to. rsmultigit check same evaluates them.

[[check-same]]
name = "workflow-build-yml"
select = "*"
marker = "rsconstruct.toml"
marker_absent = ".noci"
path = ".github/workflows/build.yml"
must_have = true
FieldRequiredDefaultMeaning
nameyesRule name, used in output and for --checks / --checks-re
selectyesGlob over repo directory names (not paths). * selects every repo
excludenononeGlob over repo names to drop from the selection
markernononeOnly repos containing this file (relative to the repo root) stay selected
marker_absentnononeRepos containing this file are dropped. This is the in-repo opt-out: a repo declares itself exempt (with a .noci file, say) instead of being named in an exclude glob far away from it
pathyesThe file to compare, relative to each repo root
enablednotrueDisabled rules are skipped by default but can still be forced with --checks or --checks-re
must_havenofalseWhen true, every selected repo must contain path; missing files are violations. When false, repos without the file are silently skipped

A rule passes when every surviving repo’s path hashes the same and, with must_have = true, no selected repo lacks the file. A rule that ends up matching no files at all fails, because that almost always means a stale select or path; --allow-empty turns that into a pass.

Selection filters are applied in the order listed: select, then exclude, then marker, then marker_absent.

[[check-exists]] rules: files that must be present

Each [[check-exists]] block asserts that a file is present in every repo it applies to, without ever comparing content. This is the rule type for files that must exist but legitimately differ per repo, such as a README. rsmultigit check exists evaluates them.

[[check-exists]]
name = "readme-present"
select = "*"
path = "README.md"

name, select, exclude, marker, marker_absent, path and enabled mean exactly what they mean for [[check-same]]. There is no must_have, because requiring the file is the whole rule. A directory at path does not satisfy the rule; it must be a file.

[[check-submodules]] rules: submodules that must be pinned alike

Each [[check-submodules]] block names a submodule by its remote URL and asserts that every selected repo carrying it has it checked out at the same commit. This is the rule type for a repo the fleet shares through submodules, such as design tokens or a tag vocabulary, where one consumer bumping the pin while the others stay behind is exactly the drift nothing else notices. rsmultigit check submodules evaluates them.

[[check-submodules]]
name = "shared-themes"
select = "*"
url = "github.com/veltzer/shared-themes"

name, select, exclude, marker, marker_absent and enabled mean exactly what they mean for [[check-same]]. url identifies the submodule and is matched after normalisation on both sides: scheme, user, a trailing .git and case are ignored, so the spelling above matches a submodule added as git@github.com:veltzer/shared-themes.git. A selected repo that does not carry the submodule is skipped, not failed. Only submodules with a rule are checked; one without a rule may differ per repo.

rsmultigit check all runs the [[check-same]], [[check-exists]] and [[check-submodules]] rules together.

Global flags

All flags are global: they may appear before or after the subcommand.

Output control

FlagDefaultDescription
-v, --verboseoffPrint every repo, even when nothing happened; git status switches to per-file output
--terseoffRepo names only for data commands; only the N/total line for count commands; failing rule names only for the check commands
--no-headeroffSuppress the [repo] (or [rule]) header line
--no-outputoffSuppress command output, keep the [repo] headers
--print-notoffInvert selection: print the repos that do NOT match

Execution

FlagDefaultDescription
--no-stopoffReport errors on stderr and continue instead of stopping at the first one; still exits non-zero if any repo failed
--short-circuitoffStop at the first negative result. Honoured by check same and check exists; other commands accept it and ignore it
-j, --jobs <N>1Number of repos to process concurrently; 0 means one per CPU. Output is buffered per repo and printed in repo order

Tool environment

FlagDefaultDescription
--venvonActivate each repo’s local .venv (prepend .venv/bin to PATH, set VIRTUAL_ENV) before running tool subprocesses. Honoured by run, build, cargo and npm; repos without a .venv run unchanged. Not honoured by uv, which selects its own environment from the repo directory
--no-venvoffTurn the .venv activation off

Short-circuiting

--short-circuit tells a check command to stop at the first broken rule rather than working through everything. Rules that already passed are still reported, and the exit code is unchanged (non-zero when a rule is broken). Without the flag, every rule is evaluated and every failure reported.

rsmultigit check same                        # report every broken rule
rsmultigit --short-circuit check same        # report the first broken rule and stop
rsmultigit --terse --short-circuit check same # print just that rule's name
rsmultigit --short-circuit check exists      # same, for presence rules

Build command skipping

rsmultigit build <method> skips projects that contain a .disable file in their root. build rsconstruct additionally skips projects without an rsconstruct.toml, and build cargo, the cargo commands and cargo release skip projects without a Cargo.toml. The uv commands skip projects without a pyproject.toml, and gh commands skip repos with no github.com remote.

Repository Discovery

RSMultiGit does not scan the current directory. The set of repositories it operates on comes from the repos list in ~/.config/rsmultigit/config.toml, so every command sees the same fleet no matter where it is run from.

The repos list

repos = [
    "~/git/*",
    "~/work/team-*/repo",
    "/srv/checkouts/special",
]

Each entry is a glob pattern. Resolution works as follows:

  1. Shell expansion. ~ and $VAR are expanded in every entry.
  2. Glob matching. The expanded pattern is matched against the filesystem. A pattern with no glob characters is simply a path.
  3. Git filter. Only matches that are git checkouts are kept: directories whose .git is the git directory itself, or a file holding a gitdir: <path> pointer to it, as in a linked worktree (git worktree add) or a submodule. Plain directories, files, and directories with any other kind of .git file are silently dropped.
  4. Dedupe and sort. Matches from all patterns are merged, duplicates removed, and the list sorted alphabetically. Output order is always this sorted order, even when running in parallel.

An empty repos list, or a list that matches no git repositories at all, is an error: rsmultigit refuses to run a command over nothing.

Inspecting the result

rsmultigit list repos              # one absolute path per line
rsmultigit --verbose list repos    # with the usual [repo] header per entry

Per-rule selection

The [[check-same]] and [[check-exists]] rules narrow the discovered list further with select, exclude, marker and marker_absent. Those filters apply only to the consistency checks; every other command runs over the whole repos list. See Configuration.

Overriding the config path

The location is fixed at ~/.config/rsmultigit/config.toml and there is no --config flag. The RSMULTIGIT_CONFIG environment variable overrides the path; the integration tests use it to point the binary at a temporary config.

Four commands need no config at all, because they are how a fresh install bootstraps one: setup, setup config-sample, complete, and version.

Architecture

Overview

RSMultiGit follows a simple pipeline: load the config (the repos list and the check rules) → resolve the repo list → run one command across every repo → print per-repo results in repo order.

Module structure

src/
  main.rs              Entry point: CLI dispatch
  cli.rs               Clap derive definitions (Cli + Commands + value enums),
                       shell-completion output
  config.rs            AppConfig: the global flags as a runtime struct
  runner.rs            The three execution patterns and the parallel scheduler
  subprocess_utils.rs  Shell command helpers, venv activation, per-thread output capture
  commands/
    mod.rs             Module declarations
    check.rs           Config-file parsing (repos, [[check-same]], [[check-exists]]), repo
                       resolution, rule evaluation (SHA-256 grouping, presence)
    check_run.rs       check same / check exists / check all drivers: rule selection,
                       reporting, exit codes, the interactive flows
    interactive.rs     Prompt helpers for --diff / --copy / --fix-missing
    count.rs           git2-based repo inspection (dirty, untracked, ahead/behind)
    status.rs          Status summary via git2; per-file detail / diff via subprocess
    build.rs           Build methods and their precondition checks
    run.rs             Arbitrary command execution
    gh.rs              GitHub cleanup via the gh CLI
    uv.rs, cargo.rs, rust.rs   Tooling passthrough
    <one file per git operation>  pull, push, fetch, clean, diff, log, grep, ...

Runner patterns

Every subcommand that operates on repos uses one of three runner functions in runner.rs. Each command module exposes a plain pub fn taking a repo path, and main.rs picks the runner.

do_count

For count <what> and git tag has-local / git tag has-remote. Calls a boolean test function on each repo (libgit2, no subprocess), prints the path of each match, then a final matched/total line. --print-not inverts the test, --terse drops the per-repo lines.

do_for_all_projects and do_for_all_projects_with_check

For action commands (git pull, git clean, git diff, run, build, …). Runs an action in each repo directory. The _with_check variant runs a cheap precondition first (is there a Cargo.toml? is the branch ahead? does the file exist?) and skips repos where it fails: no header, no output. That predicate is the only place a skip can be decided, because the serial path prints the [repo] header before the action so the action’s live output lands under it. The header is printed for repos where the action ran, or for every repo with --verbose. --no-stop turns a failing repo into a stderr line instead of a fatal error; the failures are counted, and the run returns an error (N of M repos failed) at the end if there were any.

For data commands (git status, git dirty, git grep, git age, git size, …). Calls a function returning Option<String> per repo and prints the header plus data only when it is Some, so a repo with nothing to say prints nothing. --verbose and --print-not also print the header for None repos; --terse prints only the repo path.

Parallel execution

All three runners go through for_each_project_ordered, which is a serial loop when --jobs is 1 (the default) and a work-stealing thread pool otherwise. Workers claim repo indices from an atomic counter and send (index, result) pairs back over a channel; the calling thread buffers out-of-order results and emits them strictly in repo order, so parallel output is indistinguishable from serial output.

Subprocess output is the complication: git pull writes straight to the inherited stdout. In the parallel path each worker thread enables a thread-local capture buffer (subprocess_utils::enter_capture) before running the action, so check_call and friends collect the child’s stdout and stderr into it instead of inheriting the parent’s streams, and command modules that format their own lines (gh, git branch github) route them through subprocess_utils::out_line, which lands in the same buffer. The buffer keeps each chunk tagged with the stream it came from, in arrival order (one reader thread per pipe), so the replay puts stderr back on stderr and keeps the two interleaved to read granularity. It travels back with the result and is replayed on the main thread under the repo’s header. In the serial path nothing is captured and children write live, which keeps interactive tools (credential prompts, pagers) working. --no-output reuses the capture: the buffer is simply dropped, in both paths, and attached to the error if the action failed.

An error without --no-stop ends the run the way the serial loop does: the receiver is closed, a cancel flag stops idle workers from claiming more repos, and only work already in flight completes.

When stderr is a terminal, the parallel path shows an indicatif progress bar on stderr and suspends it while each repo’s output is printed.

Consistency checks

check same and check exists do not use the runners: they are organised by rule, not by repo. check.rs parses the config, applies each rule’s select / exclude / marker / marker_absent filters to the resolved repo list, then either hashes path in every selected repo with SHA-256 and groups repos by digest (evaluate_rule) or records presence (evaluate_exists_rule). check_run.rs owns rule selection (--checks / --checks-re, shared by both commands through the NamedRule trait), the reporting, the exit codes, and the interactive --diff / --copy / --fix-missing flows. The drivers read prompts from a BufRead and write everything to a Write, so the unit tests run whole commands against in-memory buffers.

Git inspection — prefer the git2 crate

Policy: every git operation that libgit2 can do is done through the git2 crate, in-process. The git CLI subprocess is the fallback, not the default.

The reasoning: rsmultigit’s whole job is running the same small operation across hundreds of repositories. A subprocess pays fork + exec + git binary startup (config parsing, index loading) on every repo — a few milliseconds each that pure library calls don’t pay. Per repo this is negligible; times 260 repos it dominates the runtime of the fast inspection commands.

Subprocesses remain the right tool where libgit2 is genuinely weaker: network operations (pull, push, fetch — credential helpers, SSH agent and transport quirks are handled far better by the real git), and commands whose value is git’s own output formatting (log, blame, grep).

Recorded benchmark (2026-08-21)

Measured on a real 260-repo config, release build, warm page cache, five runs each, under the command names of the day. rsmultigit status spawned git status -s per repo; rsmultigit count dirty performs the equivalent working-tree scan in-process via git2, so the pair isolates the subprocess overhead:

CommandWallUserSys
status (subprocess per repo)~0.89 s~0.34 s~0.58 s
count dirty (git2)~0.52 s~0.30 s~0.21 s

The wall-clock win is ~40%. The telling column is sys: 0.58 s → 0.21 s is the cost of 260 fork+exec+startup cycles disappearing. User time is nearly identical because git and libgit2 do broadly the same index-vs-worktree comparison — the library doesn’t scan faster, it just skips the process machinery around the scan.

Known trade-offs

  • Very large working trees can regress: git status supports the untracked cache and fsmonitor extensions, libgit2 does not. None of the benchmarked repos was big enough to flip the result, but a kernel-sized repo could be.
  • Output parity with git porcelain formats is close but not byte-perfect (submodule state, unusual ignore rules).

Subprocess environments — venv activation vs. clean env

Policy: tools that resolve from PATH get the repo’s .venv activated. Tools that select their own target environment get a clean env instead, and choose from the working directory. Never pass the caller’s VIRTUAL_ENV through to either.

Three helpers in subprocess_utils.rs implement this:

HelperEnvironmentUsed by
check_callinherited, unchangedplain commands with no venv stake
check_call_ve_env.venv/bin prepended to PATH, VIRTUAL_ENV setrun, build, cargo, npm
check_call_clean_envVIRTUAL_ENV and UV_PROJECT_ENVIRONMENT removeduv

Commands honouring --venv do not call the first two directly: they call check_call_maybe_ve, which dispatches to check_call_ve_env when the flag is on and check_call when it is off. A repo with no .venv runs with the environment unchanged either way.

Why activation is right for run/build

These run tools from the environment — pytest, mypy, ruff. The tool name resolves through PATH, so activation is what makes the repo’s own pinned version win over whatever ~/.venv happens to provide. This mirrors the global rule that builds happen inside an already-entered environment; --venv is rsmultigit entering it on your behalf, once per repo.

Why activation is wrong for uv

uv is not run from an environment, it manages one. It already locates the target from the working directory — the project’s .venv for uv sync and uv lock, ./.venv for the uv pip interface — and since rsmultigit sets the working directory per repo, that discovery is already correct.

An inherited VIRTUAL_ENV can then only make it wrong, because it names the venv the calling shell was in, never the repo being operated on. The two uv interfaces fail differently on it, and the quiet one is the dangerous one:

InvocationInherited VIRTUAL_ENVResult
uv sync / uv lock~/.venvWarns does not match the project environment path .venv and will be ignored, then does the right thing
uv pip install~/.venvInstalls into ~/.venv, silently, once per repo

The first is the visible annoyance that prompted the change. The second is the reason the fix is a clean environment rather than a suppressed warning: uv pip treats an active venv as a perfectly legitimate target, so there is nothing to warn about, and a fleet-wide run would quietly write into the shared toolbox hundreds of times.

Setting VIRTUAL_ENV explicitly to the repo’s own .venv would also be correct for both — it agrees with what uv discovers anyway — but it still trips the uv sync warning, because uv compares the absolute path it was given against the project’s relative .venv. Unsetting is the only option that is both correct and quiet, and it extends to uv subcommands not yet wired up: anything added to commands/uv.rs gets the right behaviour by calling check_call_clean_env.

Consequently the global --venv/--no-venv flag does not apply to uv. It still parses there (it is a global flag) but has no effect.

Error handling

All functions return anyhow::Result, with .context() naming the repo the error came from. The --no-stop flag controls whether an error in one repo is fatal (default) or printed to stderr as error in <repo>: ... before moving on; a --no-stop run with any such error still exits non-zero. In the parallel path a failing repo’s captured output is attached to the error so it is not lost.

Build script

The build.rs script embeds git metadata (commit SHA, branch, dirty status, describe) and the Rust compiler version at compile time. These are accessible via env!() macros and displayed by rsmultigit version.

CLI Parser Notes

rsmultigit uses clap 4 (derive macros) for command-line parsing. This page documents parser limitations we’ve hit and the workarounds we’ve chosen, so that future contributors don’t re-investigate the same dead ends.

Command categories in --help

What we wanted. Group the ~30 subcommands under headings (Inspection, Sync, Mutating, Build, Maintenance, Correctness, Meta) so rsmultigit --help is scannable instead of a flat alphabetical wall.

What clap actually supports. In clap 4, #[command(help_heading = "...")] (and the builder equivalent Command::help_heading) only group arguments, not subcommands. Per-subcommand-variant help_heading doesn’t compile:

error[E0599]: no method named `help_heading` found for struct `clap::Command`

Upstream tracks this as an unresolved feature request (clap-rs/clap#4416, open as of clap 4.6).

What we do. Keep the flat alphabetical list in --help, and keep it short by nesting: the git wrappers (pull, push, reset, restore, …) live under a git subcommand group, so the top level holds rsmultigit’s own reports and tool runners. A real subcommand level is the one kind of grouping clap renders. Document the intended categories in the README / these docs for human readers. The enum arms themselves stay ungrouped because adding source-level categories without corresponding help-output categories would just be noise.

What we considered and rejected. Intercepting --help to emit a custom grouped listing (~50 lines): doable but adds a maintenance surface — every new subcommand has to be added to the grouping table as well as to the enum, and the table can silently drift out of sync. Not worth it for a cosmetic win unless the flat help becomes actively painful.

Commands that demand an operation word

What we wanted. rsmultigit gh (and count, git clean, cargo, uv, npm, … every command whose first positional is a ValueEnum picking the operation) must list the operations when invoked without one. clap’s default is an error that only says <WHAT> is required, which leaves the user to go and ask --help for what <WHAT> may be.

What clap supports. #[command(arg_required_else_help = true)] on the variant turns the bare invocation into a help display. Two gaps remain: it renders the short help, where the operations appear as a bare [possible values: ...] list without descriptions, and it only fires when nothing followed the command, so rsmultigit gh --keep 3 still gets the <WHAT> error.

A third gap: either rendering lists every flag as well, and since the global flags (--terse, --no-header, -j, --venv, …) apply to every command, that is a dozen entries burying the five operations the user stopped to see.

What we do. Every such variant carries arg_required_else_help, and main::parse_cli intercepts both outcomes (the help-on-empty error and a MissingRequiredArgument naming <WHAT>) to print the command’s help via cli::long_help_for. That renders the command’s about and usage, then a Commands: table of its operations - each with its description, no flag listed - and a trailer pointing at --help, which keeps the full output. The table is built by us (operations_of + operations_list), not by clap’s {positionals}, whose long form is an indented Possible values: block that looks nothing like a subcommand list. Exit status 2 on stderr, as clap would. The positional is called what on every such command; that name is the contract the intercept relies on. A missing free-form positional (git blame <FILE>) keeps clap’s own error.

A group of subcommands (git) gets the same treatment: the variant carries arg_required_else_help (and disable_help_subcommand, so no help entry pads the list), the intercept also catches MissingSubcommand (the rsmultigit git --no-stop form), and operations_of takes the subcommands instead of the what values when the command it lands on has subcommands. Both kinds then go through the same operations_list, so rsmultigit git and rsmultigit npm print the same form.

Aliases and shell completion

What we wanted. alias mg=rsmultigit should preserve tab completion.

What actually happens. Shell completions are bound to a specific program name — the name is baked into the generated completion script. Generating with rsmultigit complete bash produces a _rsmultigit function registered against the literal word rsmultigit. Typing mg <TAB> doesn’t trigger it.

Workaround. Reuse the existing completion function by telling the shell:

alias mg=rsmultigit
complete -F _rsmultigit mg   # bash
compdef mg=rsmultigit        # zsh
# fish aliases inherit completions automatically

rsmultigit could also be extended to take a program name: e.g. rsmultigit complete bash --name mg would emit completions bound to mg. Not currently implemented — the complete subcommand hardcodes "rsmultigit" in cli::print_completions.

Top-level help_template and subcommand heading interaction

The top-level Cli uses a custom help_template containing a literal Commands: line above {subcommands}. If subcommand help_heading support ever lands, the template needs to drop that literal — otherwise you’d see Commands: above clap’s auto-emitted per-group headings. Flagging it here so it’s not forgotten.

Colours in help

Cli sets styles = STYLES (cargo’s palette: green headings, cyan flags and commands, red errors). clap colours only what it renders itself, so any heading written as literal text in a help_template (Commands:, Options:, and the hand-written -h/-V lines) is put together as a StyledStr from STYLES (top_help_template, subcommands_help_template) instead of a plain &str. long_help_for returns that StyledStr, and parse_cli prints it with anstream::eprint! and .ansi(). Calling .to_string() on it would strip the colours, and a plain eprint! would send escape codes into pipes. anstream applies the same rules clap does: colour on a terminal, none when the output is piped, and it respects NO_COLOR and CLICOLOR_FORCE.

--version vs version subcommand

Both exist. --version is the clap-derived flag and prints a one-line rsmultigit x.y.z by Author. The version subcommand prints the richer version block with git SHA / branch / dirty / rustc / build timestamp (populated by build.rs). Keep both — they serve different use cases (scripts that parse a version string vs humans debugging an install).

Global flags on subcommands

--terse, --no-header, --no-output, --verbose, --print-not, --no-stop, --short-circuit, -j/--jobs, --venv/--no-venv are all declared with global = true on the top-level Cli, so they can appear before or after the subcommand:

rsmultigit --jobs 8 git pull       # works
rsmultigit git pull --jobs 8        # also works

Subcommand-specific flags (e.g. pull --quiet, grep -l) are declared on the subcommand variant and only work after the subcommand name. That split is intentional and matches git’s own conventions.

Testing

RSMultiGit has unit tests inside the source files and integration tests that drive the compiled binary.

Running tests

The test runner is cargo-nextest; it is what CI runs and what the shared .config/nextest.toml profile is written for.

cargo nextest run               # Debug
cargo nextest run --release     # Release
cargo nt                        # Alias defined in .cargo/config.toml
make test                       # Both

CI additionally gates on cargo fmt --all --check, cargo clippy --all-targets -- -D warnings and cargo deny check; run all four before pushing.

Unit tests

Unit tests live in #[cfg(test)] modules inside each source file:

ModuleWhat’s tested
cliEvery subcommand and value enum parses; global flags before and after the subcommand; --venv/--no-venv precedence; default_build_method accepts exactly the CLI spellings
commands::checkConfig parsing, repos resolution (git filter, dedupe, empty-list errors), rule selection filters, SHA-256 grouping, must_have, matched_nothing, [[check-exists]] evaluation
commands::check_run--checks / --checks-re resolution; whole check same and check exists runs against in-memory stdin/stdout: output format, exit codes, --terse, --only-failed, --short-circuit, --allow-empty, --diff, --copy, --fix-missing
commands::countis_dirty, has_untracked, ahead_behind (configured upstream, origin fallback, detached HEAD) against temp git repos built with git2
commands::statusThe one-line status summary
commands::runShell vs direct execution, venv activation
commands::ghDeletion selection and failed-conclusion classification (pure functions, no network)
commands::interactiveGroup labels (A..Z, AA…), prompt parsing, skip/quit/EOF handling via Cursor buffers
runnerAll three runner patterns, serial and parallel, with mock closures, including stop-on-first-error under -j
subprocess_utilsCapture mode, venv activation, clean-environment execution

No test changes the process working directory: every helper takes an explicit repo path, so tests run in parallel without coordination.

Integration tests

Integration tests are in tests/ and run the compiled rsmultigit binary as a subprocess against temporary git repositories:

tests/
  main.rs              Test entry point, loads modules
  common/mod.rs        Shared helpers
  tests_mod/
    build.rs           build with and without default_build_method
    check_same.rs      check same: grouping, must_have, --checks/--checks-re,
                       --only-failed, --allow-empty, --diff, --copy, --fix-missing,
                       --terse, --short-circuit, exit codes
    cli.rs             Help, unknown subcommand, missing args
    count.rs           count dirty/untracked, --terse, --print-not
    docs.rs            Every subcommand and global flag is in commands.md
    inspect.rs         list, log, blame, tags, size, age, authors, config,
                       check exists, check all, complete
    remote.rs          push, fetch, pull, prune, remote, tag remote, against a
                       local bare repository standing in for origin
    run.rs             run/exec, shell vs direct, --no-stop, --no-output,
                       parallel output ordering
    rust.rs            cargo release against a fake cargo/cargo-release/pass on
                       PATH: level, token from pass or environment, preflight
    status.rs          status summary, --verbose, dirty
    version.rs         version subcommand and --version flag
    worktree.rs        commit, checkout, stash, reset, diff, clean, gc,
                       submodule-update

Every run points the binary at a config written into the temp directory via the RSMULTIGIT_CONFIG environment variable, so tests never touch ~/.config/rsmultigit/config.toml.

Test helpers (tests/common/mod.rs)

FunctionDescription
setup_git_repos(names)Create a temp dir with one initialised git repo (one empty commit) per name
init_git_repo(path)Initialise a single git repo with one commit
utf8(&tmp)The temp dir as a UTF-8 path
git(dir, args)Run git in dir with signing off, panic on failure, return trimmed stdout
current_branch(dir)The checked-out branch name
commit_file(repo, name, content, message)Write a file and commit it
add_bare_origin(repo, bare)Create a bare repo at bare, add it as origin, push with tracking
clone_of(bare, path)A second working clone of bare, for commits made “elsewhere”
write_config(dir, extra)Write <dir>/config.toml with repos = ["<dir>/*"] plus extra (for [[check-same]] blocks); returns its path
run_rsmultigit(dir, args)Run the binary in dir against a default config written by write_config
run_rsmultigit_with_env(dir, args, env)Run the binary with extra environment variables (used to pass a custom RSMULTIGIT_CONFIG)
run_rsmultigit_with_stdin(dir, args, env, bytes)Same, with bytes fed to stdin, for the interactive check same flows
stdout_str(output) / stderr_str(output)Trimmed stdout / stderr of a finished command

Writing new tests

  1. Create a new file in tests/tests_mod/
  2. Add a #[path] module entry in tests/main.rs
  3. Use setup_git_repos() to create fixtures and write_config() if the test needs check rules
  4. Use run_rsmultigit() or run_rsmultigit_with_env() and assert on the output and exit status

Example:

#![allow(unused)]
fn main() {
use camino::Utf8Path;
use crate::common::{run_rsmultigit, setup_git_repos, stdout_str};

#[test]
fn my_new_test() {
    let tmp = setup_git_repos(&["repo1", "repo2"]);
    let dir = Utf8Path::from_path(tmp.path()).unwrap();
    let output = run_rsmultigit(dir, &["list", "repos"]);
    assert!(output.status.success());
    let stdout = stdout_str(&output);
    assert!(stdout.contains("repo1"));
}
}

Release Info

This page is generated automatically by the docs deployment workflow.

  • Version: 0.1.61
  • Git tag: v0.1.61
  • Git commit: b9fa8087aa678b8b0331c9c201925fc282f552b2
  • Released at: 2026-10-16 17:50:06 UTC
  • Workflow: CI run #168