use crate::CliError;
use crate::commands::phase_artifact_on_develop;
use crate::pipeline_gate::{abort, run_gate};
use crate::pipeline_launch::launch_stage;
use crate::pipeline_launch::launch_stage_inner;
use crate::pipeline_outcomes::truncate_reason;
use devflow_core::gates::{GateAction, Gates};
use devflow_core::git::git_command;
use devflow_core::mode::{self, Mode};
use devflow_core::stage::Stage;
use devflow_core::state::{AgentKind, State};
use devflow_core::{agents, events, version, workflow};
use std::path::{Path, PathBuf};
pub(crate) fn worktree_writable_roots(project_root: &Path, worktree: &Path) -> Vec<PathBuf> {
let git_dir = project_root.join(".git");
let admin = std::fs::read_to_string(worktree.join(".git"))
.ok()
.and_then(|s| {
s.trim()
.strip_prefix("gitdir:")
.map(|p| PathBuf::from(p.trim()))
})
.unwrap_or_else(|| {
git_dir
.join("worktrees")
.join(worktree.file_name().unwrap_or_default())
});
vec![git_dir, admin]
}
fn agent_binary_available(program: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |path: &Path| {
path.is_file()
&& std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
};
if program.contains('/') {
return executable(Path::new(program));
}
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| executable(&dir.join(program))))
.unwrap_or(false)
}
pub(crate) fn agent_program(agent: AgentKind) -> &'static str {
agents::adapter_for(agent).exec_command(0, "", &[]).0
}
pub(crate) fn ensure_agent_binary(program: &str) -> Result<(), CliError> {
if agent_binary_available(program) {
return Ok(());
}
Err(CliError::Message(format!(
"agent binary `{program}` not found — is it installed? (run `devflow doctor`)"
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PhaseReachability {
Reachable,
Undeterminable,
Unreachable {
roadmap_entry_found: bool,
phase_dir_found: bool,
},
}
pub(crate) fn phase_reachability_on_base(
project_root: &Path,
phase: u32,
base: &str,
) -> PhaseReachability {
let verify = git_command(project_root)
.args(["rev-parse", "--verify", "--quiet", base])
.output();
match verify {
Ok(out) if out.status.success() => {}
_ => return PhaseReachability::Undeterminable,
}
let roadmap = git_command(project_root)
.args(["show", &format!("{base}:.planning/ROADMAP.md")])
.output();
let roadmap_text = match roadmap {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
_ => return PhaseReachability::Undeterminable,
};
let heading = format!("### Phase {phase}:");
let roadmap_entry_found = roadmap_text
.lines()
.any(|line| line.trim_start().starts_with(&heading));
let ls_tree = git_command(project_root)
.args([
"ls-tree",
"-r",
"--name-only",
base,
"--",
".planning/phases/",
])
.output();
let phase_dir_found = match ls_tree {
Ok(out) if out.status.success() => {
let prefix = format!(".planning/phases/{phase:02}-");
String::from_utf8_lossy(&out.stdout).lines().any(|path| {
path.strip_prefix(&prefix)
.is_some_and(|rest| rest.contains('/'))
})
}
_ => return PhaseReachability::Undeterminable,
};
if roadmap_entry_found && phase_dir_found {
PhaseReachability::Reachable
} else {
PhaseReachability::Unreachable {
roadmap_entry_found,
phase_dir_found,
}
}
}
pub(crate) fn unreachable_message(
phase: u32,
base: &str,
roadmap_entry_found: bool,
phase_dir_found: bool,
) -> String {
let mut msg = format!(
"phase {phase} is not reachable from `{base}` — the branch `devflow start` \
forks its worktree from:\n"
);
if !roadmap_entry_found {
msg.push_str(&format!(
" missing: the `### Phase {phase}:` heading in `ROADMAP.md` on `{base}`\n"
));
}
if !phase_dir_found {
msg.push_str(&format!(
" missing: a `.planning/phases/{phase:02}-*/` directory on `{base}`\n"
));
}
msg.push_str(&format!(
"a phase promoted only on another branch is invisible to this run — merge that \
branch into `{base}` first, then re-run."
));
msg
}
pub(crate) fn ensure_phase_reachable_on_base(
project_root: &Path,
phase: u32,
base: &str,
) -> Result<(), CliError> {
match phase_reachability_on_base(project_root, phase, base) {
PhaseReachability::Reachable | PhaseReachability::Undeterminable => Ok(()),
PhaseReachability::Unreachable {
roadmap_entry_found: true,
..
} => Ok(()),
PhaseReachability::Unreachable {
roadmap_entry_found,
phase_dir_found,
} => Err(CliError::Message(unreachable_message(
phase,
base,
roadmap_entry_found,
phase_dir_found,
))),
}
}
const ORIGIN: &str = "origin";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BaseRefCurrency {
Current,
Ahead,
Behind { count: u32 },
Diverged,
Undeterminable,
}
pub(crate) fn base_ref_currency(project_root: &Path, base: &str) -> BaseRefCurrency {
let remote_ref = format!("{ORIGIN}/{base}");
let fetch_ok = git_command(project_root)
.args(["fetch", "--quiet", ORIGIN, base])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !fetch_ok {
println!(
"warning: could not fetch `{ORIGIN} {base}` — comparing `{base}` against \
whatever `{remote_ref}` currently resolves to locally, which may be stale"
);
}
let ref_exists = git_command(project_root)
.args(["rev-parse", "--verify", "--quiet", &remote_ref])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !ref_exists {
return BaseRefCurrency::Undeterminable;
}
let is_ancestor = |ancestor: &str, descendant: &str| {
git_command(project_root)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
};
let local_is_ancestor_of_remote = is_ancestor(base, &remote_ref);
let remote_is_ancestor_of_local = is_ancestor(&remote_ref, base);
match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
(true, true) => BaseRefCurrency::Current,
(false, true) => BaseRefCurrency::Ahead,
(false, false) => BaseRefCurrency::Diverged,
(true, false) => {
let count = git_command(project_root)
.args(["rev-list", "--count", &format!("{base}..{remote_ref}")])
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| {
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<u32>()
.ok()
})
.unwrap_or(0);
BaseRefCurrency::Behind { count }
}
}
}
pub(crate) fn stale_base_message(base: &str, remote_ref: &str, count: u32) -> String {
format!(
"`{base}` is {count} commit(s) behind `{remote_ref}` and could not be safely \
fast-forwarded (it is currently checked out, or the fast-forward itself failed) \
— `devflow start` refuses to fork a phase worktree from a stale base. Switch off \
`{base}` if it is currently checked out, then run `git fetch {ORIGIN} {base} && \
git checkout {base} && git merge --ff-only {remote_ref}`, then re-run `devflow start`."
)
}
pub(crate) fn base_is_checked_out_anywhere(project_root: &Path, base: &str) -> bool {
let out = git_command(project_root)
.args(["worktree", "list", "--porcelain"])
.output();
match out {
Ok(out) if out.status.success() => {
let needle = format!("branch refs/heads/{base}");
String::from_utf8_lossy(&out.stdout)
.lines()
.any(|line| line.trim() == needle)
}
_ => true,
}
}
pub(crate) fn fast_forward_base_ref(
project_root: &Path,
base: &str,
expected_old: &str,
new: &str,
) -> bool {
git_command(project_root)
.args([
"update-ref",
&format!("refs/heads/{base}"),
new,
expected_old,
])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
pub(crate) fn ensure_base_ref_current(project_root: &Path, base: &str) -> Result<(), CliError> {
match base_ref_currency(project_root, base) {
BaseRefCurrency::Current | BaseRefCurrency::Ahead => Ok(()),
BaseRefCurrency::Undeterminable => {
println!(
"warning: could not determine whether `{base}` is current with `{ORIGIN}/{base}` \
— proceeding without a currency check (fail-open, per this module's \
fail-open-where-blind contract)"
);
Ok(())
}
BaseRefCurrency::Diverged => {
let remote_ref = format!("{ORIGIN}/{base}");
Err(CliError::Message(format!(
"`{base}` and `{remote_ref}` have diverged — neither is an ancestor of the \
other, so `devflow start` refuses to fork a phase worktree from either. \
Resolve manually (e.g. `git checkout {base} && git rebase {remote_ref}`, or \
`git reset --hard {remote_ref}` if `{base}`'s local commits are disposable), \
then re-run `devflow start`."
)))
}
BaseRefCurrency::Behind { count } => {
let remote_ref = format!("{ORIGIN}/{base}");
if !base_is_checked_out_anywhere(project_root, base) {
let resolve = |rref: &str| {
git_command(project_root)
.args(["rev-parse", "--verify", "--quiet", rref])
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let local_sha = resolve(&format!("refs/heads/{base}"));
let remote_sha = resolve(&format!("refs/remotes/{remote_ref}"));
if let (Some(local_sha), Some(remote_sha)) = (local_sha, remote_sha) {
let fast_forwarded =
fast_forward_base_ref(project_root, base, &local_sha, &remote_sha);
if fast_forwarded {
println!(
"advanced `{base}` to `{remote_ref}` ({count} commit(s) fast-forwarded)"
);
return Ok(());
}
}
}
Err(CliError::Message(stale_base_message(
base,
&remote_ref,
count,
)))
}
}
}
fn preflight_interactivity_check(project_root: &Path, state: &State) -> Result<(), String> {
if state.agent == AgentKind::Codex
&& state.mode == Mode::Auto
&& state.stage == Stage::Define
&& !phase_artifact_on_develop(project_root, state.phase, "-CONTEXT.md")
{
return Err(format!(
"phase {} has no CONTEXT.md on develop — codex cannot run Define's \
discuss-phase interview headlessly in auto mode",
state.phase
));
}
Ok(())
}
fn gh_auth_check_applies(stage: Stage) -> bool {
stage == Stage::Ship
}
fn preflight_gh_auth_check(state: &State) -> Result<(), String> {
if !gh_auth_check_applies(state.stage) {
return Ok(());
}
match std::process::Command::new("gh")
.args(["auth", "status"])
.output()
{
Ok(output) if output.status.success() => Ok(()),
Ok(_) => Err("gh auth status reports not authenticated".to_string()),
Err(_) => {
println!(
"warning: `gh` binary not found — cannot verify GitHub credential validity \
before Ship (fail-soft, not a preflight failure)"
);
Ok(())
}
}
}
fn major_bump_check_applies(stage: Stage) -> bool {
stage == Stage::Ship
}
fn preflight_major_bump_check(project_root: &Path, state: &State) -> Result<(), String> {
if !major_bump_check_applies(state.stage) {
return Ok(());
}
let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
let highest = version::highest_semver_tag(execution_root).map_err(|err| err.to_string())?;
let baseline =
version::reachable_semver_baseline(execution_root).map_err(|err| err.to_string())?;
if let Some(highest) = &highest {
let unreachable = match &baseline {
Some(reachable) => highest > reachable,
None => true,
};
if unreachable {
return Err(truncate_reason(&format!(
"version derivation refused: highest semver tag `v{highest}` is not reachable \
from HEAD (D-10) — a major-bump classification cannot be trusted here, so \
preflight refuses rather than proceed toward a no-rollback ship batch"
)));
}
}
let baseline_tag = baseline.as_ref().map(|tag| format!("v{tag}"));
let range_start = match &baseline_tag {
Some(tag) => {
version::release_range_start(execution_root, tag).map_err(|err| err.to_string())?
}
None => String::new(),
};
let bump = version::classify_range_bump(execution_root, &range_start)
.map_err(|err| err.to_string())?;
if bump != version::Bump::Major {
return Ok(());
}
let baseline_display = baseline_tag.as_deref().unwrap_or("(none)").to_string();
let baseline_major = baseline.as_ref().map(|v| v.major).unwrap_or(0);
let resulting_major = baseline_major + 1;
let subjects = breaking_commit_subjects(execution_root, &range_start);
let subjects_display = if subjects.is_empty() {
String::new()
} else {
format!(" — deciding commit(s): {}", subjects.join("; "))
};
Err(truncate_reason(&format!(
"classified bump is MAJOR — baseline `{baseline_display}`, resulting version \
`v{resulting_major}.0.0`; a major version bump never ships unattended (D-09){subjects_display}"
)))
}
fn breaking_commit_subjects(execution_root: &Path, range_start: &str) -> Vec<String> {
let range = if range_start.is_empty() {
"HEAD".to_string()
} else {
format!("{range_start}..HEAD")
};
let Ok(output) = git_command(execution_root)
.args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut subjects = Vec::new();
for record in stdout.split('\u{1e}') {
let record = record.trim_matches('\n');
if record.is_empty() {
continue;
}
let Some((_hash, message)) = record.split_once('\u{1f}') else {
continue;
};
let message = message.trim();
let subject = message.lines().next().unwrap_or_default();
let is_breaking = subject
.split_once(':')
.is_some_and(|(prefix, _)| prefix.contains('!'))
|| message.contains("BREAKING CHANGE:")
|| message.contains("BREAKING-CHANGE:");
if is_breaking {
subjects.push(subject.to_string());
}
}
subjects
}
fn generic_preflight_checks(project_root: &Path, state: &State) -> Result<(), String> {
let mut reasons = Vec::new();
if let Err(reason) = preflight_major_bump_check(project_root, state) {
reasons.push(reason);
}
if let Err(reason) = preflight_interactivity_check(project_root, state) {
reasons.push(reason);
}
if let Err(reason) = preflight_gh_auth_check(state) {
reasons.push(reason);
}
if reasons.is_empty() {
Ok(())
} else {
Err(reasons.join("; "))
}
}
pub(crate) fn run_preflight(
project_root: &Path,
state: &mut State,
adapter: &dyn agents::AgentAdapter,
) -> Result<bool, CliError> {
let stage = state.stage;
if let Err(reason) =
generic_preflight_checks(project_root, state).and_then(|()| adapter.preflight(state))
{
if state.preflight_retries >= mode::MAX_PREFLIGHT_RETRIES {
let ceiling_reason = format!(
"preflight retry ceiling ({}) reached for stage {stage}: {}",
mode::MAX_PREFLIGHT_RETRIES,
truncate_reason(&reason)
);
events::emit(
project_root,
state.phase,
"preflight_retry_ceiling_reached",
serde_json::json!({
"stage": stage.to_string(),
"reason": truncate_reason(&reason),
"ceiling": mode::MAX_PREFLIGHT_RETRIES,
}),
);
abort(project_root, state, &ceiling_reason)?;
return Ok(false);
}
state.preflight_retries = state.preflight_retries.saturating_add(1);
workflow::save_state(state)?;
let context = format!(
"[never-silent] preflight failed for stage {stage}: {} — human review needed \
(retry, loop-to-code, or abort)",
truncate_reason(&reason)
);
match run_gate(project_root, state, stage, &context)? {
GateAction::Advance => {
let _ = Gates::cleanup(project_root, state.phase, stage);
state.gate_pending = false;
state.preflight_retries = 0;
workflow::save_state(state)?;
launch_stage_inner(state, None, None)?;
}
GateAction::LoopBack(_) => {
let _ = Gates::cleanup(project_root, state.phase, stage);
launch_stage(state, None, None)?;
}
GateAction::Abort(reason) => abort(project_root, state, &reason)?,
}
return Ok(false);
}
if state.preflight_retries != 0 {
state.preflight_retries = 0;
workflow::save_state(state)?;
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::*;
#[test]
fn ensure_agent_binary_diagnoses_missing_program() {
assert!(ensure_agent_binary("sh").is_ok());
assert!(ensure_agent_binary("/bin/sh").is_ok());
let err = ensure_agent_binary("definitely-not-a-real-agent-xyz").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found — is it installed?"), "{msg}");
assert!(msg.contains("devflow doctor"), "{msg}");
assert!(ensure_agent_binary("/nonexistent/path/agent").is_err());
}
#[test]
fn preflight_interactivity_check_flags_auto_define_without_context_md() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let mut state = State::new(60, AgentKind::Codex, Mode::Auto, root.to_path_buf());
state.stage = Stage::Define;
assert!(preflight_interactivity_check(root, &state).is_err());
state.mode = Mode::Supervise;
assert!(preflight_interactivity_check(root, &state).is_ok());
state.mode = Mode::Auto;
state.stage = Stage::Plan;
assert!(preflight_interactivity_check(root, &state).is_ok());
state.stage = Stage::Define;
state.agent = AgentKind::Claude;
assert!(
preflight_interactivity_check(root, &state).is_ok(),
"Claude/OpenCode can complete Define headlessly — only Codex is flagged"
);
state.agent = AgentKind::Codex;
let git = |args: &[&str]| {
assert!(
devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
std::fs::create_dir_all(root.join(".planning/phases/60-widget")).unwrap();
std::fs::write(root.join(".planning/phases/60-widget/60-CONTEXT.md"), "ctx").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "context"]);
state.stage = Stage::Define;
assert!(preflight_interactivity_check(root, &state).is_ok());
}
#[test]
fn gh_auth_check_applies_only_to_ship_stage() {
assert!(gh_auth_check_applies(Stage::Ship));
for stage in [Stage::Define, Stage::Plan, Stage::Code, Stage::Validate] {
assert!(!gh_auth_check_applies(stage));
}
}
#[test]
fn major_bump_check_applies_only_to_ship_stage() {
assert!(major_bump_check_applies(Stage::Ship));
for stage in [Stage::Define, Stage::Plan, Stage::Code, Stage::Validate] {
assert!(!major_bump_check_applies(stage));
}
}
fn commit_msg(root: &Path, name: &str, message: &str) {
std::fs::write(root.join(name), name).unwrap();
run_git(root, &["add", "."]);
run_git(root, &["commit", "-q", "-m", message]);
}
fn tag(root: &Path, name: &str) {
run_git(root, &["tag", name]);
}
fn major_bump_fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_git(root, &["init", "-q"]);
run_git(root, &["config", "user.email", "t@e.st"]);
run_git(root, &["config", "user.name", "t"]);
run_git(root, &["config", "commit.gpgsign", "false"]);
run_git(root, &["config", "tag.gpgsign", "false"]);
run_git(root, &["config", "core.hooksPath", "/dev/null"]);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
dir
}
#[test]
fn major_bump_short_circuits_for_non_ship_stage() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut state = State::new(70, AgentKind::Claude, Mode::Auto, root.to_path_buf());
for stage in [Stage::Define, Stage::Plan, Stage::Code, Stage::Validate] {
state.stage = stage;
assert!(
preflight_major_bump_check(root, &state).is_ok(),
"stage {stage} must short-circuit before ever shelling out to git"
);
}
}
#[test]
fn major_bump_ok_for_patch_or_minor_bump_at_ship() {
let dir = major_bump_fixture();
let root = dir.path();
commit_msg(root, "b.txt", "fix(x): correct off-by-one");
let mut state = State::new(71, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
assert!(preflight_major_bump_check(root, &state).is_ok());
commit_msg(root, "c.txt", "feat(x): add capability");
assert!(preflight_major_bump_check(root, &state).is_ok());
}
#[test]
fn major_bump_errs_naming_bump_baseline_and_version_for_major_at_ship() {
let dir = major_bump_fixture();
let root = dir.path();
commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
let mut state = State::new(72, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
let err = preflight_major_bump_check(root, &state).unwrap_err();
assert!(err.contains("MAJOR"), "{err}");
assert!(err.contains("v1.0.0"), "{err}");
assert!(err.contains("v2.0.0"), "{err}");
assert!(err.contains("drop legacy api"), "{err}");
}
#[test]
fn major_bump_surfaces_unreachable_baseline_refusal() {
let dir = major_bump_fixture();
let root = dir.path();
let main_branch = {
let out = devflow_core::test_support::git_command(root)
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.unwrap();
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
run_git(root, &["checkout", "--orphan", "orphan-release"]);
run_git(
root,
&["commit", "--allow-empty", "-q", "-m", "chore: orphan"],
);
tag(root, "v9.9.9");
run_git(root, &["checkout", &main_branch]);
let mut state = State::new(73, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
let err = preflight_major_bump_check(root, &state).unwrap_err();
assert!(err.contains("v9.9.9"), "{err}");
assert!(err.contains("not reachable"), "{err}");
}
fn major_bump_worktree_fixture() -> (tempfile::TempDir, PathBuf) {
let outer = tempfile::tempdir().unwrap();
let project_root = outer.path().join("project");
std::fs::create_dir_all(&project_root).unwrap();
let worktree_path = outer.path().join("worktree");
run_git(&project_root, &["init", "-q", "-b", "develop"]);
run_git(&project_root, &["config", "user.email", "t@e.st"]);
run_git(&project_root, &["config", "user.name", "t"]);
run_git(&project_root, &["config", "commit.gpgsign", "false"]);
run_git(&project_root, &["config", "tag.gpgsign", "false"]);
run_git(&project_root, &["config", "core.hooksPath", "/dev/null"]);
commit_msg(&project_root, "a.txt", "chore: init");
tag(&project_root, "v1.0.0");
run_git(
&project_root,
&[
"worktree",
"add",
"-b",
"feature/phase-90",
worktree_path.to_str().unwrap(),
"develop",
],
);
commit_msg(&worktree_path, "b.txt", "feat(scope)!: drop legacy api");
(outer, worktree_path)
}
#[test]
fn preflight_major_bump_check_fires_against_the_worktree_head() {
let _guard = ENV_MUTEX.lock().unwrap();
let (outer, worktree_path) = major_bump_worktree_fixture();
let project_root = outer.path().join("project");
let mut state = State::new(76, AgentKind::Claude, Mode::Auto, project_root.clone());
state.stage = Stage::Ship;
state.worktree_path = Some(worktree_path.clone());
let err = preflight_major_bump_check(&project_root, &state).unwrap_err();
assert!(err.contains("MAJOR"), "{err}");
assert!(err.contains("v1.0.0"), "{err}");
assert!(err.contains("v2.0.0"), "{err}");
assert!(err.contains("drop legacy api"), "{err}");
state.worktree_path = None;
assert!(
preflight_major_bump_check(&project_root, &state).is_ok(),
"with no worktree set, the check must classify project_root's own HEAD, which \
never received the breaking commit"
);
}
#[test]
fn run_preflight_major_bump_gates_and_never_ships_unattended() {
let _guard = ENV_MUTEX.lock().unwrap();
let git_only_dir = agent_free_git_only_path_dir();
let original_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", git_only_dir.path());
}
let dir = major_bump_fixture();
let root = dir.path();
commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
let phase = 74;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
state.yes_ship = true;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Ship);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let adapter = agents::adapter_for(AgentKind::Claude);
let should_continue = run_preflight(root, &mut state, adapter.as_ref()).unwrap();
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert!(
!should_continue,
"a major bump at Ship must gate rather than tell its caller to continue launch_stage"
);
assert!(
workflow::load_state(root, phase).is_err(),
"abort() must clear state — spawn_monitor was never reached"
);
let last = devflow_core::events::last_event_for_phase(root, phase)
.expect("gate_fired/gate_resolved must have been recorded");
assert_ne!(last["event"], "stage_launched");
}
#[test]
fn run_preflight_major_bump_gate_not_auto_approved_by_yes_ship() {
let _guard = ENV_MUTEX.lock().unwrap();
let original_gate_timeout = std::env::var_os("DEVFLOW_GATE_TIMEOUT_SECS");
let git_only_dir = agent_free_git_only_path_dir();
let original_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("DEVFLOW_GATE_TIMEOUT_SECS", "1");
std::env::set_var("PATH", git_only_dir.path());
}
let dir = major_bump_fixture();
let root = dir.path();
commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
let phase = 75;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
state.yes_ship = true;
workflow::save_state(&state).unwrap();
let adapter = agents::adapter_for(AgentKind::Claude);
let result = run_preflight(root, &mut state, adapter.as_ref());
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
match &original_gate_timeout {
Some(value) => std::env::set_var("DEVFLOW_GATE_TIMEOUT_SECS", value),
None => std::env::remove_var("DEVFLOW_GATE_TIMEOUT_SECS"),
}
}
assert!(
result.is_err(),
"yes_ship must never auto-approve this gate — an unattended \
Ok(_) within the bounded timeout would mean it did, got {result:?}"
);
}
fn git_only_path_dir_with_failing_gh() -> tempfile::TempDir {
use std::os::unix::fs::PermissionsExt;
let dir = agent_free_git_only_path_dir();
let path = dir.path().join("gh");
std::fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
dir
}
#[test]
fn generic_preflight_checks_reports_major_bump_even_when_gh_auth_fails_first() {
let _guard = ENV_MUTEX.lock().unwrap();
let git_only_dir = git_only_path_dir_with_failing_gh();
let original_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", git_only_dir.path());
}
let (outer, worktree_path) = major_bump_worktree_fixture();
let project_root = outer.path().join("project");
let mut state = State::new(77, AgentKind::Claude, Mode::Auto, project_root.clone());
state.stage = Stage::Ship;
state.worktree_path = Some(worktree_path);
let err = generic_preflight_checks(&project_root, &state).unwrap_err();
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
assert!(err.contains("MAJOR"), "{err}");
assert!(err.contains("drop legacy api"), "{err}");
assert!(err.contains("not authenticated"), "{err}");
assert!(
truncate_reason(&err).contains("MAJOR"),
"{}",
truncate_reason(&err)
);
}
#[test]
fn run_preflight_failing_check_gates_and_never_reaches_spawn_monitor() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 61;
let mut state = State::new(phase, AgentKind::Codex, Mode::Auto, root.to_path_buf());
state.stage = Stage::Define;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Define);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let adapter = agents::adapter_for(AgentKind::Codex);
let should_continue = run_preflight(root, &mut state, adapter.as_ref()).unwrap();
assert!(
!should_continue,
"an aborted preflight must tell its caller not to continue launch_stage"
);
assert!(
workflow::load_state(root, phase).is_err(),
"abort() must clear state — spawn_monitor was never reached"
);
let last = devflow_core::events::last_event_for_phase(root, phase)
.expect("gate_fired/gate_resolved must have been recorded");
assert_ne!(last["event"], "stage_launched");
}
#[test]
fn run_preflight_adapter_hook_override_fires() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 62;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Plan;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Plan);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"abort: test cleanup","responded_by":"test"}"#,
)
.unwrap();
let should_continue = run_preflight(root, &mut state, &AlwaysFailAdapter).unwrap();
assert!(
!should_continue,
"an aborted preflight must tell its caller not to continue launch_stage"
);
assert!(workflow::load_state(root, phase).is_err());
let last = devflow_core::events::last_event_for_phase(root, phase).unwrap();
assert_eq!(last["event"], "workflow_aborted");
}
#[test]
fn run_preflight_advance_gate_launches_agent_exactly_once() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 63;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Plan;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Plan);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(&response_path, r#"{"approved":true,"responded_by":"test"}"#).unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let adapter = FailOnceAdapter::new();
let preflight = run_preflight(root, &mut state, &adapter);
let continuation = match &preflight {
Ok(true) => launch_stage(&mut state, None, None),
_ => Ok(()),
};
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let should_continue = preflight.unwrap();
continuation.unwrap();
assert!(
!should_continue,
"an Advance-resolved preflight failure must tell its caller not \
to continue launch_stage — the recursive retry already did"
);
let launches = stage_launched_count(root, phase);
assert_eq!(
launches, 1,
"a preflight failure resolved by Advance must launch the agent \
exactly once, not {launches}"
);
assert!(
state.monitor_pid.is_some(),
"this test is expected to drive a real monitor::spawn_monitor \
through run_preflight's Advance arm — None here means WR-05's \
premise was wrong and the guard above reaped nothing"
);
}
#[test]
fn run_preflight_loopback_gate_launches_agent_exactly_once() {
let _guard = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 64;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Plan;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Plan);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"retry","responded_by":"test"}"#,
)
.unwrap();
let stub_dir = stub_agent_binary("claude");
let original_path = std::env::var_os("PATH");
let stubbed_path = prepend_path(&stub_dir, &original_path);
unsafe {
std::env::set_var("PATH", &stubbed_path);
}
let adapter = FailOnceAdapter::new();
let preflight = run_preflight(root, &mut state, &adapter);
let continuation = match &preflight {
Ok(true) => launch_stage(&mut state, None, None),
_ => Ok(()),
};
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
let should_continue = preflight.unwrap();
continuation.unwrap();
assert!(
!should_continue,
"a LoopBack-resolved preflight failure must tell its caller not \
to continue launch_stage — the recursive retry already did"
);
let launches = stage_launched_count(root, phase);
assert_eq!(
launches, 1,
"a preflight failure resolved by LoopBack must launch the agent \
exactly once, not {launches}"
);
assert!(
state.monitor_pid.is_some(),
"this test is expected to drive a real monitor::spawn_monitor \
through run_preflight's LoopBack arm — None here means WR-05's \
premise was wrong and the guard above reaped nothing"
);
}
#[test]
fn run_preflight_advance_skips_recheck_on_idempotently_failing_check() {
let _guard = ENV_MUTEX.lock().unwrap();
let original_gate_timeout = std::env::var_os("DEVFLOW_GATE_TIMEOUT_SECS");
unsafe {
std::env::set_var("DEVFLOW_GATE_TIMEOUT_SECS", "2");
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 620;
let mut state = State::new(phase, AgentKind::Codex, Mode::Auto, root.to_path_buf());
state.stage = Stage::Define;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Define);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(&response_path, r#"{"approved":true,"responded_by":"test"}"#).unwrap();
let agent_dir = agent_free_dir_with_agent_stub("codex");
let original_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", agent_dir.path());
}
let result = run_preflight(root, &mut state, &AlwaysFailAdapter);
let _reap_guard = ReapMonitorOnDrop::after_launch(&state);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
match &original_gate_timeout {
Some(value) => std::env::set_var("DEVFLOW_GATE_TIMEOUT_SECS", value),
None => std::env::remove_var("DEVFLOW_GATE_TIMEOUT_SECS"),
}
}
assert!(
matches!(result, Ok(false)),
"Advance on a preflight gate must skip the just-adjudicated \
check and return Ok(false), not {result:?}"
);
assert!(
!Gates::gate_path(root, phase, Stage::Define).exists(),
"no second gate should ever be written once Advance skips the recheck"
);
assert_eq!(
state.preflight_retries, 0,
"a human Advance must reset the retry counter"
);
assert!(
state.monitor_pid.is_some(),
"this test's Advance arm is expected to drive a real \
monitor::spawn_monitor via launch_stage_inner — None here means \
this residual finding's premise was wrong and the guard above \
reaped nothing"
);
}
#[test]
fn run_preflight_loopback_bounds_recursion() {
let _guard = ENV_MUTEX.lock().unwrap();
let original_gate_timeout = std::env::var_os("DEVFLOW_GATE_TIMEOUT_SECS");
unsafe {
std::env::set_var("DEVFLOW_GATE_TIMEOUT_SECS", "2");
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
let phase = 621;
let mut state = State::new(phase, AgentKind::Codex, Mode::Auto, root.to_path_buf());
state.stage = Stage::Define;
state.preflight_retries = mode::MAX_PREFLIGHT_RETRIES - 1;
workflow::save_state(&state).unwrap();
let response_path = Gates::response_path(root, phase, Stage::Define);
std::fs::create_dir_all(response_path.parent().unwrap()).unwrap();
std::fs::write(
&response_path,
r#"{"approved":false,"note":"retry","responded_by":"test"}"#,
)
.unwrap();
let agent_dir = agent_free_dir_with_agent_stub("codex");
let original_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", agent_dir.path());
}
let result = run_preflight(root, &mut state, &AlwaysFailAdapter);
unsafe {
match &original_path {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
match &original_gate_timeout {
Some(value) => std::env::set_var("DEVFLOW_GATE_TIMEOUT_SECS", value),
None => std::env::remove_var("DEVFLOW_GATE_TIMEOUT_SECS"),
}
}
assert!(
matches!(result, Ok(false)),
"the ceiling must abort cleanly, not error out, got {result:?}"
);
assert!(
workflow::load_state(root, phase).is_err(),
"the ceiling must abort() and clear state, not leave it gate_pending forever"
);
let last = devflow_core::events::last_event_for_phase(root, phase)
.expect("a ceiling or abort event must have been recorded");
assert!(
last["event"] == "preflight_retry_ceiling_reached"
|| last["event"] == "workflow_aborted",
"expected a ceiling or abort event, got {last:?}"
);
}
#[test]
fn preflight_retries_reset_on_pass() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 622;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Plan;
state.preflight_retries = 2;
workflow::save_state(&state).unwrap();
let adapter = agents::adapter_for(AgentKind::Claude);
let result = run_preflight(root, &mut state, adapter.as_ref());
assert!(
matches!(result, Ok(true)),
"a passing preflight must return Ok(true), got {result:?}"
);
assert_eq!(
state.preflight_retries, 0,
"the in-memory counter must reset immediately on a pass"
);
let reloaded = workflow::load_state(root, phase).unwrap();
assert_eq!(
reloaded.preflight_retries, 0,
"the reset must be persisted to disk, not just held in memory"
);
}
fn reachability_fixture(roadmap: &str, phase_dir: Option<(u32, &str)>) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let run = |args: &[&str]| {
assert!(
devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
run(&["init", "-q"]);
run(&["config", "user.email", "t@e.st"]);
run(&["config", "user.name", "t"]);
run(&["config", "commit.gpgsign", "false"]);
run(&["config", "core.hooksPath", "/dev/null"]);
run(&["checkout", "-q", "-b", "develop"]);
std::fs::create_dir_all(root.join(".planning")).unwrap();
std::fs::write(root.join(".planning/ROADMAP.md"), roadmap).unwrap();
if let Some((phase, slug)) = phase_dir {
let d = root.join(format!(".planning/phases/{phase:02}-{slug}"));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join(".gitkeep"), "").unwrap();
}
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "fixture"]);
dir
}
#[test]
fn reachability_is_reachable_when_roadmap_entry_and_phase_dir_are_both_on_base() {
let dir = reachability_fixture("### Phase 24: Something\n", Some((24, "something")));
let root = dir.path();
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Reachable
);
assert!(ensure_phase_reachable_on_base(root, 24, "develop").is_ok());
}
#[test]
fn reachability_is_unreachable_when_the_phase_dir_is_absent_from_base() {
let dir = reachability_fixture("### Phase 24: Something\n", None);
let root = dir.path();
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Unreachable {
roadmap_entry_found: true,
phase_dir_found: false,
}
);
}
#[test]
fn enforcement_does_not_refuse_when_only_the_phase_dir_is_absent() {
let dir = reachability_fixture("### Phase 24: Something\n", None);
let root = dir.path();
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Unreachable {
roadmap_entry_found: true,
phase_dir_found: false,
}
);
assert!(
ensure_phase_reachable_on_base(root, 24, "develop").is_ok(),
"a present ROADMAP heading with no phase directory is the legitimate \
bootstrap state — Define has not run yet, and running it is what \
creates that directory. The guard must not refuse it (999.63)."
);
}
#[test]
fn enforcement_still_refuses_when_the_roadmap_heading_is_absent() {
let dir = reachability_fixture("### Phase 1: Something else\n", Some((24, "something")));
let root = dir.path();
let err = ensure_phase_reachable_on_base(root, 24, "develop")
.expect_err("a missing ROADMAP heading must still refuse (23-12's failure class)");
assert!(
err.to_string().contains("### Phase 24:"),
"the refusal must still name the missing heading, got:\n{err}"
);
}
#[test]
fn reachability_is_unreachable_when_the_roadmap_entry_is_absent_from_base() {
let dir = reachability_fixture("### Phase 1: Something else\n", Some((24, "something")));
let root = dir.path();
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Unreachable {
roadmap_entry_found: false,
phase_dir_found: true,
}
);
}
#[test]
fn reachability_is_undeterminable_outside_a_git_repo() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Undeterminable
);
assert!(
ensure_phase_reachable_on_base(root, 24, "develop").is_ok(),
"a probe that cannot see must never refuse (fail-open contract)"
);
}
#[test]
fn reachability_is_undeterminable_when_base_has_no_roadmap_at_all() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let run = |args: &[&str]| {
assert!(
devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
run(&["init", "-q"]);
run(&["config", "user.email", "t@e.st"]);
run(&["config", "user.name", "t"]);
run(&["config", "commit.gpgsign", "false"]);
run(&["config", "core.hooksPath", "/dev/null"]);
run(&["checkout", "-q", "-b", "develop"]);
std::fs::write(root.join("README.md"), "no roadmap here\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "no roadmap"]);
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Undeterminable
);
assert!(ensure_phase_reachable_on_base(root, 24, "develop").is_ok());
}
#[test]
fn phase_heading_probe_does_not_confuse_a_phase_number_prefix() {
let dir = reachability_fixture("### Phase 240: Later\n", Some((24, "something")));
let root = dir.path();
assert_eq!(
phase_reachability_on_base(root, 24, "develop"),
PhaseReachability::Unreachable {
roadmap_entry_found: false,
phase_dir_found: true,
}
);
}
#[test]
fn unreachable_message_names_the_base_branch_and_each_missing_half() {
let roadmap_missing = unreachable_message(24, "develop", false, true);
assert!(roadmap_missing.contains("is not reachable from"));
assert!(roadmap_missing.contains("develop"));
assert!(roadmap_missing.contains("### Phase 24:"));
assert!(!roadmap_missing.contains(".planning/phases/24-"));
let dir_missing = unreachable_message(24, "develop", true, false);
assert!(dir_missing.contains("is not reachable from"));
assert!(dir_missing.contains("develop"));
assert!(!dir_missing.contains("### Phase 24:"));
assert!(dir_missing.contains(".planning/phases/24-"));
let both_missing = unreachable_message(24, "develop", false, false);
assert!(both_missing.contains("is not reachable from"));
assert!(both_missing.contains("develop"));
assert!(both_missing.contains("### Phase 24:"));
assert!(both_missing.contains(".planning/phases/24-"));
}
#[test]
fn unreachable_message_contains_no_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let fixture_root = dir.path().to_string_lossy().into_owned();
let msg = unreachable_message(24, "develop", false, false);
assert!(!msg.contains(&fixture_root));
assert!(!msg.contains("/home/"));
assert!(!msg.contains("/Users/"));
}
#[test]
fn phase_reachability_resolves_caller_root_under_a_hostile_git_dir() {
let real_dir = reachability_fixture("### Phase 500: Something\n", None);
let real_root = real_dir.path();
let foreign_dir =
reachability_fixture("### Phase 500: Something\n", Some((500, "something")));
let foreign_root = foreign_dir.path();
let git_program = "git";
let vulnerable = std::process::Command::new(git_program)
.args([
"ls-tree",
"-r",
"--name-only",
"develop",
"--",
".planning/phases/",
])
.current_dir(real_root)
.env("GIT_DIR", foreign_root.join(".git"))
.output()
.expect("spawn git");
assert!(
vulnerable.status.success(),
"the reproduction itself must spawn successfully: {}",
String::from_utf8_lossy(&vulnerable.stderr)
);
assert!(
String::from_utf8_lossy(&vulnerable.stdout).contains(".planning/phases/500-something/"),
"an unscrubbed Command must be redirected onto the foreign repository's phase \
directory by an inherited GIT_DIR — this is the vulnerability class T-27-01 closes"
);
assert_eq!(
phase_reachability_on_base(real_root, 500, "develop"),
PhaseReachability::Unreachable {
roadmap_entry_found: true,
phase_dir_found: false,
}
);
}
fn run_git(root: &Path, args: &[&str]) {
assert!(
devflow_core::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed in {root:?}"
);
}
fn currency_fixture() -> (tempfile::TempDir, tempfile::TempDir) {
let remote_dir = tempfile::tempdir().unwrap();
let remote_root = remote_dir.path();
run_git(remote_root, &["init", "-q"]);
run_git(remote_root, &["config", "user.email", "t@e.st"]);
run_git(remote_root, &["config", "user.name", "t"]);
run_git(remote_root, &["config", "commit.gpgsign", "false"]);
run_git(remote_root, &["config", "core.hooksPath", "/dev/null"]);
run_git(remote_root, &["checkout", "-q", "-b", "develop"]);
std::fs::write(remote_root.join("f.txt"), "1").unwrap();
run_git(remote_root, &["add", "-A"]);
run_git(remote_root, &["commit", "-q", "-m", "c1"]);
let local_dir = tempfile::tempdir().unwrap();
let local_root = local_dir.path();
run_git(
local_root,
&["clone", "-q", remote_root.to_str().unwrap(), "."],
);
run_git(local_root, &["config", "user.email", "t@e.st"]);
run_git(local_root, &["config", "user.name", "t"]);
run_git(local_root, &["config", "commit.gpgsign", "false"]);
run_git(local_root, &["config", "core.hooksPath", "/dev/null"]);
(remote_dir, local_dir)
}
fn advance_remote(remote_root: &Path, filename: &str) {
std::fs::write(remote_root.join(filename), "2").unwrap();
run_git(remote_root, &["add", "-A"]);
run_git(remote_root, &["commit", "-q", "-m", "c2"]);
}
#[test]
fn currency_is_current_when_local_equals_remote() {
let (_remote, local) = currency_fixture();
let root = local.path();
assert_eq!(base_ref_currency(root, "develop"), BaseRefCurrency::Current);
assert!(ensure_base_ref_current(root, "develop").is_ok());
}
#[test]
fn currency_behind_and_not_checked_out_fast_forwards_and_proceeds() {
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "f2.txt");
run_git(local_root, &["checkout", "-q", "-b", "other"]);
assert_eq!(
base_ref_currency(local_root, "develop"),
BaseRefCurrency::Behind { count: 1 }
);
assert!(
ensure_base_ref_current(local_root, "develop").is_ok(),
"a safely-behind base must fast-forward and proceed unattended"
);
let rev_parse = |rref: &str| {
let out = devflow_core::test_support::git_command(local_root)
.args(["rev-parse", rref])
.output()
.unwrap();
assert!(out.status.success(), "rev-parse {rref} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
assert_eq!(
rev_parse("develop"),
rev_parse("origin/develop"),
"the local ref must now equal the remote-tracking ref after the fast-forward"
);
}
#[test]
fn currency_behind_and_checked_out_refuses_with_actionable_message() {
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "f2.txt");
assert_eq!(
base_ref_currency(local_root, "develop"),
BaseRefCurrency::Behind { count: 1 }
);
let err = ensure_base_ref_current(local_root, "develop").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("develop"), "{msg}");
assert!(msg.contains("origin/develop"), "{msg}");
assert!(msg.contains('1'), "{msg}");
assert!(msg.contains("git "), "{msg}");
let fixture_root = local_root.to_string_lossy().into_owned();
assert!(!msg.contains(&fixture_root), "{msg}");
}
#[test]
fn currency_behind_fast_forward_failure_falls_through_to_refusal() {
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "f2.txt");
run_git(local_root, &["checkout", "-q", "-b", "other"]);
std::fs::create_dir_all(local_root.join(".git/refs/heads")).unwrap();
std::fs::write(local_root.join(".git/refs/heads/develop.lock"), "").unwrap();
let result = ensure_base_ref_current(local_root, "develop");
assert!(
result.is_err(),
"a failed fast-forward must fall through to refusal, never a silent proceed"
);
}
#[test]
fn base_is_checked_out_anywhere_sees_a_linked_worktree() {
let (_remote, local) = currency_fixture();
let local_root = local.path();
run_git(local_root, &["checkout", "-q", "-b", "other"]);
let linked = tempfile::tempdir().unwrap();
run_git(
local_root,
&[
"worktree",
"add",
linked.path().to_str().unwrap(),
"develop",
],
);
assert!(
base_is_checked_out_anywhere(local_root, "develop"),
"a linked worktree with `develop` checked out must be seen, even though \
`project_root`'s own HEAD is on `other`"
);
assert!(
!base_is_checked_out_anywhere(local_root, "no-such-branch"),
"a branch no worktree holds must not be reported as checked out"
);
drop(linked);
}
#[test]
fn currency_behind_refuses_when_base_is_checked_out_in_another_worktree() {
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "f2.txt");
run_git(local_root, &["checkout", "-q", "-b", "other"]);
let linked = tempfile::tempdir().unwrap();
run_git(
local_root,
&[
"worktree",
"add",
linked.path().to_str().unwrap(),
"develop",
],
);
let rev_parse = |rref: &str| {
let out = devflow_core::test_support::git_command(local_root)
.args(["rev-parse", rref])
.output()
.unwrap();
assert!(out.status.success(), "rev-parse {rref} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let before = rev_parse("develop");
assert_eq!(
base_ref_currency(local_root, "develop"),
BaseRefCurrency::Behind { count: 1 }
);
let err = ensure_base_ref_current(local_root, "develop").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("develop"), "{msg}");
assert!(msg.contains("origin/develop"), "{msg}");
let fixture_root = local_root.to_string_lossy().into_owned();
assert!(!msg.contains(&fixture_root), "{msg}");
assert_eq!(
rev_parse("develop"),
before,
"`develop` must be unmoved when it is checked out in a linked worktree, even \
though `project_root`'s own HEAD is on `other`"
);
drop(linked);
}
#[test]
fn fast_forward_base_ref_refuses_a_stale_expected_old_value() {
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "f2.txt");
run_git(local_root, &["checkout", "-q", "-b", "other"]);
run_git(local_root, &["fetch", "-q", "origin", "develop"]);
let rev_parse = |rref: &str| {
let out = devflow_core::test_support::git_command(local_root)
.args(["rev-parse", rref])
.output()
.unwrap();
assert!(out.status.success(), "rev-parse {rref} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let before = rev_parse("develop");
let remote_sha = rev_parse("origin/develop");
assert!(
!fast_forward_base_ref(local_root, "develop", &remote_sha, &remote_sha),
"a stale expected-old value must refuse the write"
);
assert_eq!(
rev_parse("develop"),
before,
"a refused compare-and-swap must leave the ref byte-identical"
);
assert!(
fast_forward_base_ref(local_root, "develop", &before, &remote_sha),
"the correct expected-old value must succeed"
);
assert_eq!(rev_parse("develop"), remote_sha);
}
fn init_small_repo(root: &Path, branch: &str) {
run_git(root, &["init", "-q", "-b", branch]);
run_git(root, &["config", "user.email", "t@e.st"]);
run_git(root, &["config", "user.name", "t"]);
run_git(root, &["config", "commit.gpgsign", "false"]);
run_git(root, &["config", "core.hooksPath", "/dev/null"]);
}
#[test]
fn fast_forward_base_ref_never_writes_into_a_hostile_git_dir() {
let rev_parse_in = |root: &Path, rref: &str| {
let out = devflow_core::test_support::git_command(root)
.args(["rev-parse", rref])
.output()
.unwrap();
assert!(out.status.success(), "rev-parse {rref} in {root:?} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let demo_real = tempfile::tempdir().unwrap();
init_small_repo(demo_real.path(), "develop");
std::fs::write(demo_real.path().join("a.txt"), "1").unwrap();
run_git(demo_real.path(), &["add", "-A"]);
run_git(demo_real.path(), &["commit", "-q", "-m", "c1"]);
let demo_real_before = rev_parse_in(demo_real.path(), "develop");
let demo_foreign = tempfile::tempdir().unwrap();
init_small_repo(demo_foreign.path(), "develop");
std::fs::write(demo_foreign.path().join("f.txt"), "1").unwrap();
run_git(demo_foreign.path(), &["add", "-A"]);
run_git(demo_foreign.path(), &["commit", "-q", "-m", "foreign-c1"]);
let demo_foreign_old = rev_parse_in(demo_foreign.path(), "develop");
std::fs::write(demo_foreign.path().join("f2.txt"), "2").unwrap();
run_git(demo_foreign.path(), &["add", "-A"]);
run_git(demo_foreign.path(), &["commit", "-q", "-m", "foreign-c2"]);
let demo_foreign_new = rev_parse_in(demo_foreign.path(), "develop");
run_git(
demo_foreign.path(),
&["update-ref", "refs/heads/develop", &demo_foreign_old],
);
let git_program = "git";
let vulnerable = std::process::Command::new(git_program)
.args([
"update-ref",
"refs/heads/develop",
&demo_foreign_new,
&demo_foreign_old,
])
.current_dir(demo_real.path())
.env("GIT_DIR", demo_foreign.path().join(".git"))
.output()
.expect("spawn git");
assert!(
vulnerable.status.success(),
"the reproduction itself must succeed: {}",
String::from_utf8_lossy(&vulnerable.stderr)
);
assert_eq!(
rev_parse_in(demo_foreign.path(), "develop"),
demo_foreign_new,
"an unscrubbed update-ref, cwd pinned to the real repository, must still land the \
write in the foreign repository named by GIT_DIR — the exact hazard T-27-12 closes"
);
assert_eq!(
rev_parse_in(demo_real.path(), "develop"),
demo_real_before,
"the real repository's own ref must be untouched by the misdirected write"
);
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "f2.txt");
run_git(local_root, &["checkout", "-q", "-b", "other"]);
run_git(local_root, &["fetch", "-q", "origin", "develop"]);
let local_before = rev_parse_in(local_root, "develop");
let remote_sha = rev_parse_in(local_root, "origin/develop");
let foreign = tempfile::tempdir().unwrap();
init_small_repo(foreign.path(), "develop");
std::fs::write(foreign.path().join("x.txt"), "1").unwrap();
run_git(foreign.path(), &["add", "-A"]);
run_git(foreign.path(), &["commit", "-q", "-m", "unrelated"]);
let foreign_before = rev_parse_in(foreign.path(), "develop");
assert!(
fast_forward_base_ref(local_root, "develop", &local_before, &remote_sha),
"the correct expected-old value must succeed against the real repository"
);
assert_eq!(
rev_parse_in(local_root, "develop"),
remote_sha,
"the real repository's ref must advance to `new`"
);
assert_eq!(
rev_parse_in(foreign.path(), "develop"),
foreign_before,
"an unrelated foreign repository must be byte-identical before and after — \
fast_forward_base_ref must never touch it"
);
}
#[test]
fn currency_is_ahead_for_unpushed_local_work() {
let (_remote, local) = currency_fixture();
let local_root = local.path();
std::fs::write(local_root.join("local-only.txt"), "x").unwrap();
run_git(local_root, &["add", "-A"]);
run_git(local_root, &["commit", "-q", "-m", "unpushed local work"]);
assert_eq!(
base_ref_currency(local_root, "develop"),
BaseRefCurrency::Ahead
);
assert!(
ensure_base_ref_current(local_root, "develop").is_ok(),
"unpushed local work must not be misreported as staleness"
);
}
#[test]
fn currency_is_diverged_when_local_and_remote_both_moved_independently() {
let (remote, local) = currency_fixture();
let remote_root = remote.path();
let local_root = local.path();
advance_remote(remote_root, "remote-only.txt");
std::fs::write(local_root.join("local-only.txt"), "x").unwrap();
run_git(local_root, &["add", "-A"]);
run_git(
local_root,
&["commit", "-q", "-m", "local-only divergent commit"],
);
assert_eq!(
base_ref_currency(local_root, "develop"),
BaseRefCurrency::Diverged
);
let err = ensure_base_ref_current(local_root, "develop").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("develop"), "{msg}");
assert!(msg.contains("origin/develop"), "{msg}");
}
#[test]
fn currency_is_undeterminable_with_no_remote_configured() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
assert_eq!(
base_ref_currency(root, "develop"),
BaseRefCurrency::Undeterminable
);
assert!(
ensure_base_ref_current(root, "develop").is_ok(),
"a probe that cannot see the remote at all must never refuse"
);
}
#[test]
fn currency_fetch_failure_falls_back_to_existing_remote_ref() {
let (remote, local) = currency_fixture();
let local_root = local.path();
drop(remote);
assert_eq!(
base_ref_currency(local_root, "develop"),
BaseRefCurrency::Current,
"a failing fetch must fall back to the already-resolved remote-tracking ref, \
not collapse to Undeterminable"
);
}
#[test]
fn currency_message_contains_no_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let fixture_root = dir.path().to_string_lossy().into_owned();
let msg = stale_base_message("develop", "origin/develop", 3);
assert!(!msg.contains(&fixture_root));
assert!(!msg.contains("/home/"));
assert!(!msg.contains("/Users/"));
assert!(msg.contains("develop"));
assert!(msg.contains("origin/develop"));
assert!(msg.contains('3'));
}
}