When you maintain multiple Rust crates that depend on each other, routine tasks multiply. Checking status across repos, validating dependency versions, running tests everywhere, publishing in the right order — doing these by hand doesn't scale.
This guide covers practical shell scripts and Just recipes for automating a multi-crate workspace. The patterns work for any collection of related Rust repos, whether they live in a Cargo workspace or as separate repositories.
Consider maintaining four crates where gainlineup depends on rfconversions and touchstone, and linkbudget depends on rfconversions. You need to:
Each of these is simple alone. Together, they're a system.
The most frequently used script shows the health of every Git repo at a glance:
#!/usr/bin/env bash
# repo-status.sh — Quick health check across all repos
set -uo pipefail
DEV_DIR="$HOME/Development"
printf "%-18s %-20s %-12s %-12s %-10s %s\n" \
"REPO" "BRANCH" "DIRTY" "UNPUSHED" "OPEN PRs" "STALE BRANCHES"
printf '%.0s─' {1..90}; echo
for repo_dir in "$DEV_DIR"/*/; do
[ -d "$repo_dir/.git" ] || continue
repo=$(basename "$repo_dir")
cd "$repo_dir"
branch=$(git branch --show-current 2>/dev/null || echo "detached")
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
dirty="yes"
else
dirty="—"
fi
unpushed=$(git log --oneline "@{u}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
[ "$unpushed" = "0" ] && unpushed="—"
open_prs=$(gh pr list --repo "iancleary/$repo" --json number \
--jq 'length' 2>/dev/null || echo "?")
[ "$open_prs" = "0" ] && open_prs="—"
stale=$(git branch --merged main 2>/dev/null \
| grep -v '^\*\|main\|master' | wc -l | tr -d ' ')
[ "$stale" = "0" ] && stale="—"
printf "%-18s %-20s %-12s %-12s %-10s %s\n" \
"$repo" "$branch" "$dirty" "$unpushed" "$open_prs" "$stale"
done
Run it and you get a table like:
REPO BRANCH DIRTY UNPUSHED OPEN PRs STALE BRANCHES
──────────────────────────────────────────────────────────────────────────────────────────────
career main — — — —
docs main — — — 2
gainlineup main — — — —
linkbudget main — — 1 —
rfconversions main yes — — —
At a glance: rfconversions has uncommitted changes, linkbudget has an open PR, and docs has two stale branches to clean up.
Detect when your local version differs from what's published on crates.io:
#!/usr/bin/env bash
# crate-versions.sh — Compare local vs published crate versions
set -uo pipefail
CRATES=("rfconversions" "touchstone" "gainlineup" "linkbudget")
DEV_DIR="$HOME/Development"
printf "%-20s %-12s %-12s %s\n" "CRATE" "LOCAL" "CRATES.IO" "STATUS"
printf '%.0s─' {1..60}; echo
for crate in "${CRATES[@]}"; do
dir="$DEV_DIR/$crate"
[ -f "$dir/Cargo.toml" ] || continue
local_ver=$(grep '^version' "$dir/Cargo.toml" \
| head -1 | sed 's/.*"\(.*\)".*/\1/')
published=$(cargo search "$crate" --limit 1 2>/dev/null \
| grep "^$crate " | sed 's/.*= "\(.*\)".*/\1/')
if [ "$local_ver" = "$published" ]; then
status="✓ up to date"
else
status="⬆ needs publish"
fi
printf "%-20s %-12s %-12s %s\n" \
"$crate" "$local_ver" "${published:-?}" "$status"
done
Output:
CRATE LOCAL CRATES.IO STATUS
────────────────────────────────────────────────────────────────
rfconversions 0.7.2 0.7.0 ⬆ needs publish
touchstone 0.11.4 0.11.4 ✓ up to date
gainlineup 0.21.0 0.21.0 ✓ up to date
linkbudget 0.5.3 0.5.3 ✓ up to date
Cross-crate dependency mismatches cause subtle bugs. This script checks what version each crate declares for its dependencies:
#!/usr/bin/env bash
# dep-check.sh — Cross-crate dependency matrix
set -uo pipefail
CRATES=("rfconversions" "touchstone" "gainlineup" "linkbudget")
DEV_DIR="$HOME/Development"
echo "=== Dependency Matrix ==="
for crate in "${CRATES[@]}"; do
toml="$DEV_DIR/$crate/Cargo.toml"
[ -f "$toml" ] || continue
echo ""
echo "$crate dependencies:"
for dep in "${CRATES[@]}"; do
[ "$dep" = "$crate" ] && continue
ver=$(grep "^$dep" "$toml" 2>/dev/null \
| sed 's/.*version *= *"\([^"]*\)".*/\1/' | head -1)
[ -n "$ver" ] && echo " $dep = $ver"
done
done
echo ""
echo "=== Cargo.lock Freshness ==="
for crate in "${CRATES[@]}"; do
dir="$DEV_DIR/$crate"
[ -f "$dir/Cargo.lock" ] || continue
lock_age=$(( ($(date +%s) - $(stat -f %m "$dir/Cargo.lock")) / 86400 ))
echo " $crate: Cargo.lock is ${lock_age}d old"
done
This catches the scenario where gainlineup pins rfconversions = "0.6.0" but you've already published 0.7.0. Without this check, you'd discover the mismatch at publish time.
Running cargo test across five crates and counting results manually is tedious:
#!/usr/bin/env bash
# test-summary.sh — Run tests across all crates, report counts
set -uo pipefail
CRATES=("rfconversions" "touchstone" "gainlineup" "linkbudget" "regulatory-rf")
DEV_DIR="$HOME/Development"
total=0
printf "%-20s %-10s %-8s\n" "CRATE" "VERSION" "TESTS"
printf '%.0s─' {1..42}; echo
for crate in "${CRATES[@]}"; do
dir="$DEV_DIR/$crate"
[ -f "$dir/Cargo.toml" ] || continue
ver=$(grep '^version' "$dir/Cargo.toml" \
| head -1 | sed 's/.*"\(.*\)".*/\1/')
count=$(cd "$dir" && cargo test 2>&1 \
| grep "test result:" \
| awk '{sum += $4} END {print sum}')
total=$((total + count))
printf "%-20s %-10s %-8s\n" "$crate" "$ver" "$count"
done
printf '%.0s─' {1..42}; echo
printf "%-20s %-10s %-8s\n" "TOTAL" "" "$total"
A quick mode counts #[test] annotations without compiling — useful for fast estimates:
# Quick mode: count test annotations without compiling
for crate in "${CRATES[@]}"; do
dir="$DEV_DIR/$crate"
unit=$(grep -r '#\[test\]' "$dir/src/" 2>/dev/null | wc -l | tr -d ' ')
doc=$(grep -r '```' "$dir/src/" 2>/dev/null \
| grep -v 'no_run\|ignore\|compile_fail' | wc -l | tr -d ' ')
doc=$((doc / 2)) # opening + closing fence pairs
echo "$crate: ~$unit unit + ~$doc doc = ~$((unit + doc))"
done
Crates with inter-dependencies must be published in topological order. Publishing gainlineup before its dependency rfconversions fails:
#!/usr/bin/env bash
# publish-cascade.sh — Ordered publish plan
set -uo pipefail
# Define publish order (leaves first)
PUBLISH_ORDER=(
"rfconversions" # no internal deps
"touchstone" # depends on rfconversions
"gainlineup" # depends on rfconversions + touchstone
"linkbudget" # depends on rfconversions
)
for crate in "${PUBLISH_ORDER[@]}"; do
local_ver=$(grep '^version' "$HOME/Development/$crate/Cargo.toml" \
| head -1 | sed 's/.*"\(.*\)".*/\1/')
published=$(cargo search "$crate" --limit 1 2>/dev/null \
| grep "^$crate " | sed 's/.*= "\(.*\)".*/\1/')
if [ "$local_ver" != "$published" ]; then
echo "PUBLISH: $crate $published → $local_ver"
echo " cd ~/Development/$crate && cargo publish"
else
echo "SKIP: $crate ($local_ver = published)"
fi
done
Before running cargo publish, validate everything:
#!/usr/bin/env bash
# pre-publish.sh — Comprehensive pre-publish checks
set -euo pipefail
CRATE="${1:?Usage: pre-publish.sh <crate-name>}"
DIR="$HOME/Development/$CRATE"
cd "$DIR"
echo "=== Pre-publish: $CRATE ==="
PASS=0; FAIL=0
check() {
if eval "$2" >/dev/null 2>&1; then
echo " ✓ $1"; ((PASS++))
else
echo " ✗ $1"; ((FAIL++))
fi
}
check "On main branch" '[ "$(git branch --show-current)" = "main" ]'
check "Clean working tree" '[ -z "$(git status --porcelain)" ]'
check "No unpushed commits" '[ -z "$(git log @{u}..HEAD --oneline)" ]'
check "Tests pass" 'cargo test --quiet'
check "Clippy clean" 'cargo clippy -- -D warnings'
check "Docs build" 'cargo doc --no-deps --quiet'
check "Dry run succeeds" 'cargo publish --dry-run --quiet'
check "Semver OK" 'cargo semver-checks'
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && echo "Ready to publish! 🚀" || echo "Fix issues first."
A justfile at the workspace root makes these scripts easy to invoke:
# Workspace-level justfile
# Show repo health across all projects
status:
./scripts/repo-status.sh
# Compare local vs published crate versions
crate-versions:
./scripts/crate-versions.sh
# Cross-crate dependency check
dep-check:
./scripts/dep-check.sh
# Run all tests and show summary
test-summary:
./scripts/test-summary.sh
# Quick test count (no compile)
test-summary-quick:
./scripts/test-summary.sh --quick
# Full dashboard
dashboard:
./scripts/crate-versions.sh
@echo ""
./scripts/repo-status.sh
# Pre-publish checks for a specific crate
pre-publish crate:
./scripts/pre-publish.sh {{crate}}
# Show publish order and what needs publishing
publish-cascade:
./scripts/publish-cascade.sh
# Delete merged branches (dry run)
clean-branches:
./scripts/clean-stale-branches.sh
# Delete merged branches (for real)
clean-branches-execute:
./scripts/clean-stale-branches.sh --execute
Now just dashboard gives a full workspace overview, and just pre-publish rfconversions runs all checks before you publish.
Fail fast, fix early. Run just dashboard before starting work. Catch stale branches, unpushed commits, and version mismatches before they compound.
Topological publishing. Always publish leaf crates first. The publish-cascade script encodes this order so you don't have to remember it.
Quick vs. full modes. Test counting without compilation gives a fast estimate. Save full test runs for pre-publish validation.
Color in terminals, clean in logs. Use ANSI colors in interactive scripts but strip them when piping to files or CI. The tput command or [ -t 1 ] check helps:
if [ -t 1 ]; then
RED='\033[0;31m'; RESET='\033[0m'
else
RED=''; RESET=''
fi
Idempotent operations. Every script can run multiple times safely. Status checks don't modify anything. The branch cleaner defaults to dry-run mode.
Start with the problem that annoys you most. If you're constantly running git status across repos, build the status dashboard first. If you keep publishing crates in the wrong order, build the cascade script first.
Each script here is under 100 lines. They compose through the justfile. The whole system grew organically — one script at a time, each solving a real friction point.