use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
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
}
struct FakeBin {
_dir: tempfile::TempDir,
path: PathBuf,
}
fn fake_bin_dir() -> FakeBin {
let dir = tempfile::tempdir().unwrap();
let claude = dir.path().join("claude");
fs::write(
&claude,
"#!/bin/sh\nprintf 'DEVFLOW_RESULT: {\"status\":\"success\"}\\n'\n",
)
.unwrap();
let mut perms = fs::metadata(&claude).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&claude, perms).unwrap();
let path = dir.path().to_path_buf();
FakeBin { _dir: dir, path }
}
fn init_repo_with_phase_24_promoted_only_on_feature_branch(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", "core.hooksPath", "/dev/null"]);
git(root, &["checkout", "-q", "-b", "develop"]);
fs::create_dir_all(root.join(".planning")).unwrap();
fs::write(
root.join(".planning/ROADMAP.md"),
"# Roadmap\n\n### Phase 1: Something else\n",
)
.unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", "develop base — no phase 24"]);
git(root, &["checkout", "-q", "-b", "feature/phase-23"]);
fs::write(
root.join(".planning/ROADMAP.md"),
"# Roadmap\n\n### Phase 1: Something else\n\n### Phase 24: Acceptance target\n",
)
.unwrap();
fs::create_dir_all(root.join(".planning/phases/24-acceptance-target")).unwrap();
fs::write(
root.join(".planning/phases/24-acceptance-target/.gitkeep"),
"",
)
.unwrap();
git(root, &["add", "-A"]);
git(
root,
&[
"commit",
"-q",
"-m",
"promote phase 24 onto feature/phase-23 only",
],
);
}
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())
}
#[test]
fn start_refuses_a_phase_promoted_only_on_the_working_branch_and_scaffolds_nothing() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo_with_phase_24_promoted_only_on_feature_branch(root);
let fake_bin = fake_bin_dir();
let output = Command::new(devflow_bin())
.args([
"start", "--phase", "24", "--agent", "claude", "--mode", "auto",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"devflow start must refuse an unreachable phase, but exited successfully\n\
stdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stderr.contains("is not reachable from"),
"stderr must carry the refusal phrase `is not reachable from`, got:\n{stderr}"
);
assert!(
stderr.contains("develop"),
"stderr must name the base branch `develop`, got:\n{stderr}"
);
assert!(
stderr.contains("### Phase 24:"),
"stderr must name the missing roadmap heading `### Phase 24:`, got:\n{stderr}"
);
assert!(
stderr.contains(".planning/phases/24-"),
"stderr must name the missing phase directory `.planning/phases/24-`, got:\n{stderr}"
);
assert!(
!root.join(".worktrees/phase-24").exists(),
"a refused start must not scaffold a phase-24 worktree"
);
assert!(
!root.join(".worktrees").exists(),
"a refused start must not create .worktrees at all"
);
assert!(
!root.join(".devflow/state-24.json").exists(),
"a refused start must not persist phase-24 state"
);
let branch_list = devflow_core::test_support::git_command(root)
.args(["branch", "--list", "feature/phase-24"])
.output()
.expect("spawn git branch --list");
assert!(
String::from_utf8_lossy(&branch_list.stdout)
.trim()
.is_empty(),
"a refused start must not create feature/phase-24"
);
}
#[test]
fn start_refuses_before_creating_the_feature_branch_in_no_worktree_mode() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
init_repo_with_phase_24_promoted_only_on_feature_branch(root);
let fake_bin = fake_bin_dir();
let output = Command::new(devflow_bin())
.args([
"start",
"--phase",
"24",
"--agent",
"claude",
"--mode",
"auto",
"--no-worktree",
])
.arg(root)
.env("PATH", path_with_fake_bin(&fake_bin.path))
.current_dir(root)
.output()
.expect("run devflow");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"devflow start --no-worktree must refuse an unreachable phase, but exited \
successfully\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stderr.contains("is not reachable from"),
"stderr must carry the refusal phrase `is not reachable from`, got:\n{stderr}"
);
let branch_list = devflow_core::test_support::git_command(root)
.args(["branch", "--list", "feature/phase-24"])
.output()
.expect("spawn git branch --list");
assert!(
String::from_utf8_lossy(&branch_list.stdout)
.trim()
.is_empty(),
"a refused --no-worktree start must not create feature/phase-24 either"
);
}