balls 0.5.11

Git-native task tracker for parallel agent workflows
Documentation
//! Unit tests for [`crate::speculate_run`] — the whole pass against fixture
//! repos with a stub gate, proving the chain rules the design states: strict
//! head-first order, stop on conflict/fail/spent, sweep of the unsealed, and
//! the no-leftover invariants.

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;

use tempfile::TempDir;

use super::run;
use crate::speculate_queue::{enqueue, queue};

fn git(repo: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(["-c", "user.name=t", "-c", "user.email=t@t"])
        .args(args)
        .output()
        .unwrap();
    assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

/// A repo whose gate files exist (the fingerprint reads them), with two
/// cleanly-stacking work branches and one that conflicts with main.
struct Fx {
    _tmp: TempDir,
    root: PathBuf,
    scratch: PathBuf,
    territory: PathBuf,
    gate: PathBuf,
    log: PathBuf,
}

fn fx(gate_exit: &str) -> Fx {
    let tmp = TempDir::new().unwrap();
    let root = tmp.path().join("repo");
    fs::create_dir_all(root.join("scripts")).unwrap();
    for rel in ["scripts/pre-commit", "scripts/check-line-lengths.sh", "scripts/check-coverage.sh"] {
        fs::write(root.join(rel), "#!/bin/sh\n").unwrap();
    }
    fs::write(root.join("Makefile"), "all:\n").unwrap();
    git(&root, &["init", "-q", "-b", "main"]);
    fs::write(root.join("shared"), "line\n").unwrap();
    git(&root, &["add", "-A"]);
    git(&root, &["commit", "-q", "-m", "base"]);
    let base = git(&root, &["rev-parse", "HEAD"]);
    for (branch, file) in [("work/a", "fa"), ("work/b", "fb")] {
        git(&root, &["checkout", "-q", "-b", branch, &base]);
        fs::write(root.join(file), branch).unwrap();
        git(&root, &["add", "-A"]);
        git(&root, &["commit", "-q", "-m", branch]);
    }
    git(&root, &["checkout", "-q", "-b", "work/hostile", &base]);
    fs::write(root.join("shared"), "hostile\n").unwrap();
    git(&root, &["add", "-A"]);
    git(&root, &["commit", "-q", "-m", "hostile"]);
    git(&root, &["checkout", "-q", "main"]);
    fs::write(root.join("shared"), "moved\n").unwrap();
    git(&root, &["add", "-A"]);
    git(&root, &["commit", "-q", "-m", "moved"]);
    let log = tmp.path().join("gate.log");
    let gate = tmp.path().join("gate.sh");
    fs::write(&gate, format!("#!/bin/sh\npwd >> {}\nexit {gate_exit}\n", log.display())).unwrap();
    fs::set_permissions(&gate, fs::Permissions::from_mode(0o755)).unwrap();
    Fx {
        scratch: tmp.path().join("scratch"),
        territory: tmp.path().join("territory"),
        _tmp: tmp,
        root,
        gate,
        log,
    }
}

fn pass(f: &Fx, builds: usize) -> Vec<String> {
    run(
        &f.root,
        &f.scratch,
        &f.territory,
        "rustc test",
        "main",
        f.gate.to_str().unwrap(),
        builds,
    )
    .unwrap()
}

fn gate_runs(f: &Fx) -> usize {
    fs::read_to_string(&f.log).map_or(0, |s| s.lines().count())
}

#[test]
fn builds_head_first_then_hits_and_the_second_pass_spends_nothing() {
    let f = fx("0");
    enqueue(&f.root, "a", Some("2026-01-01T10:00:00Z")).unwrap();
    enqueue(&f.root, "b", Some("2026-01-01T11:00:00Z")).unwrap();
    let report = pass(&f, 10);
    assert_eq!(report.len(), 3, "{report:?}");
    assert!(report[0].starts_with("built a ") && report[0].ends_with(" pass"), "{report:?}");
    assert!(report[1].starts_with("built b "), "{report:?}");
    assert!(report[2].starts_with("adopted hostile "), "the stray tip is adopted: {report:?}");
    assert_eq!(gate_runs(&f), 2);
    let again = pass(&f, 10);
    assert!(again[0].starts_with("hit a ") && again[1].starts_with("hit b "), "{again:?}");
    assert_eq!(gate_runs(&f), 2, "hits spend no gates");
    let listing = git(&f.root, &["worktree", "list", "--porcelain"]);
    assert_eq!(listing.matches("worktree ").count(), 1, "no build worktrees survive");
}

#[test]
fn budget_defers_and_a_failing_gate_stops_the_chain() {
    let f = fx("1");
    enqueue(&f.root, "a", Some("2026-01-01T10:00:00Z")).unwrap();
    enqueue(&f.root, "b", Some("2026-01-01T11:00:00Z")).unwrap();
    let report = pass(&f, 0);
    assert_eq!(report[0], "deferred a (builds spent)", "builds=0 is plan-only: {report:?}");
    assert!(report[1].starts_with("adopted hostile "), "{report:?}");
    let report = pass(&f, 10);
    assert!(report[0].starts_with("built a ") && report[0].contains("FAIL"), "{report:?}");
    assert_eq!(report.len(), 1, "a failing prefix ends the pass, nothing left to adopt: {report:?}");
    let report = pass(&f, 10);
    assert!(report[0].starts_with("fail a "), "the recorded FAIL stops later passes too");
    assert_eq!(gate_runs(&f), 1, "the fail was built once, never again");
}

#[test]
fn conflicts_stop_the_chain_and_the_unsealed_are_swept() {
    let f = fx("0");
    enqueue(&f.root, "hostile", Some("2026-01-01T10:00:00Z")).unwrap();
    enqueue(&f.root, "a", Some("2026-01-01T11:00:00Z")).unwrap();
    let report = pass(&f, 10);
    assert!(report[0].starts_with("conflict hostile"), "{report:?}");
    assert!(report[1].starts_with("adopted b "), "nothing BUILDS past a conflict: {report:?}");
    assert_eq!(report.len(), 2, "{report:?}");
    git(&f.root, &["branch", "-D", "work/hostile"]);
    let report = pass(&f, 10);
    assert_eq!(report[0], "swept hostile (unsealed)", "{report:?}");
    assert!(report[1].starts_with("built a "), "the queue heals past the swept: {report:?}");
    assert!(report[2].starts_with("built b "), "the adopted entry builds in turn: {report:?}");
    assert_eq!(queue(&f.root).unwrap().len(), 2, "the stale tag is gone");
}

#[test]
fn adoption_is_the_paved_path_and_is_idempotent() {
    let f = fx("0");
    let report = pass(&f, 10);
    assert_eq!(report.len(), 3, "every stray tip is adopted, none built yet: {report:?}");
    for (line, id) in report.iter().zip(["a", "b", "hostile"]) {
        let tip = git(&f.root, &["rev-parse", &format!("refs/heads/work/{id}")]);
        assert_eq!(line, &format!("adopted {id} {tip}"), "{report:?}");
    }
    assert_eq!(gate_runs(&f), 0, "a fresh seal must survive one full pass before it builds");
    let report = pass(&f, 10);
    assert!(report[0].starts_with("built a ") && report[1].starts_with("built b "), "{report:?}");
    assert!(report[2].starts_with("conflict hostile"), "{report:?}");
    assert_eq!(report.len(), 3, "standing seals are not re-adopted: {report:?}");
}

#[test]
fn a_moved_tip_requeues_at_the_bottom_via_sweep_plus_adopt() {
    let f = fx("0");
    enqueue(&f.root, "a", Some("2026-01-01T10:00:00Z")).unwrap();
    enqueue(&f.root, "b", Some("2026-01-01T11:00:00Z")).unwrap();
    git(&f.root, &["checkout", "-q", "work/a"]);
    fs::write(f.root.join("fa"), "moved").unwrap();
    git(&f.root, &["add", "-A"]);
    git(&f.root, &["commit", "-q", "-m", "moved"]);
    git(&f.root, &["checkout", "-q", "main"]);
    let report = pass(&f, 10);
    assert_eq!(report[0], "swept a (unsealed)", "{report:?}");
    assert!(report[1].starts_with("built b "), "{report:?}");
    assert!(report[2].starts_with("adopted a "), "the moved tip re-seals at the bottom: {report:?}");
    let report = pass(&f, 10);
    assert!(report[0].starts_with("hit b ") && report[1].starts_with("built a "), "{report:?}");
}

#[test]
fn a_missing_landing_branch_is_loud() {
    let f = fx("0");
    let err = run(&f.root, &f.scratch, &f.territory, "rustc test", "ghost", "true", 1).unwrap_err();
    assert!(err.to_string().contains("rev-parse ghost"), "{err}");
}