ezgitx 0.1.0

Agent-native multi-repo git CLI: JSONL output, zero interactivity, cross-repo dependency awareness
use std::collections::BTreeSet;
use std::time::Instant;

use crate::errors::{ErrorCode, ErrorInfo, aggregate_exit};
use crate::exec::{self, RUN_HEADERS, RunSummary};
use crate::output::Emitter;
use crate::state;
use crate::workspace::{Repo, Workspace};

/// `ezgitx run` (PRD §5.3, §9.4). Takes no locks (§7): commands are
/// user-supplied and arbitrarily long.
pub async fn run(
    ws: &Workspace,
    targets: Vec<Repo>,
    cmd: Option<String>,
    with_deps: bool,
    jobs: usize,
    max_bytes: usize,
    human: bool,
) -> i32 {
    let started = Instant::now();
    let target_names: BTreeSet<String> = targets.iter().map(|r| r.name.clone()).collect();

    // --with-deps expands targets with their *stale* transitive upstreams
    // (PRD §9.4); fresh upstreams are skipped. Without it, a single
    // unordered wave — staleness never changes what executes. The union of
    // all targets' upstreams is probed concurrently in one wave so shared
    // dependencies are checked once.
    let waves: Vec<Vec<String>> = if with_deps {
        let mut upstreams: BTreeSet<String> = BTreeSet::new();
        for name in &target_names {
            upstreams.extend(crate::graph::transitive_upstreams(ws, name));
        }
        // Targets always execute; only non-target upstreams need probing.
        upstreams.retain(|u| !target_names.contains(u));
        let mut set = target_names.clone();
        set.extend(state::filter_stale(ws, &upstreams, max_bytes).await);
        crate::graph::topo_waves(ws, &set)
    } else {
        vec![target_names.iter().cloned().collect()]
    };

    let mut emitter = Emitter::new(human, RUN_HEADERS);
    let command_for = |repo: &Repo| -> Result<String, ErrorInfo> {
        // Expanded upstreams always run their default_cmd (§9.4); explicit
        // targets use the given command when present.
        if let Some(c) = &cmd {
            if target_names.contains(&repo.name) {
                return Ok(c.clone());
            }
        }
        repo.default_cmd.clone().ok_or_else(|| {
            ErrorInfo::new(
                ErrorCode::NoDefaultCmd,
                format!("repo {:?} has no default_cmd", repo.name),
            )
        })
    };

    let (passed, failed) =
        exec::execute_waves(ws, waves, command_for, jobs, max_bytes, true, &mut emitter).await;

    let summary = RunSummary::new(passed, failed, started.elapsed().as_millis() as u64);
    emitter.emit_summary(&summary, summary.human());
    emitter.finish();
    aggregate_exit(failed > 0, false)
}