DEV Community

loadingalias
loadingalias

Posted on

cargo-rail: Making Rust Monorepos Boring Again

Rust monorepos are painful. Your dependency graph drifts, CI tests everything, and extracting crates for OSS means Copybara (Java) or git subtree hell.

After 18 months of fighting these problems, I built cargo-rail — 11 dependencies, four workflows, zero workspace-hack crates.

My justfile had grown to over 1k lines. I had thirty shell scripts just to run tests. I wanted to release a few crates to the OSS community but git subtree was a project unto itself, and Copybara meant pulling in Java tooling. I searched for a solution to modern Rust monorepo challenges and couldn't find one that fit — so I built cargo-rail.

cargo-rail is a single Cargo subcommand that unifies dependencies, detects affected crates, splits/syncs repositories, and automates releases for Rust workspaces — with 11 core dependencies.

TL;DR: cargo-rail makes Rust monorepos lean, boring, and safe. It keeps your dependency graph clean, runs tests only where changes matter, lets you split crates with full history, and automates releases — all with 11 core dependencies.

Quick start:

cargo install cargo-rail
cargo rail init
cargo rail unify --check

Quick links:


Who This Is For

This article is for you if:

  • You maintain a Rust workspace with multiple crates (a monorepo or a “fat” workspace).
  • Your CI is slow or expensive and you’d like to only run what actually changed.
  • You care about dependency hygiene and supply-chain surface area.
  • You want to stay on Cargo (not Bazel/Buck2/Moon) but still have a first-class monorepo story.

The Four Problems

Rust monorepos are hard because:

  • Build graphs get bloated and misaligned.
  • CI slows down without graph-aware change detection.
  • Extracting OSS crates cleanly (with history) is painful.
  • Release tooling is often heavy and expands your supply chain.

Here’s how cargo-rail approaches each of these.

1. The Build Graph

Rust is notorious for its compilation times and massive dependency graphs. I needed a way to ensure my build graph was never dirty — that every build was the leanest, full-featured version. I needed to know all dependencies would resolve to the same major version; all dead dependencies and features would fall off if unused. I needed to automatically resolve the MSRV against the actual dependencies I was using, not guess and hope my manually-set rust-version was correct.

I desperately wanted to get rid of the "meta-crate" architecture that cargo-hakari or "workspace hack" workflows added to my codebase. It felt heavy-handed. I hated the idea of an added CI step to keep my Rust monorepo aligned.

2. Change Detection

I'm a solo startup founder working on low-level, systems code. I run a lot of testing variants across my crates:

  • Unit tests, integration tests, and doc-tests.
  • Property tests and concurrency tests via loom and shuttle.
  • Memory safety checks with Miri.
  • Mutation tests, fuzzing, and benchmarks via Criterion.
  • 2–4 sanitizers across different platforms.

Every just check command was running cargo fmt, cargo check, cargo clippy, cargo doc, cargo audit, and cargo deny. I'm profiling extensively and layering more checks over time.

Before long, I realized that if I didn't implement robust change detection, my development velocity was going to slow to a crawl. My CI was dragging. I needed a way to check, test, and bench only what actually changed — without introducing cognitive overhead.

3. Distribution

The first few crates I built were strong OSS candidates — low-level primitives that could benefit the Rust kernel and database communities. I wanted to share them. I started looking for a way to safely split crates from the canonical monorepo into clean, open-source repositories for the community to audit, test, and hack on.

That's when I realized this workflow isn't easy to do well. I had two messy options: write even more shell for git subtree with extensive filtering and hope I didn't mess it up as the codebase grew, or use Google's Copybara — which meant pulling in Java tooling (or their GHA). So I didn't ship those OSS crates. I didn't want to release a repository full of unfinished, or potentially closed-source work, just to open-source a few primitives.

4. Release, Publish, and Maintain

When I realized I had to solve #3, I quickly saw that releasing and publishing would be messy too. I researched the options, and while they were strong — release-plz is a stellar crate — they pulled in hundreds of dependencies. That was puzzling. Why would I need that many dependencies to automate releases? We hear of new supply-chain attacks almost weekly and I just wasn't willing to take that risk.

This is true of other options in the release workflow, too. git-cliff-core throws warnings left and right under cargo-deny. It pulls hundreds of deps into the codebase to generate a changelog. I just couldn't accept that as the best solution.


The Solution

How It Works

I couldn't afford to stop my core work to build this. So on weekends, or when I needed a break from the low-level code, I started learning more about Cargo and the Rust tooling ecosystem. I dug into Meta's guppy-rs and tried to understand it at a lower level. I started to understand the trade-offs and the history that led us here. Ultimately, I coded cargo-rail, and now — after a year of thought, six months of planning, and a few months hacking — it's ready to share with the community. It's time for feedback.

