repolith-engine 0.0.9

Declarative orchestrator for Rust toolchains spread across multiple sibling git repositories.
Documentation

repolith

Declarative orchestrator for Rust toolchains spread across multiple sibling git repositories.

โšก Parallel ยท ๐Ÿ›‘ Cancellation-aware ยท ๐Ÿ’พ Cache-first

CI License Rust crates.io PRs welcome

repolith reads one repolith.toml describing remote repositories and the actions to run against them โ€” git clone, cargo install, docker build โ€” then executes the plan in parallel layers, with content-addressed caching so untouched work never re-runs and a shared CancellationToken so Ctrl-C cleanly aborts every in-flight subprocess.

What it looks like

Given a manifest:

[orchestrator]
schema_version = "0.1"
name = "my-stack"

[[node]]
id = "shared-types"
git = "https://github.com/example-org/shared-types"
path = "../libs/shared-types"
  [[node.action]]
  kind = "git-clone"

[[node]]
id = "migration-tool"
git = "https://github.com/example-org/migration-tool"
path = "../tools/migration-tool"
  [[node.action]]
  kind = "git-clone"
  [[node.action]]
  kind = "cargo-install"
  crate = "migrate"
  install_to = "~/.local/bin"

You preview, then sync:

$ repolith status
+-------------------------------+--------+---------------+
| Action                        | Status | Reason        |
+===============================+========+===============+
| shared-types::git-clone::0    | stale  | NoCachedBuild |
| migration-tool::git-clone::0  | stale  | NoCachedBuild |
| migration-tool::cargo-install | stale  | NoCachedBuild |
+-------------------------------+--------+---------------+

$ repolith sync --dry-run --explain
โ€ข shared-types::git-clone::0: NoCachedBuild
โ€ข migration-tool::git-clone::0: NoCachedBuild
โ€ข migration-tool::cargo-install::1: NoCachedBuild
dry-run: 3 action(s) would run

$ repolith sync
OK   shared-types::git-clone::0 (55 ms)
OK   migration-tool::git-clone::0 (188 ms)
OK   migration-tool::cargo-install::1 (4.2 s)

$ repolith sync
up to date โ€” 0 stale actions

The second sync is a no-op โ€” the cache holds last-run input hashes per action, so nothing re-runs unless something actually changed. What counts as "changed": the upstream HEAD, the content of a local path source (edits are seen even uncommitted), a cargo feature, the toolchain, the build platform โ€” or the artifact going missing from this machine. That last one matters with a shared cache backend: another machine having built something is not a reason to skip building it here.

Why use it

  • One declarative file. Everything your stack needs to bootstrap, in repolith.toml. No bash glue, no per-machine README dance.
  • Parallel by default. tokio::FuturesUnordered + Semaphore cap concurrency at --jobs N (default = num_cpus). Layer N+1 starts only when layer N settles โ€” typed dependencies, no race conditions.
  • Cancels cleanly. A shared CancellationToken plumbed through every action's Ctx. First failure in --fail-fast (default) cancels in-flight peers; --keep-going lets the layer settle then halts. On Unix the subprocess process group is signalled (SIGTERM โ†’ grace โ†’ SIGKILL) so cargo's rustc / linker grandchildren get reaped too.
  • Cache-first, and honest about it. Every successful build writes a BuildEvent keyed by a content-addressed input hash โ€” for local path sources that means the tree's actual bytes (.gitignore-aware, target/ excluded, mtimes ignored so a fresh clone doesn't look stale). Before trusting a cache hit the planner also checks the artifact is still here. Re-runs are near-instant when nothing changed, and never skipped when something did.
  • Hardened argv. URLs validated against a scheme allowlist with nested-userinfo / host / path-segment leading-dash checks; -- argv separator before every user URL as defense in depth; crate names + feature flags rejected if they could break cargo's --features list.

What it is NOT

The negative scope is fixed and will not evolve:

  • Not a CI runner โ€” no distributed execution, no remote workers.
  • Not a toolchain manager โ€” rustup is fine.
  • Not a package manager โ€” cargo is fine.
  • Not a process supervisor โ€” systemd / launchd / docker compose are fine; repolith reads heartbeats, doesn't write them.
  • Not a monorepo tool โ€” cargo workspaces already covers single-repo workspace publishing.
  • Not a hermetic build system โ€” hermeticity is opt-in per action, not the default.

Quick start

# 1. Install the binary from crates.io
cargo install repolith-cli

# 2. Write a manifest in your stack root (see the reference below,
#    or start from repolith.toml.example)
mkdir -p ~/my-stack && cd ~/my-stack
$EDITOR repolith.toml

# 3. Preview what would happen
repolith sync --dry-run --explain

# 4. Go
repolith sync

Building from source works too: git clone https://github.com/anatta-rs/repolith && cargo build --release.

repolith status prints a cache hit/miss table without running anything. repolith sync -k keeps a layer running after a failure (useful for surfacing every failure of a layer in one pass).

