ai-dispatch 10.35.0

Multi-AI CLI team orchestrator
use super::{
    AID_ADD_EXCLUDES, WorktreeStatusKind, capture_worktree_snapshot,
    capture_worktree_snapshot_with_base, is_rescuable_path, parse_status_entry,
};
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

fn git(dir: &Path, args: &[&str]) {
    let output = Command::new("git")
        .current_dir(dir)
        .args(args)
        .output()
        .expect("git command failed");
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}

fn repo_with_main() -> TempDir {
    let dir = TempDir::new().unwrap();
    git(dir.path(), &["init", "-b", "main"]);
    git(dir.path(), &["config", "user.email", "aid@example.com"]);
    git(dir.path(), &["config", "user.name", "Aid Tester"]);
    std::fs::write(dir.path().join("file.txt"), "initial").unwrap();
    git(dir.path(), &["add", "file.txt"]);
    git(dir.path(), &["commit", "-m", "initial"]);
    dir
}

fn checkout_feature_with_change(dir: &Path) {
    git(dir, &["checkout", "-b", "feature"]);
    std::fs::write(dir.join("file.txt"), "updated").unwrap();
    git(dir, &["add", "file.txt"]);
    git(dir, &["commit", "-m", "feature change"]);
}

#[test]
fn worktree_snapshot_parses_status_entries() {
    let untracked = parse_status_entry("?? src/new.rs").unwrap();
    assert_eq!(untracked.path, "src/new.rs");
    assert_eq!(untracked.kind, WorktreeStatusKind::Untracked);

    let modified = parse_status_entry(" M src/lib.rs").unwrap();
    assert_eq!(modified.path, "src/lib.rs");
    assert_eq!(modified.kind, WorktreeStatusKind::Modified);

    assert!(parse_status_entry(" D src/lib.rs").is_none());
}

#[test]
fn worktree_snapshot_filters_non_source_artifacts() {
    assert!(is_rescuable_path("src/lib.rs"));
    assert!(!is_rescuable_path("target/debug/app"));
    assert!(!is_rescuable_path("cache/file.pyc"));
}

#[test]
fn is_rescuable_path_excludes_aid_artifacts() {
    assert!(!is_rescuable_path("result-t-abc123.md"));
    assert!(!is_rescuable_path("result-t-0d8f.md"));
    assert!(!is_rescuable_path(".aid/results/foo.md"));
    assert!(is_rescuable_path("results/foo.md"));
    assert!(is_rescuable_path("my-result-t.md"));
}

#[test]
fn aid_add_excludes_covers_nested_and_untyped_bookkeeping_paths() {
    let dir = repo_with_main();
    std::fs::create_dir_all(dir.path().join("sub")).unwrap();
    std::fs::create_dir_all(dir.path().join(".aid/batches")).unwrap();
    std::fs::write(dir.path().join(".aid-lock"), "pid=1\n").unwrap();
    std::fs::write(dir.path().join("sub/.aid-nested"), "pid=1\n").unwrap();
    std::fs::write(dir.path().join(".aid/state.toml"), "health = 1\n").unwrap();
    std::fs::write(dir.path().join(".aid/batches/foo.toml"), "x\n").unwrap();
    std::fs::write(dir.path().join("aid-batch-run.json"), "x\n").unwrap();
    std::fs::write(dir.path().join("sub/aid-batch-nested.log"), "x\n").unwrap();
    std::fs::write(dir.path().join("keep.rs"), "fn main() {}\n").unwrap();

    let mut add_args = vec!["add", "-A", "--", "."];
    add_args.extend_from_slice(AID_ADD_EXCLUDES);
    git(dir.path(), &add_args);

    let output = Command::new("git")
        .current_dir(dir.path())
        .args(["diff", "--cached", "--name-only"])
        .output()
        .expect("git diff --cached failed");
    let staged = String::from_utf8_lossy(&output.stdout);
    assert_eq!(
        staged.lines().collect::<Vec<_>>(),
        vec!["keep.rs"],
        "got: {staged}"
    );
}

#[test]
fn aid_add_excludes_still_restages_tracked_aid_project_toml() {
    let dir = repo_with_main();
    std::fs::create_dir_all(dir.path().join(".aid")).unwrap();
    std::fs::write(dir.path().join(".aid/project.toml"), "[project]\nid = \"a\"\n").unwrap();
    git(dir.path(), &["add", ".aid/project.toml"]);
    git(dir.path(), &["commit", "-m", "track project.toml"]);
    std::fs::write(dir.path().join(".aid/project.toml"), "[project]\nid = \"b\"\n").unwrap();

    let mut add_args = vec!["add", "-u", "--", "."];
    add_args.extend_from_slice(AID_ADD_EXCLUDES);
    git(dir.path(), &add_args);

    let output = Command::new("git")
        .current_dir(dir.path())
        .args(["diff", "--cached", "--name-only"])
        .output()
        .expect("git diff --cached failed");
    let staged = String::from_utf8_lossy(&output.stdout);
    assert_eq!(
        staged.lines().collect::<Vec<_>>(),
        vec![".aid/project.toml"],
        "got: {staged}"
    );
}

#[test]
fn empty_diff_is_false_for_committed_only_change_against_base() {
    let dir = repo_with_main();
    checkout_feature_with_change(dir.path());

    let snapshot = capture_worktree_snapshot_with_base(dir.path(), Some("main")).unwrap();

    assert_eq!(snapshot.empty_diff, Some(false));
    assert!(!snapshot.has_uncommitted_changes());
}

#[test]
fn empty_diff_is_true_for_clean_worktree_without_commits_ahead() {
    let dir = repo_with_main();

    let snapshot = capture_worktree_snapshot_with_base(dir.path(), Some("main")).unwrap();

    assert_eq!(snapshot.empty_diff, Some(true));
}

#[test]
fn empty_diff_is_false_for_dirty_uncommitted_change() {
    let dir = repo_with_main();
    std::fs::write(dir.path().join("file.txt"), "dirty").unwrap();

    let snapshot = capture_worktree_snapshot_with_base(dir.path(), Some("main")).unwrap();

    assert_eq!(snapshot.empty_diff, Some(false));
    assert!(snapshot.has_uncommitted_changes());
}

#[test]
fn empty_diff_uses_default_branch_fallback_for_committed_change() {
    let dir = repo_with_main();
    checkout_feature_with_change(dir.path());

    let snapshot = capture_worktree_snapshot(dir.path()).unwrap();

    assert_eq!(snapshot.empty_diff, Some(false));
}