use std::path::Path; use std::process::Command; /// Detect whether the base branch in a worktree is `master` or `main`. /// Falls back to `"master"` if neither is found. pub(crate) fn detect_worktree_base_branch(wt_path: &Path) -> String { for branch in &["master", "main"] { let ok = Command::new("git") .args(["rev-parse", "--verify", branch]) .current_dir(wt_path) .output() .map(|o| o.status.success()) .unwrap_or(false); if ok { return branch.to_string(); } } "master".to_string() } /// Return `true` if the git worktree at `wt_path` has commits on its current /// branch that are not present on the base branch (`master` or `main`). /// /// Used during server startup reconciliation to detect stories whose agent work /// was committed while the server was offline. pub(crate) fn worktree_has_committed_work(wt_path: &Path) -> bool { let base_branch = detect_worktree_base_branch(wt_path); let output = Command::new("git") .args(["log", &format!("{base_branch}..HEAD"), "--oneline"]) .current_dir(wt_path) .output(); match output { Ok(out) if out.status.success() => { !String::from_utf8_lossy(&out.stdout).trim().is_empty() } _ => false, } } /// Check whether the given directory has any uncommitted git changes. /// Returns `Err` with a descriptive message if there are any. pub(crate) fn check_uncommitted_changes(path: &Path) -> Result<(), String> { let output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(path) .output() .map_err(|e| format!("Failed to run git status: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() { return Err(format!( "Worktree has uncommitted changes. Please commit all work before \ the agent exits:\n{stdout}" )); } Ok(()) } /// Run the project's test suite. /// /// Uses `script/test` if present, treating it as the canonical single test entry point. /// Falls back to `cargo nextest run` / `cargo test` when `script/test` is absent. /// Returns `(tests_passed, output)`. pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> { let script_test = path.join("script").join("test"); if script_test.exists() { let mut output = String::from("=== script/test ===\n"); let result = Command::new(&script_test) .current_dir(path) .output() .map_err(|e| format!("Failed to run script/test: {e}"))?; let out = format!( "{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr) ); output.push_str(&out); output.push('\n'); return Ok((result.status.success(), output)); } // Fallback: cargo nextest run / cargo test let mut output = String::from("=== tests ===\n"); let (success, test_out) = match Command::new("cargo") .args(["nextest", "run"]) .current_dir(path) .output() { Ok(o) => { let combined = format!( "{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr) ); (o.status.success(), combined) } Err(_) => { // nextest not available — fall back to cargo test let o = Command::new("cargo") .args(["test"]) .current_dir(path) .output() .map_err(|e| format!("Failed to run cargo test: {e}"))?; let combined = format!( "{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr) ); (o.status.success(), combined) } }; output.push_str(&test_out); output.push('\n'); Ok((success, output)) } /// Run `cargo clippy` and the project test suite (via `script/test` if present, /// otherwise `cargo nextest run` / `cargo test`) in the given directory. /// Returns `(gates_passed, combined_output)`. pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> { let mut all_output = String::new(); let mut all_passed = true; // ── cargo clippy ────────────────────────────────────────────── let clippy = Command::new("cargo") .args(["clippy", "--all-targets", "--all-features"]) .current_dir(path) .output() .map_err(|e| format!("Failed to run cargo clippy: {e}"))?; all_output.push_str("=== cargo clippy ===\n"); let clippy_stdout = String::from_utf8_lossy(&clippy.stdout); let clippy_stderr = String::from_utf8_lossy(&clippy.stderr); if !clippy_stdout.is_empty() { all_output.push_str(&clippy_stdout); } if !clippy_stderr.is_empty() { all_output.push_str(&clippy_stderr); } all_output.push('\n'); if !clippy.status.success() { all_passed = false; } // ── tests (script/test if available, else cargo nextest/test) ─ let (test_success, test_out) = run_project_tests(path)?; all_output.push_str(&test_out); if !test_success { all_passed = false; } Ok((all_passed, all_output)) } /// Run `script/test_coverage` in the given directory if the script exists. /// /// Used as a QA gate before advancing a story from `3_qa/` to `4_merge/`. /// Returns `(passed, output)`. If the script does not exist, returns `(true, …)`. pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> { let script = path.join("script").join("test_coverage"); if !script.exists() { return Ok(( true, "script/test_coverage not found; coverage gate skipped.\n".to_string(), )); } let mut output = String::from("=== script/test_coverage ===\n"); let result = Command::new(&script) .current_dir(path) .output() .map_err(|e| format!("Failed to run script/test_coverage: {e}"))?; let combined = format!( "{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr) ); output.push_str(&combined); output.push('\n'); Ok((result.status.success(), output)) } #[cfg(test)] mod tests { use super::*; fn init_git_repo(repo: &std::path::Path) { Command::new("git") .args(["init"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(repo) .output() .unwrap(); } // ── run_project_tests tests ─────────────────────────────────── #[cfg(unix)] #[test] fn run_project_tests_uses_script_test_when_present_and_passes() { use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; let tmp = tempdir().unwrap(); let path = tmp.path(); let script_dir = path.join("script"); fs::create_dir_all(&script_dir).unwrap(); let script_test = script_dir.join("test"); fs::write(&script_test, "#!/usr/bin/env bash\necho 'all tests passed'\nexit 0\n").unwrap(); let mut perms = fs::metadata(&script_test).unwrap().permissions(); perms.set_mode(0o755); fs::set_permissions(&script_test, perms).unwrap(); let (passed, output) = run_project_tests(path).unwrap(); assert!(passed, "script/test exiting 0 should pass"); assert!(output.contains("script/test"), "output should mention script/test"); } #[cfg(unix)] #[test] fn run_project_tests_reports_failure_when_script_test_exits_nonzero() { use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; let tmp = tempdir().unwrap(); let path = tmp.path(); let script_dir = path.join("script"); fs::create_dir_all(&script_dir).unwrap(); let script_test = script_dir.join("test"); fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap(); let mut perms = fs::metadata(&script_test).unwrap().permissions(); perms.set_mode(0o755); fs::set_permissions(&script_test, perms).unwrap(); let (passed, output) = run_project_tests(path).unwrap(); assert!(!passed, "script/test exiting 1 should fail"); assert!(output.contains("script/test"), "output should mention script/test"); } // ── run_coverage_gate tests ─────────────────────────────────────────────── #[cfg(unix)] #[test] fn coverage_gate_passes_when_script_absent() { use tempfile::tempdir; let tmp = tempdir().unwrap(); let (passed, output) = run_coverage_gate(tmp.path()).unwrap(); assert!(passed, "coverage gate should pass when script is absent"); assert!( output.contains("not found"), "output should mention script not found" ); } #[cfg(unix)] #[test] fn coverage_gate_passes_when_script_exits_zero() { use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; let tmp = tempdir().unwrap(); let path = tmp.path(); let script_dir = path.join("script"); fs::create_dir_all(&script_dir).unwrap(); let script = script_dir.join("test_coverage"); fs::write( &script, "#!/usr/bin/env bash\necho 'Rust line coverage: 85%'\necho 'PASS: Coverage 85% meets threshold 0%'\nexit 0\n", ) .unwrap(); let mut perms = fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); fs::set_permissions(&script, perms).unwrap(); let (passed, output) = run_coverage_gate(path).unwrap(); assert!(passed, "coverage gate should pass when script exits 0"); assert!( output.contains("script/test_coverage"), "output should mention script/test_coverage" ); } #[cfg(unix)] #[test] fn coverage_gate_fails_when_script_exits_nonzero() { use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; let tmp = tempdir().unwrap(); let path = tmp.path(); let script_dir = path.join("script"); fs::create_dir_all(&script_dir).unwrap(); let script = script_dir.join("test_coverage"); fs::write( &script, "#!/usr/bin/env bash\necho 'FAIL: Coverage 40% is below threshold 80%'\nexit 1\n", ) .unwrap(); let mut perms = fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); fs::set_permissions(&script, perms).unwrap(); let (passed, output) = run_coverage_gate(path).unwrap(); assert!(!passed, "coverage gate should fail when script exits 1"); assert!( output.contains("script/test_coverage"), "output should mention script/test_coverage" ); } // ── worktree_has_committed_work tests ───────────────────────────────────── #[test] fn worktree_has_committed_work_false_on_fresh_repo() { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path(); // init_git_repo creates the initial commit on the default branch. // HEAD IS the base branch — no commits ahead. init_git_repo(repo); assert!(!worktree_has_committed_work(repo)); } #[test] fn worktree_has_committed_work_true_after_commit_on_feature_branch() { use std::fs; let tmp = tempfile::tempdir().unwrap(); let project_root = tmp.path().join("project"); fs::create_dir_all(&project_root).unwrap(); init_git_repo(&project_root); // Create a git worktree on a feature branch. let wt_path = tmp.path().join("wt"); Command::new("git") .args([ "worktree", "add", &wt_path.to_string_lossy(), "-b", "feature/story-99_test", ]) .current_dir(&project_root) .output() .unwrap(); // No commits on the feature branch yet — same as base branch. assert!(!worktree_has_committed_work(&wt_path)); // Add a commit to the feature branch in the worktree. fs::write(wt_path.join("work.txt"), "done").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(&wt_path) .output() .unwrap(); Command::new("git") .args([ "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "coder: implement story", ]) .current_dir(&wt_path) .output() .unwrap(); // Now the feature branch is ahead of the base branch. assert!(worktree_has_committed_work(&wt_path)); } }