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
gitsubprocess per repo - Bulk operations — pull, push, fetch, diff, grep, clean, commit, stash, reset, and branch inspection across all repos
- Consistency checks —
check sameverifies that shared files are byte-identical across the fleet,check existsverifies that required files are present, and interactive--diff,--copyand--fix-missingmodes repair drift - Build orchestration — run make, rsconstruct, cargo, or bootstrap across
all projects, with each repo’s
.venvactivated automatically - Tooling passthrough —
uv lock|sync|build|publish,cargo build|clippy|fmt|test|update|...,npm install|ci|update|audit|...,cargo release, andghcleanup on the repos where they apply - Parallel execution —
-j Nruns 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:
| Platform | Asset |
|---|---|
| Linux x86_64 | rsmultigit-linux-x86_64 |
| Linux aarch64 | rsmultigit-linux-aarch64 |
| macOS x86_64 | rsmultigit-macos-x86_64 |
| macOS aarch64 | rsmultigit-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.
| Flag | Description |
|---|---|
-v, --verbose | Print every repo, even when no action is taken (adds the header for skipped repos; git status switches to per-file output) |
--terse | Minimal, 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-header | Suppress the [repo] header line printed before per-repo output |
--no-output | Suppress 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-not | Invert selection: print the repos that do NOT match |
--no-stop | On 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-circuit | Stop 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 |
--venv | Activate 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-venv | Turn --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.
| What | Runs | Selects repos with | --dry-run shows |
|---|---|---|---|
hard | git reset --hard HEAD | any change to a tracked file, staged or not | git status --short --untracked-files=no |
mixed | git reset --mixed HEAD | staged changes | git 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]
| What | Runs | Notes |
|---|---|---|
hard | git clean -ffxd | Removes untracked and ignored files (.venv/, target/, .env, …) |
soft | git clean -fd | Removes 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.
| Flag | Effect |
|---|---|
--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-failed | Drop the ok (N files) lines; print failing rules only |
--allow-empty | Let a rule that matches no files pass as ok (0 files) instead of failing |
--diff | After 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 |
--copy | After 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-missing | For 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.
| Method | What runs | Which projects |
|---|---|---|
bootstrap | python bootstrap.py | all |
make | make | all |
rsconstruct | rsconstruct --quiet build | those with rsconstruct.toml |
cargo | cargo build --profile dev then cargo build --release | those 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.
what | Runs | Notes |
|---|---|---|
build | cargo build --profile dev then cargo build --release | Every profile, like cargo_build.sh; --release or --profile builds just that one |
check | cargo check | Type-check only, no artifacts |
clippy | cargo clippy --all-targets -- -D warnings | Same invocation as the fleet’s ci.yml: warnings fail the run |
fmt | cargo fmt --all | --check only verifies and fails on unformatted code |
test | cargo test | cargo’s built-in runner |
nextest | cargo nextest run | Requires cargo-nextest |
doc | cargo doc --no-deps | API docs for the crate itself |
deny | cargo deny check | Requires cargo-deny |
fetch | cargo fetch | Download dependencies without building |
update | cargo update | Refreshes Cargo.lock |
clean | cargo clean | Removes the target directory |
publish | cargo publish | Uploads the crate to crates.io; release is the full release flow |
release | cargo release <type> --execute --no-confirm | The 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(orpublish = []) is a scratch crate that was never meant for crates.io; it is skipped here just ascargo publishitself would refuse it.publish.workspace = truedefers to[workspace.package].publish, as in cargo. - A repo with no
Cargo.tomlis 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 rebuiltCARGO_HOMEloses 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_TOKENis already set in the environment it is used as is. Otherwise the first line ofpass show <entry>is taken, where<entry>is the config file’scrates_io_pass_entry(defaultkeys/crates.io). The token is handed to everycargo releaseprocess through its environment only, so it never lands in~/.cargo/credentialsor 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]
what | Runs | Notes |
|---|---|---|
install | npm install | Installs from package.json, writing package-lock.json |
ci | npm ci | Clean install of exactly what package-lock.json says; fails if it disagrees with package.json |
update | npm update | Moves dependencies to the newest versions their declared ranges allow and rewrites package-lock.json |
audit | npm audit | Reports known vulnerabilities; exits non-zero when any are found. --fix runs npm audit fix, which rewrites package.json and package-lock.json |
outdated | npm outdated | Lists dependencies with newer releases; exits non-zero when any are outdated |
test | npm test | Runs the project’s test script |
publish | npm publish | Publishes 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.lua | GitHub field |
|---|---|
DESCRIPTION_SHORT | description |
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:
- Which directory holds your git repositories. A text prompt with tab
completion over directories, defaulting to
~/gitwhen 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 thereposglob applies later. - Which build tool a bare
rsmultigit buildruns. A menu of the supported methods (bootstrap,make,rsconstruct,cargo) plusnone, 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
| Option | Meaning |
|---|---|
--repos-dir <DIR> | Directory whose direct subdirectories are the repositories |
--build <METHOD> | Set default_build_method; excludes --no-build |
--no-build | Write no default_build_method |
--overwrite | Replace 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"
| Key | Required | Meaning |
|---|---|---|
repos | yes | List of shell-expanded glob patterns. Matches that are not git repositories are dropped; the rest are deduplicated and sorted. See Repository Discovery |
default_build_method | no | What 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_entry | no | The 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
| Field | Required | Default | Meaning |
|---|---|---|---|
name | yes | Rule name, used in output and for --checks / --checks-re | |
select | yes | Glob over repo directory names (not paths). * selects every repo | |
exclude | no | none | Glob over repo names to drop from the selection |
marker | no | none | Only repos containing this file (relative to the repo root) stay selected |
marker_absent | no | none | Repos 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 |
path | yes | The file to compare, relative to each repo root | |
enabled | no | true | Disabled rules are skipped by default but can still be forced with --checks or --checks-re |
must_have | no | false | When 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
| Flag | Default | Description |
|---|---|---|
-v, --verbose | off | Print every repo, even when nothing happened; git status switches to per-file output |
--terse | off | Repo names only for data commands; only the N/total line for count commands; failing rule names only for the check commands |
--no-header | off | Suppress the [repo] (or [rule]) header line |
--no-output | off | Suppress command output, keep the [repo] headers |
--print-not | off | Invert selection: print the repos that do NOT match |
Execution
| Flag | Default | Description |
|---|---|---|
--no-stop | off | Report errors on stderr and continue instead of stopping at the first one; still exits non-zero if any repo failed |
--short-circuit | off | Stop at the first negative result. Honoured by check same and check exists; other commands accept it and ignore it |
-j, --jobs <N> | 1 | Number of repos to process concurrently; 0 means one per CPU. Output is buffered per repo and printed in repo order |
Tool environment
| Flag | Default | Description |
|---|---|---|
--venv | on | Activate 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-venv | off | Turn 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:
- Shell expansion.
~and$VARare expanded in every entry. - Glob matching. The expanded pattern is matched against the filesystem. A pattern with no glob characters is simply a path.
- Git filter. Only matches that are git checkouts are kept: directories
whose
.gitis the git directory itself, or a file holding agitdir: <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.gitfile are silently dropped. - 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.
print_if_data
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:
| Command | Wall | User | Sys |
|---|---|---|---|
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 statussupports 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:
| Helper | Environment | Used by |
|---|---|---|
check_call | inherited, unchanged | plain commands with no venv stake |
check_call_ve_env | .venv/bin prepended to PATH, VIRTUAL_ENV set | run, build, cargo, npm |
check_call_clean_env | VIRTUAL_ENV and UV_PROJECT_ENVIRONMENT removed | uv |
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:
| Invocation | Inherited VIRTUAL_ENV | Result |
|---|---|---|
uv sync / uv lock | ~/.venv | Warns does not match the project environment path .venv and will be ignored, then does the right thing |
uv pip install | ~/.venv | Installs 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:
| Module | What’s tested |
|---|---|
cli | Every 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::check | Config 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::count | is_dirty, has_untracked, ahead_behind (configured upstream, origin fallback, detached HEAD) against temp git repos built with git2 |
commands::status | The one-line status summary |
commands::run | Shell vs direct execution, venv activation |
commands::gh | Deletion selection and failed-conclusion classification (pure functions, no network) |
commands::interactive | Group labels (A..Z, AA…), prompt parsing, skip/quit/EOF handling via Cursor buffers |
runner | All three runner patterns, serial and parallel, with mock closures, including stop-on-first-error under -j |
subprocess_utils | Capture 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)
| Function | Description |
|---|---|
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
- Create a new file in
tests/tests_mod/ - Add a
#[path]module entry intests/main.rs - Use
setup_git_repos()to create fixtures andwrite_config()if the test needs check rules - Use
run_rsmultigit()orrun_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:
CIrun #168