use devflow_core::agent_result;
use devflow_core::events;
use devflow_core::gates::Gates;
use devflow_core::lock;
use devflow_core::monitor;
use devflow_core::ship;
use devflow_core::workflow;
use devflow_core::{AgentKind, Mode, Stage, State};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
fn gitignore_is_star(root: &Path) -> bool {
std::fs::read_to_string(root.join(".devflow").join(".gitignore"))
.map(|contents| contents.trim() == "*")
.unwrap_or(false)
}
fn wait_for_file(path: &Path, timeout: Duration) {
let start = Instant::now();
while !path.exists() {
assert!(
start.elapsed() < timeout,
"expected {} to appear within {timeout:?}",
path.display()
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_for_pid_to_die(pid: u32, timeout: Duration) {
unsafe {
let mut status: libc::c_int = 0;
libc::waitpid(pid as libc::pid_t, &mut status, 0);
}
let start = Instant::now();
while devflow_core::agent::agent_running(pid) {
assert!(
start.elapsed() < timeout,
"monitor process pid {pid} did not terminate within {timeout:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn all_seven_devflow_constructors_produce_the_gitignore() {
let mut failures: Vec<&str> = Vec::new();
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let state = State::new(1, AgentKind::Claude, Mode::Auto, root.to_path_buf());
workflow::save_state(&state).expect("save_state");
if !gitignore_is_star(root) {
failures.push("workflow::save_state (workflow.rs)");
}
}
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
Gates::write_gate(root, 1, Stage::Validate, "context").expect("write_gate");
if !gitignore_is_star(root) {
failures.push("gates::Gates::write_gate (gates.rs)");
}
}
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let state = State::new(1, AgentKind::Claude, Mode::Auto, root.to_path_buf());
let pid = monitor::spawn_monitor_no_advance(
&state,
"sh",
&["-c".to_string(), "exit 0".to_string()],
&[],
)
.expect("spawn_monitor_no_advance");
wait_for_file(
&agent_result::exit_code_path(root, 1),
Duration::from_secs(5),
);
wait_for_pid_to_die(pid, Duration::from_secs(5));
if !gitignore_is_star(root) {
failures.push("monitor::spawn_monitor_no_advance (monitor.rs)");
}
}
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(agent_result::stdout_path(root, 1), "stdout capture\n").unwrap();
archive_phase_files_or_panic(root, 1);
if !gitignore_is_star(root) {
failures.push("agent_result::archive_phase_files (agent_result.rs)");
}
}
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
events::emit(root, 1, "test_event", serde_json::json!({}));
if !gitignore_is_star(root) {
failures.push("events::emit (events.rs)");
}
}
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let instructions = ship::build_cron_instructions(root, 1, "2026-01-01T00:00:00Z", "claude");
ship::write_cron_instructions(root, &instructions).expect("write_cron_instructions");
if !gitignore_is_star(root) {
failures.push("ship::write_cron_instructions (ship.rs)");
}
}
{
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let _guard = lock::acquire(root, 1).expect("lock::acquire");
if !gitignore_is_star(root) {
failures.push("lock::acquire (lock.rs)");
}
}
assert!(
failures.is_empty(),
"the following .devflow/ constructor(s) did not produce a .devflow/.gitignore: {failures:?}"
);
}
fn archive_phase_files_or_panic(root: &Path, phase: u32) {
agent_result::archive_phase_files(root, root, phase, 5).expect("archive_phase_files");
}
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_scratch_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "test@example.com"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
std::fs::write(root.join("README.md"), "# scratch\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "initial commit"]);
dir
}
#[test]
fn git_add_all_no_longer_sweeps_devflow_into_a_commit() {
let repo = init_scratch_repo();
let root = repo.path();
let devflow_dir = root.join(".devflow");
workflow::ensure_devflow_dir(&devflow_dir).expect("ensure_devflow_dir");
std::fs::write(
devflow_dir.join("phase-01-stdout"),
"SENTINEL: unredacted agent stdout would go here\n",
)
.unwrap();
std::fs::write(root.join("README.md"), "# scratch\n\nupdated.\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "routine commit"]);
let committed = Command::new("git")
.args(["log", "-1", "--name-only", "--pretty=format:"])
.current_dir(root)
.output()
.expect("git log");
let committed_files = String::from_utf8_lossy(&committed.stdout);
assert!(
!committed_files.contains(".devflow/"),
"commit swept .devflow/ paths into history: {committed_files}"
);
let repo2 = init_scratch_repo();
let root2 = repo2.path();
workflow::ensure_devflow_dir(&root2.join(".devflow")).expect("ensure_devflow_dir");
git(root2, &["add", "."]);
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(root2)
.output()
.expect("git status");
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"a .devflow/ containing only .gitignore must stage nothing"
);
}