cargo-rail is built on two things all Rust developers share: git and Cargo. Regardless of how or where you develop, we all have this in common. This is true whether you're using JJ or Git, GitHub, GitLab, or Codeberg — cargo-rail works the same everywhere. This baseline allowed me to avoid pulling in gix or libgit2, which helped keep the dependency graph small. The graph portion of the codebase is built with petgraph, an outstanding open-source library; the parsing portion is built with winnow, another excellent crate.

At a high level, cargo-rail covers four workflows:

  • Unify — keep your dependency graph clean, deduplicated, and honest about MSRV.
  • Affected / Test — find which crates changed and only run tests where it matters.
  • Split / Sync — extract crates with full git history and keep them in sync with the monorepo.
  • Release — plan and publish dependency-order releases with a minimal supply-chain footprint.
cargo install cargo-rail
# or via cargo-binstall for prebuilt binaries
cargo binstall cargo-rail
Enter fullscreen mode Exit fullscreen mode

Why It’s Different

  • Single Cargo subcommand, no daemon, no external scheduler.
  • Built on Cargo’s resolved graph, not just manifest syntax or path filters.
  • Uses your system git directly; no libgit2 or gitoxide.
  • 11 core dependencies (55 resolved in the release binary) instead of hundreds.

The rest of this post walks through the four workflows in detail.


Workflow A: Dependency Unification — Optimize Your Dependency Graph

Commands: cargo rail init, cargo rail unify

cargo rail init                    # generates .config/rail.toml
cargo rail unify --check           # preview changes (CI-safe, exits 1 if changes needed)
cargo rail unify                   # apply changes (auto-backups on first run)
cargo rail unify sync              # re-detect targets, update rail.toml, clean builds 
Enter fullscreen mode Exit fullscreen mode

What unify does for each configured target triple:

  • Unifies versions based on what Cargo actually resolved (no syntax parsing).
  • Computes MSRV from the resolved dependency graph and writes to [workspace.package].rust-version. This means the MSRV is the floor across your resolved dependencies. cargo-rail doesn't run compilation checks because it doesn't need to.
  • Prunes dead features that are never enabled automatically.
  • Detects/removes unused deps (opt-in via config) automatically.
  • Pins transitives at the workspace root (configurable) — a direct cargo-hakari replacement.

Wire cargo rail unify sync into local dev checks or pre-commit hooks. The graph stays clean. Always.

This solved my first problem. Build graphs locally and in CI were measurably faster, leaner, and simpler to reason about. It felt like a natural extension of Cargo.


Workflow B: Change Detection — Test Only What Changed

Commands: cargo rail affected, cargo rail test

Originally, I used the paths-filter GitHub Action and a mountain of custom shell scripts. It kind of worked, but it was unmaintainable across my various testing variants. My sanitizers in CI were running across ~1700 tests each, and there were 3–4 of them for each target triple. I'm building for 9 targets, 5 of which run the testing suite in CI. It was insane.

cargo rail affected                    # show affected crates
cargo rail affected -f names-only      # just names (for scripting)
cargo rail test                        # run tests for affected crates
cargo rail test --all                  # override: test the whole workspace
Enter fullscreen mode Exit fullscreen mode

The rail.toml has a [change-detection] section for configuring specific actions or policies. For instance, I configured it to run just the checks across the codebase if ./scripts/checks/check.sh changed. This approach is more nuanced than plain paths — it's git × cargo × config for each codebase. It's a fully customizable change-detection system that's aware of your codebase.

GitHub Action

cargo-rail-action wraps this for CI:

jobs:
  detect:
    outputs:
      count: ${{ steps.rail.outputs.count }}
      docs-only: ${{ steps.rail.outputs.docs-only }}
      rebuild-all: ${{ steps.rail.outputs.rebuild-all }}
      crates: ${{ steps.rail.outputs.crates }}
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
      - uses: loadingalias/cargo-rail-action@latest
        id: rail
        with:
          since: ${{ github.event.before }}

  test-affected:
    needs: [detect]
    if: needs.detect.outputs.docs-only != 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo nextest run -p ${{ needs.detect.outputs.crates }}
Enter fullscreen mode Exit fullscreen mode

I instantly noticed a huge difference. My GitHub Actions minutes fell; my development velocity increased significantly. I was now checking, testing, and benchmarking only the code that actually changed. The first time I ran just check locally and saw rustfmt, clippy, audit, etc. run across a few crates I'd modified made all of the work worth it. As a solo dev, the resources I consume in CI are a primary expense — this cut them by 60–80% overnight and provided stability.

