use devflow_core::phase_id::PhaseId;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::Duration;
struct FakeBin {
_dir: tempfile::TempDir,
path: PathBuf,
}
fn devflow_bin() -> &'static str {
env!("CARGO_BIN_EXE_devflow")
}
fn git(root: &Path, args: &[&str]) -> Output {
let output = devflow_core::test_support::git_command(root)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn init_repo(root: &Path) {
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "devflow@example.com"]);
git(root, &["config", "user.name", "DevFlow Tests"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "tag.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
git(root, &["config", "core.fsyncObjectFiles", "true"]);
git(root, &["config", "core.fsync", "all"]);
git(root, &["checkout", "-q", "-b", "develop"]);
fs::write(root.join("README.md"), "base\n").unwrap();
for phase in ["07", "08", "09"] {
let dir = root.join(format!(".planning/phases/{phase}-test"));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{phase}-CONTEXT.md")), "ctx\n").unwrap();
fs::write(dir.join(format!("{phase}-01-PLAN.md")), "plan\n").unwrap();
}
fs::create_dir_all(root.join(".planning")).unwrap();
fs::write(
root.join(".planning/config.json"),
"{\n \"workflow\": {\n \"auto_advance\": false\n }\n}\n",
)
.unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "base"]);
git(root, &["branch", "main"]);
}
fn fake_bin_dir(scripts: &[(&str, &str)]) -> FakeBin {
let dir = tempfile::tempdir().unwrap();
for (name, script) in scripts {
let path = dir.path().join(name);
fs::write(&path, script).unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
}
let path = dir.path().to_path_buf();
FakeBin { _dir: dir, path }
}
fn path_with_fake_bin(fake_bin: &Path) -> String {
let existing = std::env::var_os("PATH").unwrap_or_default();
format!("{}:{}", fake_bin.display(), existing.to_string_lossy())
}
fn run_devflow(root: &Path, fake_bin: &Path, args: &[&str]) -> Output {
run_devflow_inner(root, fake_bin, args, false)
}
fn run_devflow_legacy_launch(root: &Path, fake_bin: &Path, args: &[&str]) -> Output {
run_devflow_inner(root, fake_bin, args, true)
}
fn run_devflow_inner(root: &Path, fake_bin: &Path, args: &[&str], legacy_launch: bool) -> Output {
let mut command = Command::new(devflow_bin());
command
.args(args)
.arg(root)
.env("PATH", path_with_fake_bin(fake_bin))
.env("DEVFLOW_TEST_ROOT", root)
.current_dir(root);
if legacy_launch {
command.env("DEVFLOW_CLAUDE_LEGACY_LAUNCH", "true");
}
let output = command.output().expect("run devflow");
assert!(
output.status.success(),
"devflow {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn wait_for(path: &Path) {
for _ in 0..200 {
if path.exists() {
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("timed out waiting for {}", path.display());
}
fn wait_for_pid(path: &Path) -> u32 {
for _ in 0..200 {
if let Ok(contents) = fs::read_to_string(path)
&& let Ok(pid) = contents.trim().parse::<u32>()
{
return pid;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("timed out waiting for a pid in {}", path.display());
}
fn wait_for_state_cleared(root: &Path, phase: PhaseId) {
for _ in 0..400 {
if devflow_core::workflow::load_state(root, phase).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("timed out waiting for phase {phase} state to clear (pipeline never finished)");
}
fn wait_for_stopped(root: &Path, phase: PhaseId) -> devflow_core::state::State {
for _ in 0..400 {
if let Ok(state) = devflow_core::workflow::load_state(root, phase)
&& state.stopped
{
return state;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("timed out waiting for phase {phase} state to report stopped == true");
}
fn seed_feature_branch(root: &Path, phase: PhaseId) {
let branch = format!("feature/phase-{padded}", padded = phase.padded());
git(root, &["checkout", "-q", "-b", &branch]);
fs::write(root.join("initial.txt"), "initial phase work\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "initial phase work"]);
git(root, &["checkout", "-q", "develop"]);
}
#[test]
fn parallel_creates_two_worktrees_and_spawns_two_monitors() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[
(
"claude",
r#"#!/bin/sh
read -r turn
case "$turn" in
*DEVFLOW_DELIVERY_CANARY_*)
token=$(printf '%s' "$turn" | grep -o 'DEVFLOW_DELIVERY_CANARY_[0-9a-f]*' | head -1)
printf '{"type":"result","subtype":"success","is_error":false,"session_id":"s-fake","result":"%s"}\n' "$token"
;;
*)
printf 'fake claude\nDEVFLOW_RESULT: {"status":"success"}\n'
;;
esac
"#,
),
(
"codex",
"#!/bin/sh\nprintf 'fake codex\\nDEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
),
]);
let output = run_devflow(
root,
&fake_bin.path,
&[
"parallel",
"--phases",
"7,8",
"--agents",
"claude,codex",
"--mode",
"supervise",
],
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("phase 7"));
assert!(stdout.contains("phase 8"));
assert!(root.join(".worktrees/phase-07").is_dir());
assert!(root.join(".worktrees/phase-08").is_dir());
let phase7_pid = root.join(".devflow/phase-07-agent-pid");
let phase8_pid = root.join(".devflow/phase-08-agent-pid");
assert!(wait_for_pid(&phase7_pid) > 0);
assert!(wait_for_pid(&phase8_pid) > 0);
let phase7_stdout = root.join(".devflow/phase-07-stdout");
let phase8_stdout = root.join(".devflow/phase-08-stdout");
wait_for(&phase7_stdout);
assert!(phase7_stdout.exists());
wait_for(&phase8_stdout);
assert!(phase8_stdout.exists());
let state7 = devflow_core::workflow::load_state(root, PhaseId::new(7)).expect("phase 7 state");
let state8 = devflow_core::workflow::load_state(root, PhaseId::new(8)).expect("phase 8 state");
assert_eq!(state7.phase, PhaseId::new(7));
assert_eq!(state8.phase, PhaseId::new(8));
assert!(
!root.join(".devflow/state.json").exists(),
"legacy single-slot state.json must not be written anymore"
);
}
#[test]
fn start_defaults_to_worktree() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[(
"claude",
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)]);
run_devflow_legacy_launch(
root,
&fake_bin.path,
&[
"start",
"--phase",
"11",
"--agent",
"claude",
"--mode",
"supervise",
],
);
wait_for(&root.join(".worktrees/phase-11"));
assert!(root.join(".worktrees/phase-11").is_dir());
let state = devflow_core::workflow::load_state(root, PhaseId::new(11)).expect("load state");
assert!(
state.worktree_path.is_some(),
"expected worktree_path to be Some(_) by default, got {:?}",
state.worktree_path
);
}
#[test]
fn start_worktree_mode_ignores_main_checkout_divergence() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
git(root, &["checkout", "-q", "-b", "ancient", "develop"]);
git(root, &["checkout", "-q", "develop"]);
for i in 0..51 {
fs::write(root.join(format!("f{i}.txt")), i.to_string()).unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", &format!("commit {i}")]);
}
git(root, &["checkout", "-q", "ancient"]);
let fake_bin = fake_bin_dir(&[(
"claude",
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)]);
run_devflow_legacy_launch(
root,
&fake_bin.path,
&[
"start",
"--phase",
"13",
"--agent",
"claude",
"--mode",
"supervise",
],
);
wait_for(&root.join(".worktrees/phase-13"));
assert!(root.join(".worktrees/phase-13").is_dir());
}
#[test]
fn start_no_worktree_uses_feature_branch() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[(
"claude",
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)]);
run_devflow_legacy_launch(
root,
&fake_bin.path,
&[
"start",
"--phase",
"12",
"--agent",
"claude",
"--mode",
"supervise",
"--no-worktree",
],
);
wait_for(&root.join(".devflow/phase-12-agent-pid"));
assert!(!root.join(".worktrees/phase-12").exists());
let state = devflow_core::workflow::load_state(root, PhaseId::new(12)).expect("load state");
assert!(
state.worktree_path.is_none(),
"expected worktree_path to be None with --no-worktree, got {:?}",
state.worktree_path
);
}
#[test]
fn start_until_plan_halts_cleanly() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[(
"claude",
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)]);
run_devflow_legacy_launch(
root,
&fake_bin.path,
&[
"start",
"--phase",
"44",
"--agent",
"claude",
"--mode",
"supervise",
"--until",
"plan",
],
);
let state = wait_for_stopped(root, PhaseId::new(44));
assert_eq!(
state.stage,
devflow_core::stage::Stage::Plan,
"the persisted stage must be the COMPLETED target (Plan), proving Plan ran \
before the halt — not that the pipeline stopped before Plan ever launched"
);
assert!(state.stopped, "stop marker must be set");
assert_eq!(
state.monitor_pid, None,
"the stop path must clear monitor_pid so no monitor is left behind"
);
assert!(
state.stop_reason.is_some(),
"a human-readable stop_reason must be recorded"
);
}
#[test]
fn start_until_ship_is_rejected() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[(
"claude",
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)]);
let output = Command::new(devflow_bin())
.args([
"start", "--phase", "45", "--agent", "claude", "--mode", "auto", "--until", "ship",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
assert!(
!output.status.success(),
"--until ship must be rejected, not silently accepted"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("ship") && stderr.contains("no-op"),
"the rejection must explain Ship is already terminal\nstderr: {stderr}"
);
assert!(
!root.join(".worktrees/phase-45").exists(),
"a rejected --until ship must not run any stage or create a worktree"
);
}
#[test]
fn start_until_unknown_stage_is_rejected_by_clap() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[(
"claude",
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)]);
let output = Command::new(devflow_bin())
.args([
"start", "--phase", "46", "--agent", "claude", "--mode", "auto", "--until", "bogus",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
assert!(
!output.status.success(),
"--until bogus must be rejected by the existing Stage parser"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("bogus"),
"clap's error must name the unrecognized value\nstderr: {stderr}"
);
assert!(!root.join(".worktrees/phase-46").exists());
}
#[test]
fn start_dry_run_annotates_until_stage() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[]);
let output = Command::new(devflow_bin())
.args([
"start",
"--phase",
"47",
"--agent",
"claude",
"--mode",
"auto",
"--until",
"plan",
"--dry-run",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
assert!(
output.status.success(),
"dry-run must not fail\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout
.lines()
.any(|l| l.contains("plan") && l.contains("[STOPS HERE — --until]")),
"the plan stage line must be annotated as the --until stop point\nstdout: {stdout}"
);
assert!(
stdout.contains("--until plan"),
"the preview must include a trailing note naming the --until stage\nstdout: {stdout}"
);
assert!(
!root.join(".worktrees/phase-47").exists(),
"dry-run must not create a worktree"
);
}
#[test]
fn start_dry_run_without_until_has_no_stop_annotation() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[]);
let output = Command::new(devflow_bin())
.args([
"start",
"--phase",
"48",
"--agent",
"claude",
"--mode",
"auto",
"--dry-run",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("STOPS HERE"),
"no stop annotation must appear without --until\nstdout: {stdout}"
);
}
#[test]
fn status_prints_cron_hint_when_cron_instructions_exist() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let instructions = devflow_core::ship::build_single_agent_cron_instructions(
root,
PhaseId::new(7),
"2026-06-18T15:45:30Z",
);
devflow_core::ship::write_cron_instructions(root, &instructions).unwrap();
let fake_bin = fake_bin_dir(&[]);
let output = run_devflow(root, &fake_bin.path, &["status"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains(&format!(
"Cron instruction pending (phase 7): hermes cron create --from-devflow {}",
root.display()
)));
}
#[test]
fn reference_and_cleanup_worktree_cli_flow() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[(
"claude",
r#"#!/bin/sh
read -r turn
case "$turn" in
*DEVFLOW_DELIVERY_CANARY_*)
token=$(printf '%s' "$turn" | grep -o 'DEVFLOW_DELIVERY_CANARY_[0-9a-f]*' | head -1)
printf '{"type":"result","subtype":"success","is_error":false,"session_id":"s-fake","result":"%s"}\n' "$token"
;;
*)
printf 'DEVFLOW_RESULT: {"status":"success"}\n'
;;
esac
"#,
)]);
let out = run_devflow(root, &fake_bin.path, &["reference"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("reference worktree"));
assert!(root.join(".worktrees/reference").is_dir());
let out = run_devflow(
root,
&fake_bin.path,
&[
"start",
"--phase",
"8",
"--agent",
"claude",
"--mode",
"auto",
"--worktree",
],
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("phase 8"));
assert!(
root.join(".worktrees/phase-08").is_dir(),
"worktree not created"
);
let out = run_devflow(root, &fake_bin.path, &["status"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains(".worktrees/reference"),
"status missing reference\n{stdout}"
);
assert!(
stdout.contains(".worktrees/phase-08"),
"status missing phase worktree\n{stdout}"
);
wait_for(&root.join(".devflow/gates/08-validate.json"));
let events = fs::read_to_string(root.join(".devflow/events.jsonl")).unwrap_or_default();
assert!(
events.contains("claude_delivery_canary_confirmed"),
"the Code launch must have run the delivery canary and confirmed it\n{events}"
);
run_devflow(
root,
&fake_bin.path,
&[
"gate",
"reject",
"8",
"--stage",
"validate",
"--note",
"abort test teardown",
],
);
wait_for_state_cleared(root, PhaseId::new(8));
let out = run_devflow(root, &fake_bin.path, &["cleanup", "--force"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("removed"));
assert!(!root.join(".worktrees/reference").is_dir());
assert!(!root.join(".worktrees/phase-08").is_dir());
}
#[test]
fn start_codex_without_context_fails_preflight() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let fake_bin = fake_bin_dir(&[("codex", "#!/bin/sh\nexit 0\n")]);
let output = Command::new(devflow_bin())
.args([
"start", "--phase", "42", "--agent", "codex", "--mode", "auto",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
assert!(
!output.status.success(),
"codex start on a context-less phase must fail pre-flight"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no CONTEXT.md"),
"pre-flight error must name the missing artifact\nstderr: {stderr}"
);
assert!(
stderr.contains("/gsd-discuss-phase 42"),
"pre-flight error must include the remediation command\nstderr: {stderr}"
);
assert!(
!root.join(".worktrees/phase-42").exists(),
"pre-flight failure must not create a worktree"
);
}
#[test]
fn cleanup_force_refuses_on_live_agent_unknown_monitor() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let phase = PhaseId::new(8);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
seed_feature_branch(root, phase);
let wt_path = root
.join(".worktrees")
.join(format!("phase-{padded}", padded = phase.padded()));
devflow_core::worktree::add(root, &wt_path, &branch, &branch, false).unwrap();
let pid_path = devflow_core::agent_result::agent_pid_path(root, phase);
fs::create_dir_all(pid_path.parent().unwrap()).unwrap();
fs::write(&pid_path, std::process::id().to_string()).unwrap();
let mut state = devflow_core::state::State::new(
phase,
devflow_core::state::AgentKind::Claude,
devflow_core::mode::Mode::Auto,
root.to_path_buf(),
);
state.worktree_path = Some(wt_path.clone());
devflow_core::workflow::save_state(&state).unwrap();
let fake_bin = fake_bin_dir(&[]);
let output = Command::new(devflow_bin())
.args(["cleanup", "--force"])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow cleanup");
assert!(
!output.status.success(),
"cleanup --force must refuse to remove a live agent's worktree even \
under Unknown liveness (monitor_pid = None)"
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("devflow resume"),
"refusal must name `devflow resume` as the unblocking action, got:\n{combined}"
);
assert!(
wt_path.is_dir(),
"worktree must NOT have been removed while the agent is alive"
);
}
#[test]
fn cleanup_force_refuses_on_dead_monitor_live_agent() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let phase = PhaseId::new(9);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
seed_feature_branch(root, phase);
let wt_path = root
.join(".worktrees")
.join(format!("phase-{padded}", padded = phase.padded()));
devflow_core::worktree::add(root, &wt_path, &branch, &branch, false).unwrap();
let pid_path = devflow_core::agent_result::agent_pid_path(root, phase);
fs::create_dir_all(pid_path.parent().unwrap()).unwrap();
fs::write(&pid_path, std::process::id().to_string()).unwrap();
let mut state = devflow_core::state::State::new(
phase,
devflow_core::state::AgentKind::Claude,
devflow_core::mode::Mode::Auto,
root.to_path_buf(),
);
state.worktree_path = Some(wt_path.clone());
state.monitor_pid = Some(0x7FFF_FFFE); devflow_core::workflow::save_state(&state).unwrap();
let fake_bin = fake_bin_dir(&[]);
let output = Command::new(devflow_bin())
.args(["cleanup", "--force"])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow cleanup");
assert!(
!output.status.success(),
"cleanup --force must refuse to remove a worktree whose agent is \
alive even when its monitor is dead (Stuck liveness)"
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("devflow resume"),
"refusal must name `devflow resume` as the unblocking action, got:\n{combined}"
);
assert!(
wt_path.is_dir(),
"worktree must NOT have been removed while the agent is alive"
);
}
#[test]
fn cleanup_keeps_worktree_for_until_stopped_phase_without_force() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let phase = PhaseId::new(11);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
seed_feature_branch(root, phase);
let wt_path = root
.join(".worktrees")
.join(format!("phase-{padded}", padded = phase.padded()));
devflow_core::worktree::add(root, &wt_path, &branch, &branch, false).unwrap();
let mut state = devflow_core::state::State::new(
phase,
devflow_core::state::AgentKind::Claude,
devflow_core::mode::Mode::Auto,
root.to_path_buf(),
);
state.worktree_path = Some(wt_path.clone());
state.stopped = true;
state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
devflow_core::workflow::save_state(&state).unwrap();
let fake_bin = fake_bin_dir(&[]);
let output = Command::new(devflow_bin())
.args(["cleanup"])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow cleanup");
assert!(
output.status.success(),
"cleanup must not error on a stopped phase — it should skip it, not fail\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("devflow resume") && combined.contains("--force"),
"the kept-worktree message must name `devflow resume` and `--force` as the paths \
forward, got:\n{combined}"
);
assert!(
wt_path.is_dir(),
"worktree for a --until-stopped phase must NOT be removed by a bare `devflow cleanup`"
);
}
#[test]
fn cleanup_force_removes_worktree_for_until_stopped_phase() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let phase = PhaseId::new(13);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
seed_feature_branch(root, phase);
let wt_path = root
.join(".worktrees")
.join(format!("phase-{padded}", padded = phase.padded()));
devflow_core::worktree::add(root, &wt_path, &branch, &branch, false).unwrap();
let mut state = devflow_core::state::State::new(
phase,
devflow_core::state::AgentKind::Claude,
devflow_core::mode::Mode::Auto,
root.to_path_buf(),
);
state.worktree_path = Some(wt_path.clone());
state.stopped = true;
state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
devflow_core::workflow::save_state(&state).unwrap();
let fake_bin = fake_bin_dir(&[]);
let output = Command::new(devflow_bin())
.args(["cleanup", "--force"])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow cleanup --force");
assert!(
output.status.success(),
"cleanup --force must succeed on a stopped phase\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!wt_path.is_dir(),
"cleanup --force must remove a --until-stopped phase's worktree"
);
}
#[test]
fn cleanup_is_idempotent_when_worktree_already_removed() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo(root);
let phase = PhaseId::new(10);
let branch = format!("feature/phase-{padded}", padded = phase.padded());
seed_feature_branch(root, phase);
let wt_path = root
.join(".worktrees")
.join(format!("phase-{padded}", padded = phase.padded()));
devflow_core::worktree::add(root, &wt_path, &branch, &branch, false).unwrap();
let mut state = devflow_core::state::State::new(
phase,
devflow_core::state::AgentKind::Claude,
devflow_core::mode::Mode::Auto,
root.to_path_buf(),
);
state.worktree_path = Some(wt_path.clone());
state.monitor_pid = Some(0x7FFF_FFFE);
devflow_core::workflow::save_state(&state).unwrap();
let fake_bin = fake_bin_dir(&[]);
let first = Command::new(devflow_bin())
.args(["cleanup", "--force"])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow cleanup (first)");
assert!(
first.status.success(),
"first cleanup of a genuinely-dead phase must succeed\nstderr: {}",
String::from_utf8_lossy(&first.stderr)
);
assert!(!wt_path.is_dir(), "worktree must be removed on first run");
let second = Command::new(devflow_bin())
.args(["cleanup", "--force"])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow cleanup (second)");
assert!(
second.status.success(),
"second cleanup run must find the worktree already gone and not error\nstderr: {}",
String::from_utf8_lossy(&second.stderr)
);
}