use devflow_core::events;
use devflow_core::gates;
use devflow_core::state::State;
use std::path::Path;
use crate::CliError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Staleness {
Fresh,
Stale,
Ahead,
Indeterminate,
}
fn embedded_commit_is_stale(execution_root: &Path, embedded_commit: &str) -> Staleness {
if embedded_commit.is_empty() {
return Staleness::Indeterminate;
}
let output = std::process::Command::new("git")
.args(["merge-base", "--is-ancestor", embedded_commit, "HEAD"])
.current_dir(execution_root)
.output();
match output.map(|o| o.status.code()) {
Ok(Some(0)) => match run_git_stdout(execution_root, &["rev-parse", "HEAD"]) {
Some(head) if head.trim() == embedded_commit.trim() => Staleness::Fresh,
Some(_) => {
if ancestry_range_affects_build(execution_root, embedded_commit) {
Staleness::Stale
} else {
Staleness::Fresh
}
}
None => Staleness::Indeterminate,
},
Ok(Some(1)) => {
let reverse = std::process::Command::new("git")
.args(["merge-base", "--is-ancestor", "HEAD", embedded_commit])
.current_dir(execution_root)
.output();
match reverse.map(|o| o.status.code()) {
Ok(Some(0)) => Staleness::Ahead,
Ok(Some(1)) => Staleness::Stale,
_ => Staleness::Indeterminate,
}
}
_ => Staleness::Indeterminate,
}
}
fn ancestry_range_affects_build(execution_root: &Path, embedded_commit: &str) -> bool {
run_git_stdout(
execution_root,
&["diff", "--name-only", embedded_commit, "HEAD"],
)
.map(|out| out.lines().any(affects_compiled_binary))
.unwrap_or(true)
}
pub(crate) fn run_git_stdout(project_root: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(project_root)
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).to_string())
}
fn tree_has_modified_build_inputs(execution_root: &Path) -> Option<bool> {
let status = run_git_stdout(execution_root, &["status", "--porcelain"])?;
if status.trim().is_empty() {
return Some(false);
}
Some(
status
.lines()
.any(|line| porcelain_tracked_path(line).is_some_and(affects_compiled_binary)),
)
}
fn porcelain_tracked_path(line: &str) -> Option<&str> {
if line.len() < 4 || line.starts_with("??") {
return None;
}
let path = &line[3..];
let path = path.rsplit(" -> ").next().unwrap_or(path);
Some(path.trim_matches('"'))
}
fn affects_compiled_binary(rel_path: &str) -> bool {
const BUILD_AFFECTING_FILES: [&str; 4] = [
"Cargo.toml",
"Cargo.lock",
"build.rs",
"rust-toolchain.toml",
];
rel_path.ends_with(".rs")
|| BUILD_AFFECTING_FILES
.iter()
.any(|name| rel_path == *name || rel_path.ends_with(&format!("/{name}")))
}
fn combined_staleness(
execution_root: &Path,
embedded_commit: &str,
build_dirty: bool,
) -> Staleness {
let ancestry = embedded_commit_is_stale(execution_root, embedded_commit);
if ancestry == Staleness::Stale {
return Staleness::Stale;
}
match tree_has_modified_build_inputs(execution_root) {
Some(true) if build_dirty => Staleness::Indeterminate,
Some(true) => Staleness::Stale,
_ => ancestry,
}
}
fn is_self_dogfood_workspace(project_root: &Path) -> bool {
let Ok(contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
return false;
};
let Some(members_start) = contents.match_indices("members").find_map(|(idx, _)| {
let preceded_by_ident = contents[..idx]
.chars()
.next_back()
.is_some_and(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-');
(!preceded_by_ident).then_some(idx)
}) else {
return false;
};
let rest = &contents[members_start..];
let Some(open_rel) = rest.find('[') else {
return false;
};
let after_open = &rest[open_rel + 1..];
let Some(close_rel) = after_open.find(']') else {
return false;
};
let members = &after_open[..close_rel];
let has_member = |wanted: &str| {
members
.split(',')
.any(|entry| entry.trim().trim_matches(['"', '\'']).trim() == wanted)
};
has_member("crates/devflow-core") && has_member("crates/devflow-cli")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StalenessOutcome {
Block,
Warn,
Ok,
}
fn staleness_outcome(is_self_dogfood: bool, staleness: Staleness) -> StalenessOutcome {
match (is_self_dogfood, staleness) {
(true, Staleness::Stale) => StalenessOutcome::Block,
(false, Staleness::Stale) => StalenessOutcome::Warn,
(_, Staleness::Ahead) => StalenessOutcome::Warn,
(_, Staleness::Indeterminate) => StalenessOutcome::Warn,
(_, Staleness::Fresh) => StalenessOutcome::Ok,
}
}
pub(crate) fn enforce_build_staleness(
project_root: &Path,
state: &State,
embedded_commit: &str,
build_dirty: bool,
) -> Result<(), CliError> {
let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
let staleness = combined_staleness(execution_root, embedded_commit, build_dirty);
let self_dogfood = is_self_dogfood_workspace(project_root);
match staleness_outcome(self_dogfood, staleness) {
StalenessOutcome::Block => {
let message = format!(
"self-dogfood stale build blocked for stage {}: a build-relevant file \
(.rs/Cargo.toml/Cargo.lock/build.rs/rust-toolchain.toml) changed in {}'s \
tracked source since this devflow binary was built, or its embedded commit \
is not an ancestor of current HEAD at all — rebuild devflow before driving \
its own workspace (D-18; the Phase 16 false-evidence incident){}",
state.stage,
execution_root.display(),
if state.worktree_path.is_some() {
" — evaluated against this phase's WORKTREE HEAD, not the main checkout; \
rebuild and reinstall the binary before resuming"
} else {
""
}
);
gates::fire_gate_notify(state.phase, state.stage, &message, true);
events::emit(
project_root,
state.phase,
"self_dogfood_stale_blocked",
serde_json::json!({
"stage": state.stage.to_string(),
"reason": "stale_build_blocked",
"worktree": state.worktree_path.is_some(),
}),
);
Err(CliError::Message(message))
}
StalenessOutcome::Warn => {
println!(
"warning: build provenance staleness check did not confirm a fresh build for \
stage {} — proceeding (only DevFlow's own workspace is ever hard-blocked, D-18)",
state.stage
);
Ok(())
}
StalenessOutcome::Ok => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::*;
use devflow_core::mode::Mode;
use devflow_core::stage::Stage;
use devflow_core::state::AgentKind;
use std::path::PathBuf;
#[test]
fn is_self_dogfood_workspace_matches_both_member_paths_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
)
.unwrap();
assert!(is_self_dogfood_workspace(root));
let name_only = tempfile::tempdir().unwrap();
std::fs::write(
name_only.path().join("Cargo.toml"),
"[package]\nname = \"devflow-cli\"\n",
)
.unwrap();
assert!(
!is_self_dogfood_workspace(name_only.path()),
"a package NAME match must never fire — the CLI package is named `devflow`"
);
let partial = tempfile::tempdir().unwrap();
std::fs::write(
partial.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\"]\n",
)
.unwrap();
assert!(!is_self_dogfood_workspace(partial.path()));
let missing = tempfile::tempdir().unwrap();
assert!(!is_self_dogfood_workspace(missing.path()));
}
#[test]
fn is_self_dogfood_workspace_requires_exact_member_paths_not_substrings() {
let lookalike = tempfile::tempdir().unwrap();
std::fs::write(
lookalike.path().join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/devflow-core-extras\",\n \"crates/devflow-cli-plugin\",\n]\n",
)
.unwrap();
assert!(
!is_self_dogfood_workspace(lookalike.path()),
"`devflow-core-extras`/`devflow-cli-plugin` are not the real members — \
a substring match here would hard-block an unrelated project"
);
let prefixed = tempfile::tempdir().unwrap();
std::fs::write(
prefixed.path().join("Cargo.toml"),
"[workspace]\nmembers = [\n \"vendor/crates/devflow-core\",\n \"vendor/crates/devflow-cli\",\n]\n",
)
.unwrap();
assert!(
!is_self_dogfood_workspace(prefixed.path()),
"vendored copies at a different path are not DevFlow's own workspace"
);
}
#[test]
fn is_self_dogfood_workspace_anchors_on_members_not_default_members() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\n\
default-members = [\"crates/devflow-cli\"]\n\
members = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
assert!(
is_self_dogfood_workspace(dir.path()),
"a `default-members` key ahead of `members` must not hide the real \
member list — that turns the D-18 hard block into a warning"
);
}
fn worktree_staleness_fixture() -> (tempfile::TempDir, PathBuf, String) {
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");
let git = |args: &[&str], cwd: &Path| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap()
.status
.success(),
"git {args:?} in {cwd:?} failed"
);
};
git(&["init", "-q", "-b", "develop"], &project_root);
git(&["config", "user.email", "t@e.st"], &project_root);
git(&["config", "user.name", "t"], &project_root);
git(&["config", "commit.gpgsign", "false"], &project_root);
git(&["config", "core.hooksPath", "/dev/null"], &project_root);
std::fs::create_dir_all(project_root.join("src")).unwrap();
std::fs::write(project_root.join("src/lib.rs"), "// base\n").unwrap();
git(&["add", "."], &project_root);
git(&["commit", "-q", "-m", "base"], &project_root);
let embedded_commit = run_git_stdout(&project_root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
git(
&[
"worktree",
"add",
"-b",
"feature/phase-90",
worktree_path.to_str().unwrap(),
"develop",
],
&project_root,
);
std::fs::write(worktree_path.join("src/lib.rs"), "// wt commit 1\n").unwrap();
git(&["add", "."], &worktree_path);
git(&["commit", "-q", "-m", "wt commit 1"], &worktree_path);
std::fs::write(worktree_path.join("src/lib.rs"), "// wt commit 2\n").unwrap();
git(&["add", "."], &worktree_path);
git(&["commit", "-q", "-m", "wt commit 2"], &worktree_path);
(outer, worktree_path, embedded_commit)
}
#[test]
fn embedded_commit_is_stale_uses_worktree_head() {
let _guard = ENV_MUTEX.lock().unwrap();
let (outer, worktree_path, embedded_commit) = worktree_staleness_fixture();
let project_root = outer.path().join("project");
assert_eq!(
embedded_commit_is_stale(&project_root, &embedded_commit),
Staleness::Fresh,
"project_root's HEAD never moved, so the embedded commit is still an exact match"
);
assert_eq!(
embedded_commit_is_stale(&worktree_path, &embedded_commit),
Staleness::Stale,
"the worktree branch advanced two commits past the embedded commit — Round 4 \
CR-01's mechanism: evaluated against the wrong tree, this same commit reads Fresh"
);
}
#[test]
fn enforce_build_staleness_blocks_self_dogfood_behind_worktree_head() {
let _guard = ENV_MUTEX.lock().unwrap();
let (outer, worktree_path, embedded_commit) = worktree_staleness_fixture();
let project_root = outer.path().join("project");
std::fs::write(
project_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
assert!(is_self_dogfood_workspace(&project_root));
let phase = 90;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, project_root.clone());
state.stage = Stage::Code;
state.worktree_path = Some(worktree_path.clone());
let err =
enforce_build_staleness(&project_root, &state, &embedded_commit, false).unwrap_err();
let message = err.to_string();
assert!(
message.contains(&worktree_path.display().to_string()),
"block message must name the worktree that was actually evaluated: {message}"
);
assert!(
!message.contains(&project_root.display().to_string()),
"block message must not name project_root when a worktree was evaluated: {message}"
);
let last = devflow_core::events::last_event_for_phase(&project_root, phase)
.expect("staleness block must record an event before returning the error");
assert_eq!(last["reason"], "stale_build_blocked");
assert_eq!(last["worktree"], true);
}
#[test]
fn staleness_without_worktree_is_unchanged() {
let _guard = ENV_MUTEX.lock().unwrap();
let (outer, _worktree_path, embedded_commit) = worktree_staleness_fixture();
let project_root = outer.path().join("project");
std::fs::write(
project_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
let phase = 91;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, project_root.clone());
state.stage = Stage::Code;
assert!(
state.worktree_path.is_none(),
"fixture precondition: no worktree recorded on this state"
);
assert!(
enforce_build_staleness(&project_root, &state, &embedded_commit, false).is_ok(),
"no worktree recorded must fall back to project_root, which the fixture never \
advances past embedded_commit"
);
}
fn init_repo_with_diverged_commit(root: &Path) -> (String, String) {
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
let rev_parse = || {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
git(&["init", "-q", "-b", "trunk"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join("a.txt"), "one").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
let base = rev_parse();
git(&["checkout", "-q", "-b", "side"]);
std::fs::write(root.join("side.txt"), "s").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "side"]);
let side = rev_parse();
git(&["checkout", "-q", "trunk"]);
std::fs::write(root.join("trunk2.rs"), "// t2\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "trunk2"]);
(base, side)
}
#[test]
fn embedded_commit_is_stale_maps_ancestry_exit_codes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let (base, side) = init_repo_with_diverged_commit(root);
let head = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
assert_eq!(embedded_commit_is_stale(root, &base), Staleness::Stale);
assert_eq!(embedded_commit_is_stale(root, &head), Staleness::Fresh);
assert_eq!(embedded_commit_is_stale(root, &side), Staleness::Stale);
assert_eq!(embedded_commit_is_stale(root, ""), Staleness::Indeterminate);
assert_eq!(
embedded_commit_is_stale(root, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
Staleness::Indeterminate
);
}
#[test]
fn wr01_clean_tree_strict_ancestor_build_is_stale_and_hard_blocks() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
std::fs::write(root.join("a.txt"), "one").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "workspace init"]);
let embedded_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "unrelated follow-up"]);
let status = run_git_stdout(root, &["status", "--porcelain"]).unwrap();
assert!(
status.trim().is_empty(),
"fixture must have a clean working tree"
);
assert_eq!(
embedded_commit_is_stale(root, &embedded_commit),
Staleness::Stale
);
assert_eq!(
combined_staleness(root, &embedded_commit, false),
Staleness::Stale
);
assert!(is_self_dogfood_workspace(root));
let phase = 66;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
let err = enforce_build_staleness(root, &state, &embedded_commit, false).unwrap_err();
assert!(
err.to_string().contains("self-dogfood stale build blocked"),
"{err}"
);
let last = devflow_core::events::last_event_for_phase(root, phase)
.expect("staleness block must record an event before returning the error");
assert_eq!(last["event"], "self_dogfood_stale_blocked");
}
#[test]
fn ahead_build_from_descendant_commit_warns_instead_of_blocking() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
std::fs::write(root.join("a.txt"), "one").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "workspace init"]);
let base_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
std::fs::write(root.join("b.txt"), "two").unwrap();
git(&["add", "."]);
git(&[
"commit",
"-q",
"-m",
"newer work the checkout does not have",
]);
let embedded_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
git(&["reset", "--hard", "-q", &base_commit]);
let status = run_git_stdout(root, &["status", "--porcelain"]).unwrap();
assert!(
status.trim().is_empty(),
"fixture must have a clean working tree"
);
assert_eq!(
embedded_commit_is_stale(root, &embedded_commit),
Staleness::Ahead,
"a descendant embedded commit is newer than HEAD, not stale"
);
assert_eq!(
staleness_outcome(true, Staleness::Ahead),
StalenessOutcome::Warn,
"an ahead build must warn, never hard-block, even for self-dogfood"
);
let phase = 67;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Validate;
assert!(
enforce_build_staleness(root, &state, &embedded_commit, false).is_ok(),
"ahead build must not block a self-dogfood workspace"
);
}
#[test]
fn dirty_flag_arm_ignores_non_build_files_but_still_flags_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
std::fs::create_dir_all(root.join("crates/devflow-cli/src")).unwrap();
std::fs::write(
root.join("crates/devflow-cli/src/main.rs"),
"fn main() {}\n",
)
.unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "workspace init"]);
let embedded_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
let build_dirty = false;
std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n\n## 1.4.26\n").unwrap();
assert_eq!(
run_git_stdout(root, &["ls-files", "-m"]).unwrap().trim(),
"CHANGELOG.md",
"fixture must have exactly one dirty tracked file"
);
assert_eq!(
tree_has_modified_build_inputs(root),
Some(false),
"a dirty CHANGELOG.md cannot change the compiled binary"
);
assert_eq!(
combined_staleness(root, &embedded_commit, build_dirty),
Staleness::Fresh,
"a doc-only dirty tree must not be Stale"
);
let phase = 68;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Ship;
assert!(
enforce_build_staleness(root, &state, &embedded_commit, build_dirty).is_ok(),
"a doc-only dirty tree must not block Ship"
);
std::fs::write(
root.join("crates/devflow-cli/src/main.rs"),
"fn main() { /* edited after build */ }\n",
)
.unwrap();
assert_eq!(
tree_has_modified_build_inputs(root),
Some(true),
"a modified .rs file is genuine staleness input"
);
git(&["add", "crates/devflow-cli/src/main.rs"]);
assert!(
!run_git_stdout(root, &["ls-files", "-m"])
.unwrap()
.lines()
.any(|line| line.ends_with(".rs")),
"fixture precondition: `ls-files -m` is blind to the staged .rs edit"
);
assert_eq!(
tree_has_modified_build_inputs(root),
Some(true),
"a STAGED source edit is just as much a staleness input as an unstaged one"
);
assert_eq!(
combined_staleness(root, &embedded_commit, build_dirty),
Staleness::Stale,
"a staged, uncommitted source edit on a clean build is Stale"
);
git(&["reset", "-q"]);
assert_eq!(
combined_staleness(root, &embedded_commit, build_dirty),
Staleness::Stale
);
assert!(
enforce_build_staleness(root, &state, &embedded_commit, build_dirty).is_err(),
"a stale source build must still hard-block a self-dogfood workspace"
);
}
#[test]
fn combined_staleness_dirty_flag_arm_flags_modified_tree_when_build_was_clean() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "// one\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
let head = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
assert_eq!(embedded_commit_is_stale(root, &head), Staleness::Fresh);
assert_eq!(combined_staleness(root, &head, false), Staleness::Fresh);
std::fs::write(root.join("src/lib.rs"), "// modified after build\n").unwrap();
assert_eq!(combined_staleness(root, &head, false), Staleness::Stale);
}
#[test]
fn docs_only_range_is_fresh() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "// base\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
let embedded_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
std::fs::create_dir_all(root.join(".planning")).unwrap();
std::fs::write(root.join(".planning/x.md"), "docs only\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "docs only"]);
assert_eq!(
embedded_commit_is_stale(root, &embedded_commit),
Staleness::Fresh,
"a docs-only strict-ancestor range must not hard-block (999.29)"
);
assert_eq!(
combined_staleness(root, &embedded_commit, false),
Staleness::Fresh
);
}
#[test]
fn mixed_range_docs_and_source_is_stale() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::create_dir_all(root.join("crates/devflow-cli/src")).unwrap();
std::fs::write(
root.join("crates/devflow-cli/src/main.rs"),
"fn main() {}\n",
)
.unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
let embedded_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
std::fs::create_dir_all(root.join(".planning")).unwrap();
std::fs::write(root.join(".planning/x.md"), "docs\n").unwrap();
std::fs::write(
root.join("crates/devflow-cli/src/main.rs"),
"fn main() { /* changed */ }\n",
)
.unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "docs + nested source"]);
assert_eq!(
embedded_commit_is_stale(root, &embedded_commit),
Staleness::Stale,
"a mixed docs+source range must still hard-block (real-change protection preserved)"
);
assert_eq!(
combined_staleness(root, &embedded_commit, false),
Staleness::Stale
);
}
#[test]
fn git_error_range_fails_toward_stale() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "// base\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
let embedded_commit = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
let embedded_tree =
run_git_stdout(root, &["rev-parse", &format!("{embedded_commit}^{{tree}}")])
.expect("rev-parse tree")
.trim()
.to_string();
std::fs::write(root.join("src/lib.rs"), "// second\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "second"]);
assert!(
std::process::Command::new("git")
.args(["merge-base", "--is-ancestor", &embedded_commit, "HEAD"])
.current_dir(root)
.status()
.unwrap()
.success(),
"fixture precondition: embedded commit must be a strict ancestor of HEAD"
);
let object_path = root
.join(".git/objects")
.join(&embedded_tree[..2])
.join(&embedded_tree[2..]);
assert!(
object_path.exists(),
"fixture precondition: tree object must exist as a loose object at {object_path:?}"
);
std::fs::remove_file(&object_path).unwrap();
assert!(
run_git_stdout(root, &["diff", "--name-only", &embedded_commit, "HEAD"]).is_none(),
"fixture precondition: git diff must fail once the embedded commit's tree object is gone"
);
assert!(
ancestry_range_affects_build(root, &embedded_commit),
"a git failure in the ancestry arm must fail toward Stale (true), never a false Fresh"
);
assert_eq!(
embedded_commit_is_stale(root, &embedded_commit),
Staleness::Stale,
"a git diff failure over the ancestry range must never yield a false Fresh"
);
}
#[test]
fn combined_staleness_dirty_flag_arm_is_indeterminate_when_build_was_already_dirty() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "// one\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
let head = run_git_stdout(root, &["rev-parse", "HEAD"])
.expect("rev-parse HEAD")
.trim()
.to_string();
assert!(is_self_dogfood_workspace(root));
std::fs::write(root.join("src/lib.rs"), "// modified\n").unwrap();
assert_eq!(embedded_commit_is_stale(root, &head), Staleness::Fresh);
assert_eq!(
tree_has_modified_build_inputs(root),
Some(true),
"fixture must have a dirty, build-affecting tree"
);
let build_was_dirty = true;
assert_eq!(
combined_staleness(root, &head, build_was_dirty),
Staleness::Indeterminate,
"cannot distinguish \"same dirt\" from \"more dirt\" without a timestamp"
);
let phase = 71;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
assert!(
enforce_build_staleness(root, &state, &head, build_was_dirty).is_ok(),
"Indeterminate must never hard-block, even for a self-dogfood workspace (Pitfall 4)"
);
}
#[test]
fn enforce_build_staleness_blocks_self_dogfood_and_records_event_before_erroring() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let (_base, side) = init_repo_with_diverged_commit(root);
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "add workspace cargo toml"]);
assert!(is_self_dogfood_workspace(root));
let phase = 63;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
let err = enforce_build_staleness(root, &state, &side, false).unwrap_err();
let message = err.to_string();
assert!(
message.contains("self-dogfood stale build blocked"),
"{message}"
);
assert!(
message.contains(&root.display().to_string()),
"the returned CliError (terminal-only) must still name the path: {message}"
);
let last = devflow_core::events::last_event_for_phase(root, phase)
.expect("staleness block must record an event before returning the error");
assert_eq!(last["event"], "self_dogfood_stale_blocked");
assert_eq!(last["reason"], "stale_build_blocked");
assert_eq!(last["worktree"], false);
let reason_str = last["reason"].as_str().unwrap();
assert!(
!reason_str.contains(&root.display().to_string()),
"persisted reason must never carry the project root path: {reason_str}"
);
}
#[test]
fn enforce_build_staleness_warns_for_ordinary_project_with_stale_commit() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let (_base, side) = init_repo_with_diverged_commit(root);
assert!(!is_self_dogfood_workspace(root));
let phase = 64;
let state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
let result = enforce_build_staleness(root, &state, &side, false);
assert!(
result.is_ok(),
"an ordinary project's stale build must only warn, never block"
);
assert!(
devflow_core::events::last_event_for_phase(root, phase).is_none(),
"a warn-only path must not fire the self_dogfood_stale_blocked event"
);
}
#[test]
fn enforce_build_staleness_never_blocks_on_indeterminate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
assert!(
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap()
.status
.success(),
"git {args:?} failed"
);
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@e.st"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/devflow-core\", \"crates/devflow-cli\"]\n",
)
.unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
assert!(is_self_dogfood_workspace(root));
let phase = 65;
let state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
let result = enforce_build_staleness(
root,
&state,
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
false,
);
assert!(
result.is_ok(),
"an Indeterminate result must never hard-block"
);
}
}