Try it now: If you're curious about the change detection alone, you can run cargo install cargo-rail && cargo rail init && cargo rail affected in any Rust workspace to see what it detects. No commitment, no changes to your files.


Workflow C: Split & Sync — Split Crates and Sync Repos with Full History

Commands: cargo rail split, cargo rail sync

This is where I needed to really stop and think about what I was doing. Google's Copybara is widely used for a reason: code transforms aren't simple. TOML manipulation, merge conflicts under bidirectional sync, splitting crates with full history — this is all critical transformation work and very high-risk. If it gets messed up, code, history, or workflows can be lost.

cargo rail split init my-crate         # add split config to rail.toml for one or many crates
cargo rail split run crate --check     # preview the split across the configured crates
cargo rail split run crate             # execute (extracts full git history)
cargo rail sync crate                  # bidirectional sync w/ 3-way conflict resolution
cargo rail sync crate --to-remote      # monorepo → split repo (direct to main)
cargo rail sync crate --from-remote    # split repo → monorepo (creates PR branch)
Enter fullscreen mode Exit fullscreen mode

Design decision: Syncing from monorepo to split repos assumes the monorepo is canonical, so we push to main directly. Coming back into the monorepo is different — the default behavior creates a PR branch that must be reviewed and merged. If merge conflicts arise, you can configure 3-way merge resolution: ours, theirs, or manual — or use flags in the initial cargo rail sync crate command.

This gives us:

  • Clean extraction of crates with full git history.
  • A way to share OSS crates while keeping closed-source code in the canonical repo.
  • A workflow for teams to split crates for independent work and sync back via 3-way merges.
  • A single development workspace with the freedom to release, publish, or deploy work however you deem fit, without one crate defining the others.

Three modes:

  • Single: One crate → new repo
  • Combined: Multiple crates → new repo
  • Workspace: Multiple crates → new monorepo

Workflow D: Release — Automate Safe, Minimal Releases

Commands: cargo rail release

I didn't need to write this last piece. I did it purely because the supply-chain story across the OSS community isn't great. Our monorepos and workspaces are critical — even personal projects. We should know what dependencies we're using and why. I don't like to use tools that pull in hundreds or even thousands of deps with little reason.

cargo rail release init crate               # define release plan
cargo rail release check crate              # fast validation
cargo rail release check crate --extended   # dry-run + MSRV check (dependency floor)
cargo rail release run crate --check        # preview release plan
cargo rail release run crate --bump minor   # execute: version, changelog, tag, publish
Enter fullscreen mode Exit fullscreen mode

Release automatically determines publishing order based on the dependency graph. Configuration lives in rail.toml and is adjustable for single-crate, non-workspace projects:

[release]
tag_prefix = "v"
tag_format = "{crate}-{prefix}{version}"
require_clean = true
changelog_path = "CHANGELOG.md"
Enter fullscreen mode Exit fullscreen mode

Full release, tag, version, publish, and changelog — light, simple, and boring. Exactly what I wanted in the release story.

I've also released cargo-rail using cargo-rail.


Minimal Attack Surface

I care about supply-chain security. For a while now, I've felt like crates.io has a major supply-chain issue staring us in the face. In my opinion, monorepo tooling should be tightly monitored and minimally dependent on other codebases. A single oversight or bad choice could poison a huge number of Rust monorepos using the tool. A tool like this needed to have the smallest attack surface it could realistically have.

cargo-rail relies on 11 core dependencies, or 55 resolved dependencies (release binary), in total.

Compare that to competing tooling where you're often looking at hundreds of dependencies scattered across multiple tools for something as straightforward as a release workflow or changelog generation.


Real-World Validation

I've tested across a variety of Rust monorepos from talented teams. I deliberately chose these codebases because of their quality. Here are a handful of the results from cargo rail unify:

Repo Members Deps Unified Dead Features Notes
tikv 72 61 3 Largest stress test
meilisearch 19 46 1 Significant unification
helix-db 6 18 0 Growing project
helix 12 16 1 Editor workspace
tokio 10 10 0 Core ecosystem
ripgrep 10 9 6 CLI baseline
polars 33 2 9 Already clean
ruff 43 0 0 Already unified
codex 49 0 0 Already unified

Each repo above links to a fork with cargo-rail fully configured for unification and change-detection. You can clone them and run side-by-side comparisons.

Demo videos: github.com/loadingalias/cargo-rail/tree/main/examples


What I Replaced