See repolith.toml.example for a full annotated manifest.

Manifest reference

A manifest is one [orchestrator] block plus any number of [[node]] blocks; each node carries the actions to run against it, in order.

[orchestrator]

Field Required Description
schema_version yes Manifest schema. Current requirement: ~0.1 (e.g. "0.1").
name yes Human-readable stack name, shown in logs.

[[node]]

Field Required Description
id yes Unique node id. Also the default crate name for cargo-install, and the prefix of every action id ({id}::{kind}::{index}).
git per action Source URL (https://, ssh://, or git@host:path). Source for git-clone.
path per action Local checkout directory, relative to the manifest. Destination for git-clone, source tree for cargo-install.

Action kinds

kind = "git-clone" โ€” fetch the node's source into path. No fields of its own:

[[node.action]]
kind = "git-clone"

kind = "cargo-install" โ€” cargo install from the node's source tree. All three fields are optional:

[[node.action]]
kind = "cargo-install"
crate = "migrate"              # default: the node's `id`
features = ["postgres", "tls"] # default: none
install_to = "~/.local/bin"    # default: ~/.repolith/bin (`~` expands at run time)

kind = "docker" โ€” docker build an image from the node's checkout (build-only: running containers stays out of scope, see What it is NOT). Requires path on the node; tag is the only required field:

[[node.action]]
kind = "docker"
tag = "my-org/app:latest"      # required โ€” docker reference charset only
dockerfile = "build/Dockerfile" # default: Dockerfile (relative to context)
context = "build"              # default: the node's `path`

dockerfile and context must stay inside the node's checkout: relative paths only, no .., validated at parse time and re-checked after symlink resolution at build time.

kind = "repolith" โ€” federation: the node's checkout contains its own repolith.toml, executed as a nested plan (orchestrator-of-orchestrators). Requires path on the node; the one field is optional:

[[node.action]]
kind = "repolith"
manifest = "repolith.toml"     # default โ€” relative to the node's `path`

The child stack keeps its own local cache (<stack>/.repolith/cache.db), exactly as if you had run repolith sync in that directory. Guard rails: manifest cycles (A -> B -> A) are rejected with the offending chain, federation depth is capped at 8, --jobs N bounds the whole tree (one global pool, never N per level), and Ctrl-C cancels every level down to the subprocess groups. The manifest path obeys the same two-stage containment as docker's paths.

Actions on the same node run in declaration order; independent nodes run in parallel.

Cache backends

The build cache is pluggable (Cache trait in repolith-core). Two backends ship today; --cache (or REPOLITH_CACHE) selects one:

Backend Select with Storage When
sqlite (default) โ€” local file, ~/.repolith/cache.db (--cache-path) single machine โ€” zero config
neo4j --cache neo4j shared server, build events as graph data multi-machine / federated stacks

The Neo4j backend reads REPOLITH_NEO4J_URI, REPOLITH_NEO4J_USER, and REPOLITH_NEO4J_PASS from the environment โ€” credentials never live in repolith.toml, and these variables are never forwarded to spawned subprocesses. Schema: one (:Action {id}) node per action with a LAST relationship to its most recent (:BuildEvent); layer writes are one transaction. NamespacedCache (library-level) lets multiple stacks share one server without id collisions.

Architecture

5 crates, layered execution with FuturesUnordered + CancellationToken + Semaphore. Full diagram + the FailFast / KeepGoing sequence diagrams

Crate Purpose
repolith-core Types, traits (Action, Cache), manifest parser, layered Plan.
repolith-cache SqliteCache (rusqlite, bundled, WAL), Neo4jCache (feature neo4j), NamespacedCache.
repolith-engine Async Orchestrator with cancellation + semaphore.
repolith-actions GitClone (feature git), CargoInstall (feature cargo), DockerBuild (feature docker).
repolith-cli repolith sync / status โ€” the binary you run.

Status

M2 complete. 5 crates, 4 action kinds (git-clone, cargo-install, docker, repolith federation), 2 cache backends (SQLite default, Neo4j opt-in with a live-server contract suite in CI), parallel layered execution with tree-wide cancellation, URL-injection + path-traversal hardening. Full test suite under cargo test --workspace --all-features (110+ tests at last count). The crates.io badge above always shows the current published version; see CHANGELOG.md for the per-release breakdown. All 5 crates are published on crates.io; releases are automated with release-plz โ€” merging the release PR publishes, tags, and cuts the GitHub Release.

Roadmap

  • M2 โ€” โœ… complete: docker action, federation kind = "repolith", Neo4j cache backend.
  • M3 โ€” watch mode (re-plan on file change), template_apply action driving AttachedEntry::Outbound.

Security

See SECURITY.md for the threat model, the env-allowlist policy, and the GitHub Security Advisories private-reporting form for vulnerability disclosure.

Contributing

PRs welcome. See CONTRIBUTING.md for the dev setup, local CI gates, and worked recipes for adding a new action or a new cache backend.

License

Dual-licensed under either of:

at your option.