Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::process::Command;
|
2026-03-17 11:32:44 +00:00
|
|
|
use std::time::Duration;
|
|
|
|
|
use wait_timeout::ChildExt;
|
|
|
|
|
|
|
|
|
|
/// Maximum time any single test command is allowed to run before being killed.
|
|
|
|
|
const TEST_TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
|
|
|
|
|
/// 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");
|
2026-03-17 11:32:44 +00:00
|
|
|
let (success, out) = run_command_with_timeout(&script_test, &[], path)?;
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
output.push_str(&out);
|
|
|
|
|
output.push('\n');
|
2026-03-17 11:32:44 +00:00
|
|
|
return Ok((success, output));
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: cargo nextest run / cargo test
|
|
|
|
|
let mut output = String::from("=== tests ===\n");
|
2026-03-17 11:32:44 +00:00
|
|
|
let (success, test_out) = match run_command_with_timeout("cargo", &["nextest", "run"], path) {
|
|
|
|
|
Ok(result) => result,
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
Err(_) => {
|
|
|
|
|
// nextest not available — fall back to cargo test
|
2026-03-17 11:32:44 +00:00
|
|
|
run_command_with_timeout("cargo", &["test"], path)
|
|
|
|
|
.map_err(|e| format!("Failed to run cargo test: {e}"))?
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
output.push_str(&test_out);
|
|
|
|
|
output.push('\n');
|
|
|
|
|
Ok((success, output))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:32:44 +00:00
|
|
|
/// Run a command with a timeout. Returns `(success, combined_output)`.
|
|
|
|
|
/// Kills the child process if it exceeds `TEST_TIMEOUT`.
|
|
|
|
|
fn run_command_with_timeout(
|
|
|
|
|
program: impl AsRef<std::ffi::OsStr>,
|
|
|
|
|
args: &[&str],
|
|
|
|
|
dir: &Path,
|
|
|
|
|
) -> Result<(bool, String), String> {
|
|
|
|
|
let mut child = Command::new(program)
|
|
|
|
|
.args(args)
|
|
|
|
|
.current_dir(dir)
|
|
|
|
|
.stdout(std::process::Stdio::piped())
|
|
|
|
|
.stderr(std::process::Stdio::piped())
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| format!("Failed to spawn command: {e}"))?;
|
|
|
|
|
|
|
|
|
|
match child.wait_timeout(TEST_TIMEOUT) {
|
|
|
|
|
Ok(Some(status)) => {
|
|
|
|
|
// Process exited within the timeout — collect output.
|
|
|
|
|
let stdout = child.stdout.take().map(|mut r| {
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
std::io::Read::read_to_string(&mut r, &mut s).ok();
|
|
|
|
|
s
|
|
|
|
|
}).unwrap_or_default();
|
|
|
|
|
let stderr = child.stderr.take().map(|mut r| {
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
std::io::Read::read_to_string(&mut r, &mut s).ok();
|
|
|
|
|
s
|
|
|
|
|
}).unwrap_or_default();
|
|
|
|
|
Ok((status.success(), format!("{stdout}{stderr}")))
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
// Timed out — kill the child.
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
|
|
|
|
Err(format!(
|
|
|
|
|
"Command timed out after {} seconds",
|
|
|
|
|
TEST_TIMEOUT.as_secs()
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
Err(e) => Err(format!("Failed to wait for command: {e}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
/// 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));
|
|
|
|
|
}
|
|
|
|
|
}
|