In my own workspace, cargo-rail let me remove:

Before After
cargo-hakari cargo rail unify with pin_transitives = true
cargo-features-manager cargo rail unify with prune_dead_features = true
cargo-udeps, cargo-machete, cargo-shear cargo rail unify with detect_unused = true
cargo-msrv cargo rail unify with msrv = true
dorny/paths-filter + 1k LoC shell cargo-rail-action
release-plz, cargo-release, git-cliff cargo rail release
Google Copybara / git-subtree cargo rail split + cargo rail sync

Results:

  • Change detection alone removed 1k lines from ./scripts/.
  • CI costs dropped 80%+ immediately.
  • Workflow is smoother, leaner, and easier to reason about.

I now depend on just 11 core deps to cover all of the above in my codebase. This was a major win for my peace of mind and helps me sleep easier knowing the supply-chain attack surface is much smaller.


When Not to Use cargo-rail

  • If you're already on Bazel, Buck2, or Moon and you like that model — cargo-rail isn't the right tool.
  • If you have a single crate with simple CI — cargo-rail is more machinery than you need.
  • If you want a general-purpose build system for a polyglot monorepo — cargo-rail is Rust-only, layered on Cargo.

cargo-rail fits if you have a Rust workspace and want to keep using Cargo while solving dependency unification, change detection, split/sync, and release workflows.


Design Decisions

Multi-Target Resolution: Most tools run cargo metadata once. Real monorepos target 6–9 different triples. cargo-rail runs cargo metadata --filter-platform per target in parallel, then computes feature intersections (not unions) with guardrails. If cargo-rail marks something unused, it's unused across all your targets.

Resolution-Based, Not Syntax-Based: cargo-rail uses what Cargo actually resolved, not what's written in manifests. This is the difference between guessing and knowing.

System Git: I use your system git binary directly — no libgit2, no gitoxide. Real git, real history, deterministic SHAs. There isn't any meaningful performance hit here, in my experience.

Lossless TOML: Uses toml_edit to preserve comments, formatting, and hand-tuned layout. Your manifests stay readable.


Try It

You don't have to restructure anything. Start small.

If you only do one thing after reading this, run:

cargo install cargo-rail

cargo rail init
cargo rail config validate

cargo rail unify --check    # preview unification for your workspace
Enter fullscreen mode Exit fullscreen mode

This runs in dry-run mode, creates no changes, and gives you a concrete “before vs after” view of your dependency graph.

If it looks reasonable:

cargo rail unify
Enter fullscreen mode Exit fullscreen mode

If it breaks your codebase:

cargo rail unify undo
Enter fullscreen mode Exit fullscreen mode

Then, when you’re comfortable:

# Test affected crates locally
cargo rail test
Enter fullscreen mode Exit fullscreen mode

Migrating from cargo-hakari?

git checkout -b migrate-to-rail
# Remove workspace-hack crate and hakari.toml
cargo rail init
# Edit rail.toml: set pin_transitives = true
cargo rail unify --check
cargo rail unify
cargo check --workspace && cargo test --workspace
Enter fullscreen mode Exit fullscreen mode

Full migration guide: docs/migrate-hakari.md


Roadmap

Short version: I want cargo-rail to stay small, boring, and focused on Rust workspaces.

Near-term:

  • More first-class support for large public workspaces (TiKV, Tokio, Polars, etc.) via configuration presets and best-practice examples.
  • Richer change-detection categories and outputs for CI (benchmarks, fuzz targets, custom categories).
  • Additional safety rails around split/sync and release (better diff views, dry-run UX, guardrails).

Longer-term:

  • Make it easier to plug cargo-rail into existing tooling (nextest, cargo-deny, custom check pipelines) without hard-coding integrations.
  • Explore limited integrations with other monorepo tooling (e.g., Bazel/Buck-style worlds) without turning cargo-rail into a general-purpose build system.

I’ll keep the roadmap in the repo up to date; feedback and sharp critiques are welcome.


Get Involved

  • Try it: cargo rail unify --check on your workspace.
  • Use it: Wire cargo-rail-action into CI.
  • Star it: github.com/loadingalias/cargo-rail — helps other teams find it.
  • Critique it: If a design decision is wrong, open an issue. I want sharp feedback.

If you maintain a public Rust workspace and want a low-risk way to evaluate cargo-rail, I'm happy to open a small PR wiring it into CI so you can see the impact — no obligation to merge.

What's your biggest Rust monorepo pain point? I'd love to hear how you're handling dependency drift, CI bloat, or crate extraction.

Star on GitHub


Links:

Built by @loadingalias

Top comments (0)