mod factory;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use comfy_table::{Cell, Table};
use repolith_cache::SqliteCache;
use repolith_core::manifest::{ActionEntry, Manifest, action_kind};
use repolith_core::plan::{ChangeReason, Plan};
use repolith_core::types::{ActionId, BuildEvent, Ctx, ExecMode};
use repolith_engine::orchestrator::Orchestrator;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio_util::sync::CancellationToken;
use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug)]
#[command(
name = "repolith",
version,
about = "Multi-repo orchestration for Rust ecosystems"
)]
struct Cli {
#[arg(long, default_value = "./repolith.toml", global = true)]
manifest: PathBuf,
#[arg(long, env = "REPOLITH_CACHE_PATH", global = true)]
cache_path: Option<PathBuf>,
#[arg(long, env = "REPOLITH_CACHE", global = true, value_enum, default_value_t = CacheBackend::Sqlite)]
cache: CacheBackend,
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum CacheBackend {
Sqlite,
Neo4j,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Sync(SyncArgs),
Status {
filter: Option<String>,
},
}
#[derive(clap::Args, Debug)]
struct SyncArgs {
#[arg(short, long, default_value_t = num_cpus::get())]
jobs: usize,
#[arg(short = 'k', long)]
keep_going: bool,
#[arg(long)]
explain: bool,
#[arg(long)]
dry_run: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.verbose);
let cancel = CancellationToken::new();
let cancel_for_signal = cancel.clone();
tokio::spawn(async move {
if let Some(reason) = wait_for_shutdown_signal().await {
tracing::info!("{reason} received, cancelling in-flight actions");
cancel_for_signal.cancel();
}
});
match &cli.cmd {
Cmd::Sync(args) => run_sync(&cli, args, cancel).await,
Cmd::Status { filter } => run_status(&cli, cancel, filter.as_deref()).await,
}
}
fn init_tracing(verbosity: u8) {
let level = match verbosity {
0 => "info",
1 => "debug",
_ => "trace",
};
let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| {
format!(
"warn,repolith={level},repolith_engine={level},repolith_cache={level},repolith_actions={level}"
)
});
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(filter))
.with_writer(std::io::stderr)
.try_init();
}
fn load_manifest(path: &PathBuf) -> Result<Manifest> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading manifest `{}`", path.display()))?;
Manifest::from_toml(&text).with_context(|| format!("parsing manifest `{}`", path.display()))
}
fn default_cache_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".repolith")
.join("cache.db")
}
const ENV_ALLOWLIST: &[&str] = &[
"PATH",
"HOME",
"USER",
"SHELL",
"TMPDIR",
"CARGO_HOME",
"RUSTUP_HOME",
"RUSTUP_TOOLCHAIN",
"RUST_LOG",
"RUST_BACKTRACE",
"TZ",
"LANG",
"LC_ALL",
"SSH_AUTH_SOCK",
"XDG_CONFIG_HOME",
];
fn filtered_env() -> std::collections::HashMap<String, String> {
std::env::vars()
.filter(|(k, _)| ENV_ALLOWLIST.iter().any(|allowed| *allowed == k))
.collect()
}
#[cfg(unix)]
async fn wait_for_shutdown_signal() -> Option<&'static str> {
use tokio::signal::unix::{SignalKind, signal};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
tracing::warn!("failed to register SIGTERM handler: {e}");
return tokio::signal::ctrl_c().await.ok().map(|()| "SIGINT");
}
};
tokio::select! {
result = tokio::signal::ctrl_c() => result.ok().map(|()| "SIGINT"),
_ = term.recv() => Some("SIGTERM"),
}
}
#[cfg(not(unix))]
async fn wait_for_shutdown_signal() -> Option<&'static str> {
tokio::signal::ctrl_c().await.ok().map(|()| "SIGINT")
}
async fn open_cache(cli: &Cli) -> Result<Box<dyn repolith_core::cache::Cache>> {
match cli.cache {
CacheBackend::Sqlite => {
let cache_path = cli.cache_path.clone().unwrap_or_else(default_cache_path);
let cache = SqliteCache::open(&cache_path)
.with_context(|| format!("opening cache at `{}`", cache_path.display()))?;
Ok(Box::new(cache))
}
CacheBackend::Neo4j => {
let cfg = repolith_cache::Neo4jConfig::from_env()?;
let cache = repolith_cache::Neo4jCache::connect(&cfg).await?;
Ok(Box::new(cache))
}
}
}
async fn build_orchestrator(
cli: &Cli,
cancel: CancellationToken,
jobs: usize,
mode: ExecMode,
) -> Result<Orchestrator> {
let manifest = load_manifest(&cli.manifest)?;
let manifest_path = cli
.manifest
.canonicalize()
.with_context(|| format!("canonicalize manifest `{}`", cli.manifest.display()))?;
let base_dir = manifest_path
.parent()
.map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf);
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(jobs.max(1)));
let hooks = factory::CliFederationHooks::new(mode, std::sync::Arc::clone(&sem));
let fctx = factory::FactoryCtx {
base_dir,
chain: vec![manifest_path],
mode,
sem: std::sync::Arc::clone(&sem),
hooks,
};
let actions = factory::build_actions_from_manifest(&manifest, &fctx)?;
let cache = open_cache(cli).await?;
let base_ctx = Ctx {
cancel,
workdir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
env: filtered_env(),
};
let mut builder = Orchestrator::builder()
.cache_boxed(cache)
.manifest(manifest)
.max_parallelism(jobs)
.shared_semaphore(sem)
.base_ctx(base_ctx);
for action in actions {
builder = builder.register_boxed(action);
}
builder.build().map_err(anyhow::Error::from)
}
async fn run_sync(cli: &Cli, args: &SyncArgs, cancel: CancellationToken) -> Result<()> {
let mode = if args.keep_going {
ExecMode::KeepGoing
} else {
ExecMode::FailFast
};
let mut orch = build_orchestrator(cli, cancel, args.jobs, mode).await?;
let plan = orch.compute_plan().await?;
if args.explain || args.dry_run {
if plan.reasons().is_empty() {
println!("up to date — no stale actions");
} else {
for (id, reason) in plan.reasons() {
println!("• {id}: {reason}");
}
}
}
if args.dry_run {
println!("dry-run: {} action(s) would run", plan.reasons().len());
return Ok(());
}
match orch.execute_plan(&plan, mode).await {
Ok(events) => {
print_events(&events);
Ok(())
}
Err(repolith_engine::orchestrator::ExecError::LayerFailed { events }) => {
print_events(&events);
anyhow::bail!("sync failed: see events above");
}
Err(other) => Err(anyhow::Error::from(other)),
}
}
async fn run_status(cli: &Cli, cancel: CancellationToken, filter: Option<&str>) -> Result<()> {
let orch = build_orchestrator(cli, cancel, num_cpus::get(), ExecMode::FailFast).await?;
let plan = orch.compute_plan().await?;
match filter {
None => {
print_status_table(&plan);
Ok(())
}
Some(f) => print_status_detail(&orch, &plan, f).await,
}
}
fn print_status_table(plan: &Plan) {
let reasons: HashMap<_, _> = plan.reasons().iter().collect();
let mut table = Table::new();
table.set_header(vec!["Action", "Status", "Reason"]);
for id in plan.flat_topo() {
if let Some(reason) = reasons.get(id) {
table.add_row(vec![
Cell::new(id.to_string()),
Cell::new("stale"),
Cell::new(reason.to_string()),
]);
} else {
table.add_row(vec![
Cell::new(id.to_string()),
Cell::new("up-to-date"),
Cell::new("—"),
]);
}
}
println!("{table}");
}
const LABEL_W: usize = 12;
fn field(label: &str, value: &str) {
let mut lines = value.lines();
println!(" {:<LABEL_W$}{}", label, lines.next().unwrap_or(""));
for line in lines {
println!(" {:<LABEL_W$}{line}", "");
}
}
async fn print_status_detail(orch: &Orchestrator, plan: &Plan, filter: &str) -> Result<()> {
let matches: Vec<&ActionId> = plan
.flat_topo()
.filter(|id| id.0.contains(filter))
.collect();
if matches.is_empty() {
anyhow::bail!(
"no action id contains `{filter}` — run `repolith status` with no argument to list them"
);
}
let reasons = plan.reasons();
for (n, id) in matches.iter().enumerate() {
if n > 0 {
println!();
}
println!("{id}");
if let Some(reason) = reasons.get(*id) {
field("state", "stale");
field("reason", &reason.to_string());
} else {
field("state", "up-to-date");
}
print_last_run(orch, id).await;
print_hashes(orch, plan, id).await;
print_action_facts(orch, reasons, id).await;
}
Ok(())
}
async fn print_last_run(orch: &Orchestrator, id: &ActionId) {
let Some(rec) = orch.cache().last_record(id).await else {
field("last run", "never — nothing cached for this id");
return;
};
let (outcome, ms) = match &rec.event {
BuildEvent::Success { ms, .. } => ("succeeded", *ms),
BuildEvent::Failed { ms, .. } => ("failed", *ms),
};
let when = rec.recorded_at.map_or_else(
|| "date not recorded by this cache".to_string(),
human_ago,
);
field(
"last run",
&format!("{outcome} in {}, {when}", human_ms(ms)),
);
if let BuildEvent::Failed { error, .. } = &rec.event {
field("error", &error.to_string());
}
}
async fn print_hashes(orch: &Orchestrator, plan: &Plan, id: &ActionId) {
let current = plan.input_hash(id);
let cached = orch.cache().last_record(id).await.map(|r| match r.event {
BuildEvent::Success { input, .. } | BuildEvent::Failed { input, .. } => input,
});
let value = match (cached, current) {
(Some(c), Some(n)) => format!("cached {c}\ncurrent {n}"),
(None, Some(n)) => format!("current {n}"),
(Some(c), None) => format!("cached {c}"),
(None, None) => return,
};
field("input", &value);
}
async fn print_action_facts(
orch: &Orchestrator,
reasons: &HashMap<ActionId, ChangeReason>,
id: &ActionId,
) {
if let Some(action) = orch.actions().iter().find(|a| a.id() == *id) {
let deps = action.deps();
if deps.is_empty() {
field("deps", "none");
} else {
let lines: Vec<String> = deps
.iter()
.map(|d| {
let state = if reasons.contains_key(d) {
"stale"
} else {
"up-to-date"
};
format!("{d} ({state})")
})
.collect();
field("deps", &lines.join("\n"));
}
let present = action.output_present(orch.base_ctx()).await;
field("artifact", if present { "present" } else { "missing" });
}
let Some(manifest) = orch.manifest.as_ref() else {
return;
};
let Some((node, entry)) = manifest_entry(manifest, id) else {
return;
};
match (&node.path, &node.git) {
(Some(p), _) => field("source", &format!("path {}", p.display())),
(None, Some(g)) => field("source", &format!("git {g}")),
(None, None) => {}
}
field("action", action_kind(entry));
print_entry_fields(entry);
}
fn print_entry_fields(entry: &ActionEntry) {
match entry {
ActionEntry::GitClone => {}
ActionEntry::CargoInstall {
crate_name,
package,
profile,
features,
install_to,
} => {
if let Some(c) = crate_name {
field("bin", c);
}
if let Some(p) = package {
field("package", p);
}
field(
"profile",
profile.as_deref().unwrap_or("release (cargo default)"),
);
if !features.is_empty() {
field("features", &features.join(", "));
}
if let Some(d) = install_to {
field("install to", &d.display().to_string());
}
}
ActionEntry::Docker {
tag,
dockerfile,
context,
} => {
field("tag", tag);
if let Some(d) = dockerfile {
field("dockerfile", &d.display().to_string());
}
if let Some(c) = context {
field("context", &c.display().to_string());
}
}
ActionEntry::Repolith { manifest } => {
field(
"manifest",
&manifest
.as_ref()
.map_or_else(|| "repolith.toml".to_string(), |p| p.display().to_string()),
);
}
}
}
fn manifest_entry<'m>(
manifest: &'m Manifest,
id: &ActionId,
) -> Option<(&'m repolith_core::manifest::NodeEntry, &'m ActionEntry)> {
let flat = manifest
.nodes
.iter()
.flat_map(|n| n.actions.iter().map(move |a| (n, a)));
manifest
.action_ids()
.into_iter()
.zip(flat)
.find_map(|(candidate, pair)| (candidate == *id).then_some(pair))
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
fn human_ago(recorded_ms: u64) -> String {
let secs = now_ms().saturating_sub(recorded_ms) / 1000;
let (n, unit) = match secs {
0 => return "just now".to_string(),
1..=89 => (secs, "second"),
90..=5399 => ((secs + 30) / 60, "minute"),
5400..=129_599 => ((secs + 1800) / 3600, "hour"),
_ => ((secs + 43_200) / 86_400, "day"),
};
format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
}
fn human_ms(ms: u64) -> String {
if ms < 1000 {
format!("{ms} ms")
} else if ms < 60_000 {
format!("{}.{} s", ms / 1000, (ms % 1000) / 100)
} else {
format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
}
}
fn print_events(events: &[BuildEvent]) {
for ev in events {
match ev {
BuildEvent::Success { id, ms, .. } => println!("OK {id} ({ms} ms)"),
BuildEvent::Failed { id, error, ms, .. } => {
println!("FAIL {id} ({ms} ms): {error}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durations_read_like_durations() {
assert_eq!(human_ms(0), "0 ms");
assert_eq!(human_ms(999), "999 ms");
assert_eq!(human_ms(1000), "1.0 s");
assert_eq!(human_ms(26_800), "26.8 s");
assert_eq!(human_ms(59_999), "59.9 s");
assert_eq!(human_ms(60_000), "1 min 0 s");
assert_eq!(human_ms(3_725_000), "62 min 5 s");
}
#[test]
fn ages_round_to_the_nearest_sensible_unit() {
let ago = |secs: u64| human_ago(now_ms().saturating_sub(secs * 1000));
assert_eq!(ago(0), "just now");
assert_eq!(ago(1), "1 second ago");
assert_eq!(ago(45), "45 seconds ago");
assert_eq!(ago(120), "2 minutes ago");
assert_eq!(ago(3600), "60 minutes ago");
assert_eq!(ago(5400), "2 hours ago");
assert_eq!(ago(86_400 * 3), "3 days ago");
}
#[test]
fn a_future_timestamp_reads_as_just_now() {
assert_eq!(human_ago(now_ms() + 3_600_000), "just now");
}
#[test]
fn manifest_entry_finds_the_declaring_node() {
let toml = r#"
[orchestrator]
schema_version = "0.1"
name = "t"
[[node]]
id = "alpha"
path = "/tmp/a"
[[node.action]]
kind = "git-clone"
[[node.action]]
kind = "cargo-install"
crate = "alpha"
"#;
let m = Manifest::from_toml(toml).expect("fixture parses");
let (node, entry) = manifest_entry(&m, &ActionId("alpha::cargo-install::1".into()))
.expect("second action of alpha");
assert_eq!(node.id, "alpha");
assert!(
matches!(entry, ActionEntry::CargoInstall { crate_name, .. }
if crate_name.as_deref() == Some("alpha")),
"index 1 must resolve to the cargo-install, not the git-clone"
);
assert!(
manifest_entry(&m, &ActionId("alpha::cargo-install::0".into())).is_none(),
"the index is part of the id; a wrong one must not match"
);
}
#[test]
fn multi_line_values_indent_to_the_value_column() {
let pad = " ".repeat(2 + LABEL_W);
assert_eq!(pad.len(), 14);
assert_eq!(format!(" {:<LABEL_W$}", "error").len(), pad.len());
}
}