use devflow_core::agents;
use devflow_core::state::State;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
pub(crate) static ENV_MUTEX: Mutex<()> = Mutex::new(());
pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn init_repo(root: &Path) {
let git = |args: &[&str]| {
let ok = devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
};
git(&["init", "-q"]);
git(&["config", "user.email", "devflow@example.com"]);
git(&["config", "user.name", "DevFlow Tests"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "tag.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "-M", "main"]);
git(&["checkout", "-q", "-b", "develop"]);
}
pub(crate) fn init_repo_no_version_file(root: &Path) {
let git = |args: &[&str]| {
let ok = devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
};
git(&["init", "-q"]);
git(&["config", "user.email", "devflow@example.com"]);
git(&["config", "user.name", "DevFlow Tests"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "tag.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join("README.md"), "no version file in this repo\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "-M", "main"]);
git(&["checkout", "-q", "-b", "develop"]);
}
pub(crate) fn commit_on_feature_branch(root: &Path, phase: u32, label: &str) {
let git = |args: &[&str]| {
let ok = devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
};
let branch = format!("feature/phase-{phase:02}");
let branch_exists = devflow_core::test_support::git_command(root)
.args(["rev-parse", "--verify", &branch])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if branch_exists {
git(&["checkout", &branch]);
} else {
git(&["checkout", "-b", &branch]);
}
let file_name = format!("{label}.txt");
std::fs::write(root.join(&file_name), label).unwrap();
git(&["add", &file_name]);
git(&["commit", "-m", label]);
}
pub(crate) struct AlwaysFailAdapter;
impl agents::AgentAdapter for AlwaysFailAdapter {
fn name(&self) -> &'static str {
"test-always-fail"
}
fn exec_command(
&self,
_phase: u32,
_prompt: &str,
_roots: &[PathBuf],
) -> (&'static str, Vec<String>) {
("true", Vec::new())
}
fn completion_signal_detected(&self, _output: &str) -> bool {
false
}
fn preflight(&self, _state: &State) -> Result<(), String> {
Err("test adapter always rejects".to_string())
}
}
pub(crate) struct FailOnceAdapter {
failed_once: std::cell::Cell<bool>,
}
impl FailOnceAdapter {
pub(crate) fn new() -> Self {
Self {
failed_once: std::cell::Cell::new(false),
}
}
}
impl agents::AgentAdapter for FailOnceAdapter {
fn name(&self) -> &'static str {
"test-fail-once"
}
fn exec_command(
&self,
_phase: u32,
_prompt: &str,
_roots: &[PathBuf],
) -> (&'static str, Vec<String>) {
("true", Vec::new())
}
fn completion_signal_detected(&self, _output: &str) -> bool {
false
}
fn preflight(&self, _state: &State) -> Result<(), String> {
if self.failed_once.get() {
Ok(())
} else {
self.failed_once.set(true);
Err("test adapter fails on the first preflight call only".to_string())
}
}
}
pub(crate) fn agent_free_git_only_path_dir() -> tempfile::TempDir {
let real_git = std::env::var_os("PATH")
.and_then(|paths| {
std::env::split_paths(&paths).find_map(|dir| {
let candidate = dir.join("git");
candidate.is_file().then_some(candidate)
})
})
.expect("git must be resolvable on PATH to run this test");
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(&real_git, dir.path().join("git")).unwrap();
dir
}
pub(crate) struct NeutralPath {
_dir: tempfile::TempDir,
original: Option<std::ffi::OsString>,
}
impl NeutralPath {
pub(crate) fn install() -> Self {
let dir = agent_free_git_only_path_dir();
let original = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", dir.path()) };
Self {
_dir: dir,
original,
}
}
}
impl Drop for NeutralPath {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
}
}
pub(crate) fn agent_free_dir_with_agent_stub(program: &str) -> tempfile::TempDir {
use std::os::unix::fs::PermissionsExt;
let dir = agent_free_git_only_path_dir();
let real_sh = std::env::var_os("PATH")
.and_then(|paths| {
std::env::split_paths(&paths).find_map(|d| {
let candidate = d.join("sh");
candidate.is_file().then_some(candidate)
})
})
.expect("sh must be resolvable on PATH to run this test");
std::os::unix::fs::symlink(&real_sh, dir.path().join("sh")).unwrap();
let path = dir.path().join(program);
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
dir
}
pub(crate) fn stub_agent_binary(name: &str) -> tempfile::TempDir {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(name);
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
dir
}
pub(crate) fn prepend_path(
stub_dir: &tempfile::TempDir,
original: &Option<std::ffi::OsString>,
) -> std::ffi::OsString {
let mut dirs = vec![stub_dir.path().to_path_buf()];
if let Some(original) = original {
dirs.extend(std::env::split_paths(original));
}
std::env::join_paths(dirs).unwrap()
}
pub(crate) fn stage_launched_count(root: &Path, phase: u32) -> usize {
std::fs::read_to_string(devflow_core::events::events_path(root))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|event| {
event.get("phase").and_then(serde_json::Value::as_u64) == Some(u64::from(phase))
&& event.get("event").and_then(serde_json::Value::as_str) == Some("stage_launched")
})
.count()
}
fn reap_monitor_pid(pid: u32) -> bool {
devflow_core::agent::terminate_and_verify(
pid,
devflow_core::agent::TERMINATE_VERIFY_WAIT,
devflow_core::agent::TERMINATE_VERIFY_POLL,
);
!devflow_core::agent::agent_running(pid)
}
pub(crate) fn reap_spawned_monitor(state: &State) {
let Some(pid) = state.monitor_pid else {
return;
};
assert!(
reap_monitor_pid(pid),
"monitor wrapper pid {pid}, spawned by this test's own launch_stage_inner call, must be \
verified dead after reaping — not merely assumed dead"
);
}
pub(crate) struct ReapMonitorOnDrop {
pid: Option<u32>,
}
impl ReapMonitorOnDrop {
pub(crate) fn after_launch(state: &State) -> Self {
Self {
pid: state.monitor_pid,
}
}
}
impl Drop for ReapMonitorOnDrop {
fn drop(&mut self) {
let Some(pid) = self.pid else {
return;
};
if !reap_monitor_pid(pid) {
if std::thread::panicking() {
use std::io::Write as _;
let _ = writeln!(
std::io::stderr(),
"ReapMonitorOnDrop: monitor wrapper pid {pid} still alive after reap \
during an unwind — not re-panicking because a panic is already in flight"
);
} else {
panic!(
"monitor wrapper pid {pid}, spawned by this test's own launch call, must be \
verified dead after reaping — not merely assumed dead"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use devflow_core::mode::Mode;
use devflow_core::state::AgentKind;
use std::panic::AssertUnwindSafe;
use std::process::Command;
struct ChildGuard(std::process::Child);
impl ChildGuard {
fn spawn() -> Self {
Self(
Command::new("sleep")
.arg("300")
.spawn()
.expect("sleep must be spawnable to run this test"),
)
}
fn pid(&self) -> u32 {
self.0.id()
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn state_holding(pid: u32) -> State {
let mut state = State::new(
0,
AgentKind::Claude,
Mode::Auto,
PathBuf::from("/nonexistent"),
);
state.monitor_pid = Some(pid);
state
}
#[test]
fn reap_guard_reaps_the_monitor_when_a_later_assertion_panics() {
let child = ChildGuard::spawn();
let pid = child.pid();
let state = state_holding(pid);
assert!(
devflow_core::agent::agent_running(pid),
"precondition: a test whose subject is already dead proves nothing"
);
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
assert_eq!(
std::hint::black_box(1_u32),
2,
"deliberate failing assertion"
);
}));
assert!(result.is_err(), "the closure must have panicked");
assert!(
!devflow_core::agent::agent_running(pid),
"the guard did not reap the monitor during the unwind"
);
}
#[test]
fn trailing_reap_call_is_skipped_when_a_later_assertion_panics() {
let child = ChildGuard::spawn();
let pid = child.pid();
let state = state_holding(pid);
assert!(
devflow_core::agent::agent_running(pid),
"precondition: a test whose subject is already dead proves nothing"
);
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
assert_eq!(
std::hint::black_box(1_u32),
2,
"deliberate failing assertion"
);
reap_spawned_monitor(&state);
}));
assert!(result.is_err(), "the closure must have panicked");
assert!(
devflow_core::agent::agent_running(pid),
"this proves the trailing call form does NOT run during an unwind — the reason the \
guard test above is not vacuous"
);
}
}