Files
huskies/server/src/agents/pool/pipeline/merge/tests.rs
T

1109 lines
36 KiB
Rust
Raw Normal View History

2026-04-29 00:11:52 +00:00
//! Tests for the merge pipeline module.
// `serial_test_lock` returns a `std::sync::MutexGuard<'static, ()>` that
// each test holds for its full body — including across `.await` points.
// Clippy normally warns about that because async-yielding while holding a
// sync mutex can deadlock arbitrary tasks; here it's exactly the goal:
// only one test in this module holds the guard at a time, the rest block
// on `lock()`, and the merge gate's flaky interleaving is eliminated.
#![allow(clippy::await_holding_lock)]
2026-04-29 00:11:52 +00:00
use super::super::super::AgentPool;
use super::time::{encode_server_start_time, server_start_time};
use crate::agents::merge::{MergeJob, MergeJobStatus};
use std::process::Command;
use std::sync::Arc;
/// Acquire a process-wide serial lock for the merge-pipeline tests.
///
/// These tests share a process-wide `server_start_time()` (a `OnceLock`
/// captured the first time anything in the merge subsystem calls it) and
/// touch the global merge-job CRDT log. Cargo runs them in parallel by
/// default, and the merge gate's Docker scheduler has caught at least one
/// interleaving where one test's `delete_merge_job` lands while another is
/// asserting the entry is still there.
///
/// Holding this mutex for each test serialises only the merge-pipeline
/// tests against each other — the rest of the suite stays parallel. The
/// guard is poison-tolerant so a panicking test doesn't lock out everything
/// that follows.
fn serial_test_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
2026-04-29 00:11:52 +00:00
fn init_git_repo(repo: &std::path::Path) {
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
}
// ── bug 498: stale Running job blocks retry ───────────────────────────────
/// Regression test for bug 498: a Running merge job left behind by a killed
/// mergemaster must not block the next call to start_merge_agent_work.
///
/// Before the fix: start_merge_agent_work would return "Merge already in
/// progress" when a Running entry existed, even after the mergemaster died.
/// After the fix: the entry is cleared when the mergemaster exits, so a new
/// call succeeds.
#[tokio::test]
async fn stale_running_merge_job_is_cleared_and_retry_succeeds() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let pool = Arc::new(AgentPool::new_test(3001));
// Inject a stale Running entry via CRDT, simulating a mergemaster that
// died before the merge pipeline completed. Use the current process PID
// so the stale-lock sweep does NOT auto-remove it — this test verifies
// the double-start guard path.
crate::crdt_state::write_merge_job(
"77_story_stale",
"running",
1.0,
None,
Some(&encode_server_start_time(server_start_time())),
);
// With a stale Running entry, start_merge_agent_work must be blocked.
let blocked = pool.start_merge_agent_work(repo, "77_story_stale");
assert!(
blocked.is_err(),
"start_merge_agent_work must be blocked while Running job exists"
);
let err_msg = blocked.unwrap_err();
assert!(
err_msg.contains("already in progress"),
"unexpected error: {err_msg}"
);
// Simulate the mergemaster exit path: clear the stale Running entry.
crate::crdt_state::delete_merge_job("77_story_stale");
// After clearing, start_merge_agent_work must succeed (it will fail
// the pipeline because there's no feature branch, but it must not be
// blocked by "Merge already in progress").
let result = pool.start_merge_agent_work(repo, "77_story_stale");
assert!(
result.is_ok(),
"start_merge_agent_work must succeed after stale Running job is cleared; got: {result:?}"
);
}
2026-04-29 08:51:22 +00:00
// ── story 852: periodic background reaper ────────────────────────────────
/// AC2: a Running merge_job whose `server_start` is older than the current
/// server's boot time must be deleted by `reap_stale_merge_jobs` without any
/// merge attempt being triggered.
#[tokio::test]
async fn reap_stale_merge_jobs_removes_old_running_entry_without_merge() {
let _serial = serial_test_lock();
2026-04-29 08:51:22 +00:00
crate::crdt_state::init_for_test();
let pool = Arc::new(AgentPool::new_test(3001));
// Inject a Running entry whose server_start predates the current server.
crate::crdt_state::write_merge_job(
"852_stale_reaper",
"running",
1.0,
None,
Some(&super::time::encode_server_start_time(0.0)), // older boot → stale
);
assert!(
crate::crdt_state::read_merge_job("852_stale_reaper").is_some(),
"stale entry must exist before reap"
);
// Reap: must remove the entry without triggering a merge pipeline.
pool.reap_stale_merge_jobs();
assert!(
crate::crdt_state::read_merge_job("852_stale_reaper").is_none(),
"reap_stale_merge_jobs must delete the stale Running entry"
);
// No agents must have been spawned (no merge was triggered).
let agents = pool.agents.lock().unwrap();
assert!(
agents.is_empty(),
"reap must not spawn any agents; got {} agent(s)",
agents.len()
);
}
2026-04-29 00:11:52 +00:00
// ── story 719: stale-lock recovery on new merge attempts ─────────────────
/// AC1/AC2/AC3: seeding merge_jobs with an entry whose PID is dead, then
/// triggering a new merge for a *different* story, must automatically remove
/// the stale entry (AC1/AC3) and log at INFO (AC2 — verified structurally
/// because the log path is exercised when the entry is removed).
#[cfg(unix)]
#[tokio::test]
async fn stale_merge_job_with_dead_pid_is_swept_on_new_merge_attempt() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let pool = Arc::new(AgentPool::new_test(3001));
// Seed CRDT merge_jobs with a Running entry whose recorded server-start
// time is older than the current server (legacy / previous instance).
crate::crdt_state::write_merge_job(
"719_stale_other",
"running",
1.0,
None,
Some(&encode_server_start_time(0.0)), // legacy/older boot — should be cleaned up
);
// Verify the entry is present before the sweep.
assert!(
crate::crdt_state::read_merge_job("719_stale_other").is_some(),
"stale entry should exist before new merge attempt"
);
// Trigger a new merge for a *different* story. The sweep runs at the
// top of start_merge_agent_work and must remove the dead-PID entry.
let _ = pool.start_merge_agent_work(repo, "719_trigger_story");
// The stale entry must have been cleared.
assert!(
crate::crdt_state::read_merge_job("719_stale_other").is_none(),
"stale entry with dead pid must be removed when a new merge attempt starts"
);
}
// ── merge_agent_work tests ────────────────────────────────────────────────
/// Helper: start a merge and poll until terminal state.
async fn run_merge_to_completion(
pool: &Arc<AgentPool>,
repo: &std::path::Path,
story_id: &str,
) -> MergeJob {
pool.start_merge_agent_work(repo, story_id).unwrap();
loop {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Some(job) = pool.get_merge_status(story_id)
&& !matches!(job.status, MergeJobStatus::Running)
{
return job;
}
}
}
#[tokio::test]
async fn merge_agent_work_returns_error_when_branch_not_found() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "99_nonexistent").await;
match &job.status {
MergeJobStatus::Completed(report) => {
2026-05-13 16:26:09 +00:00
assert!(
!matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
),
"should fail when branch missing"
);
2026-04-29 00:11:52 +00:00
}
MergeJobStatus::Failed(_) => {
// Also acceptable — the pipeline errored out
}
MergeJobStatus::Running => {
panic!("should not still be running");
}
}
}
#[tokio::test]
async fn merge_agent_work_succeeds_on_clean_branch() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a feature branch with a commit
Command::new("git")
.args(["checkout", "-b", "feature/story-23_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature.txt"), "feature content").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feature"])
.current_dir(repo)
.output()
.unwrap();
// Switch back to master (initial branch)
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Create the story file in 4_merge/ so we can test archival
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
let story_file = merge_dir.join("23_test.md");
fs::write(&story_file, "---\nname: Test\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story in merge"])
.current_dir(repo)
.output()
.unwrap();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "23_test").await;
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(
2026-05-13 16:26:09 +00:00
!matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
"should have no conflicts"
);
let is_success = matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
);
let is_gate_failure = matches!(
report.result,
crate::agents::merge::MergeResult::GateFailure { .. }
);
assert!(
is_success || is_gate_failure || report.result.output().contains("Failed to run"),
2026-04-29 00:11:52 +00:00
"report should be coherent: {report:?}"
);
if report.story_archived {
let done = repo.join(".huskies/work/5_done/23_test.md");
assert!(done.exists(), "done file should exist");
}
}
MergeJobStatus::Failed(e) => {
// Gate failures are acceptable in test env
assert!(
e.contains("Failed") || e.contains("failed"),
"unexpected failure: {e}"
);
}
MergeJobStatus::Running => panic!("should not still be running"),
}
}
// ── quality gate ordering test ────────────────────────────────
/// Regression test for bug 142: quality gates must run BEFORE the fast-forward
/// to master so that broken code never lands on master.
#[cfg(unix)]
#[test]
fn quality_gates_run_before_fast_forward_to_master() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Add a failing script/test so quality gates will fail.
let script_dir = repo.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script_test = script_dir.join("test");
fs::write(&script_test, "#!/usr/bin/env bash\nexit 1\n").unwrap();
let mut perms = fs::metadata(&script_test).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_test, perms).unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add failing script/test"])
.current_dir(repo)
.output()
.unwrap();
// Create a feature branch with a commit.
Command::new("git")
.args(["checkout", "-b", "feature/story-142_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("change.txt"), "feature change").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature work"])
.current_dir(repo)
.output()
.unwrap();
// Switch back to master and record its HEAD.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
let head_before = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
// Run the squash-merge. The failing script/test makes quality gates
// fail → fast-forward must NOT happen.
let result =
crate::agents::merge::run_squash_merge(repo, "feature/story-142_test", "142_test").unwrap();
let head_after = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
// Gates must have failed (script/test exits 1) so master should be untouched.
assert!(
2026-05-13 16:26:09 +00:00
!matches!(result, crate::agents::merge::MergeResult::Success { .. }),
"run_squash_merge must report failure when gates fail; got: {result:?}"
2026-04-29 00:11:52 +00:00
);
assert_eq!(
head_before, head_after,
"master HEAD must not advance when quality gates fail (bug 142)"
);
}
#[tokio::test]
async fn merge_agent_work_conflict_does_not_break_master() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a file on master.
fs::write(
repo.join("code.rs"),
"fn main() {\n println!(\"hello\");\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial code"])
.current_dir(repo)
.output()
.unwrap();
// Feature branch: modify the same line differently.
Command::new("git")
.args(["checkout", "-b", "feature/story-42_story_foo"])
.current_dir(repo)
.output()
.unwrap();
fs::write(
repo.join("code.rs"),
"fn main() {\n println!(\"hello\");\n feature_fn();\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature: add fn call"])
.current_dir(repo)
.output()
.unwrap();
// Master: add different line at same location.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
fs::write(
repo.join("code.rs"),
"fn main() {\n println!(\"hello\");\n master_fn();\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "master: add fn call"])
.current_dir(repo)
.output()
.unwrap();
// Create story file in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(merge_dir.join("42_story_foo.md"), "---\nname: Test\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(repo)
.output()
.unwrap();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "42_story_foo").await;
// Master should NEVER have conflict markers, regardless of merge outcome.
let master_code = fs::read_to_string(repo.join("code.rs")).unwrap();
assert!(
!master_code.contains("<<<<<<<"),
"master must never contain conflict markers:\n{master_code}"
);
assert!(
!master_code.contains(">>>>>>>"),
"master must never contain conflict markers:\n{master_code}"
);
// The report should accurately reflect what happened.
match &job.status {
MergeJobStatus::Completed(report) => {
2026-05-13 16:26:09 +00:00
assert!(
matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
"should report conflicts"
);
2026-04-29 00:11:52 +00:00
}
MergeJobStatus::Failed(_) => {
// Acceptable — merge aborted due to conflicts
}
MergeJobStatus::Running => panic!("should not still be running"),
}
}
// ── bug 675: zero commits ahead must fail with "no commits to merge" ─────
/// Regression test for bug 675: when the feature branch has zero commits
/// ahead of master the pipeline must fail with a clear "no commits to merge"
/// error and the story must remain in `4_merge` (not advance to `5_done`).
#[tokio::test]
async fn merge_agent_work_zero_commits_ahead_stays_in_merge_stage() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Feature branch is created at the same commit as master — zero commits ahead.
Command::new("git")
.args(["checkout", "-b", "feature/story-675_zero_commits"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Place the story file in 4_merge so we can verify it stays there.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("675_zero_commits.md"),
"---\nname: Zero commits test\n---\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "place story in 4_merge"])
.current_dir(repo)
.output()
.unwrap();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "675_zero_commits").await;
2026-05-13 15:57:24 +00:00
// The job must have completed with success=false and no_commits=true.
// Since story 986 the "no commits" path returns Ok(result) not Err, so the
// job status is Completed (not Failed).
2026-04-29 00:11:52 +00:00
match &job.status {
2026-05-13 15:57:24 +00:00
MergeJobStatus::Completed(report) => {
2026-04-29 00:11:52 +00:00
assert!(
2026-05-13 16:26:09 +00:00
matches!(
report.result,
crate::agents::merge::MergeResult::NoCommits { .. }
),
"merge must fail with NoCommits when feature branch is empty; got: {:?}",
report.result
2026-04-29 00:11:52 +00:00
);
2026-05-13 15:57:24 +00:00
assert!(
2026-05-13 16:26:09 +00:00
report.result.output().contains("no commits to merge"),
"output must contain 'no commits to merge', got: {}",
report.result.output()
2026-04-29 00:11:52 +00:00
);
}
2026-05-13 15:57:24 +00:00
MergeJobStatus::Failed(e) => {
panic!("expected Completed(success=false) status, got Failed: {e}");
}
2026-04-29 00:11:52 +00:00
MergeJobStatus::Running => panic!("should not still be running"),
}
// Story file must still be in 4_merge — NOT advanced to 5_done.
assert!(
merge_dir.join("675_zero_commits.md").exists(),
"story file must remain in 4_merge when merge fails"
);
assert!(
!repo
.join(".huskies/work/5_done/675_zero_commits.md")
.exists(),
"story must NOT advance to 5_done when merge fails with no commits"
);
}
// ── Story 757: deterministic server-side merge ────────────────────────────
/// AC5 (happy path): a clean feature branch with one commit ahead of master
/// must advance to `5_done/` automatically with no LLM agent involved.
/// The merge_failure field must NOT be written.
#[tokio::test]
async fn server_side_merge_happy_path_advances_to_done() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Feature branch: one commit ahead of master.
Command::new("git")
.args(["checkout", "-b", "feature/story-757a_happy"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("happy.txt"), "content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add happy file"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Place story in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("757a_happy.md"),
"---\nname: Happy path test\n---\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "place story in 4_merge"])
.current_dir(repo)
.output()
.unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"757a_happy",
"4_merge",
"---\nname: Happy path test\n---\n",
crate::db::ItemMeta::named("Happy path test"),
2026-04-29 00:11:52 +00:00
);
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "757a_happy").await;
// Verify the merge succeeded and story advanced to 5_done.
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(
2026-05-13 16:26:09 +00:00
!matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
2026-04-29 00:11:52 +00:00
"clean branch should have no conflicts"
);
2026-05-13 16:26:09 +00:00
if matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
) {
2026-04-29 00:11:52 +00:00
// story_archived may or may not be true depending on gate env,
// but merge_failure must NOT be in the content store.
2026-05-13 11:22:57 +00:00
let content = crate::db::read_content(crate::db::ContentKey::Story("757a_happy"));
2026-04-29 00:11:52 +00:00
if let Some(c) = content {
assert!(
!c.contains("merge_failure"),
"merge_failure must not be set on success: {c}"
);
}
} else {
// Gate failure (no script/test) is acceptable in test env —
// but merge_failure should be written.
2026-05-13 11:22:57 +00:00
let content = crate::db::read_content(crate::db::ContentKey::Story("757a_happy"));
2026-04-29 00:11:52 +00:00
if let Some(c) = content {
// merge_failure should be written for gate failures
assert!(
c.contains("merge_failure"),
"merge_failure must be set when gates fail: {c}"
);
}
}
}
MergeJobStatus::Failed(_) => {
// Acceptable — "no commits to merge" or similar infra failure.
}
MergeJobStatus::Running => panic!("should not still be running"),
}
// Verify no LLM agent was spawned.
let agents = pool.agents.lock().unwrap();
assert!(
agents.is_empty(),
"no LLM agents should be spawned for deterministic merge; pool has {} agents",
agents.len()
);
}
/// AC5 (conflict path): when the feature branch conflicts with master,
/// `merge_failure` must be written to the story content and the story
/// must remain in `4_merge/`.
#[tokio::test]
async fn server_side_merge_conflict_sets_merge_failure() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a file on master.
fs::write(repo.join("shared.rs"), "fn master() {}\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "master: add shared.rs"])
.current_dir(repo)
.output()
.unwrap();
// Feature branch: modify the same file differently.
Command::new("git")
.args(["checkout", "-b", "feature/story-757b_conflict"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("shared.rs"), "fn feature() {}\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature: rewrite shared.rs"])
.current_dir(repo)
.output()
.unwrap();
// Master: modify the same file differently.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("shared.rs"), "fn master_v2() {}\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "master: update shared.rs"])
.current_dir(repo)
.output()
.unwrap();
// Place story in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("757b_conflict.md"),
"---\nname: Conflict test\n---\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "place story in 4_merge"])
.current_dir(repo)
.output()
.unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"757b_conflict",
"4_merge",
"---\nname: Conflict test\n---\n",
crate::db::ItemMeta::named("Conflict test"),
2026-04-29 00:11:52 +00:00
);
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "757b_conflict").await;
// The merge must fail (conflict).
let failed = matches!(
&job.status,
2026-05-13 16:26:09 +00:00
MergeJobStatus::Completed(r)
if !matches!(r.result, crate::agents::merge::MergeResult::Success { .. })
2026-04-29 00:11:52 +00:00
) || matches!(&job.status, MergeJobStatus::Failed(_));
assert!(
failed,
"conflicting branches must not succeed; status: {:?}",
job.status
);
// Story 929: merge_failure detail is persisted on the MergeJob CRDT entry,
// not the YAML body.
let mj = crate::crdt_state::read_merge_job("757b_conflict").expect("merge job must be in CRDT");
2026-04-29 00:11:52 +00:00
assert!(
mj.error.is_some(),
"MergeJob.error must be set on conflict: {mj:?}"
2026-04-29 00:11:52 +00:00
);
// Story must remain in 4_merge (not advanced to 5_done).
assert!(
!repo.join(".huskies/work/5_done/757b_conflict.md").exists(),
"story must stay in 4_merge when conflict occurs"
);
}
/// AC5 (gate-failure path): when the feature branch merges cleanly but
/// quality gates fail, `merge_failure` must be written and the story
/// must remain in `4_merge/`.
#[cfg(unix)]
#[tokio::test]
async fn server_side_merge_gate_failure_sets_merge_failure() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Add a failing script/test so quality gates will always fail.
let script_dir = repo.join("script");
fs::create_dir_all(&script_dir).unwrap();
let script_test = script_dir.join("test");
fs::write(&script_test, "#!/usr/bin/env sh\nexit 1\n").unwrap();
let mut perms = fs::metadata(&script_test).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_test, perms).unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add failing gates"])
.current_dir(repo)
.output()
.unwrap();
// Feature branch: one commit ahead of master.
Command::new("git")
.args(["checkout", "-b", "feature/story-757c_gates"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature_c.txt"), "content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feature"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Place story in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("757c_gates.md"),
"---\nname: Gate failure test\n---\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "place story in 4_merge"])
.current_dir(repo)
.output()
.unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"757c_gates",
"4_merge",
"---\nname: Gate failure test\n---\n",
crate::db::ItemMeta::named("Gate failure test"),
2026-04-29 00:11:52 +00:00
);
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "757c_gates").await;
// The merge must report gate failure (not conflict).
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(
2026-05-13 16:26:09 +00:00
!matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
),
2026-04-29 00:11:52 +00:00
"gates should have failed; report: {report:?}"
);
assert!(
2026-05-13 16:26:09 +00:00
!matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
2026-04-29 00:11:52 +00:00
"should be a gate failure, not a conflict"
);
}
MergeJobStatus::Failed(_) => {
// Also acceptable.
}
MergeJobStatus::Running => panic!("should not still be running"),
}
// Story 929: merge_failure detail is persisted on the MergeJob CRDT
// entry, not the YAML body.
let mj = crate::crdt_state::read_merge_job("757c_gates").expect("merge job must be in CRDT");
2026-04-29 00:11:52 +00:00
assert!(
mj.error.is_some(),
"MergeJob.error must be set on gate failure: {mj:?}"
2026-04-29 00:11:52 +00:00
);
// Story must remain in 4_merge.
assert!(
!repo.join(".huskies/work/5_done/757c_gates.md").exists(),
"story must stay in 4_merge when gates fail"
);
}
/// Non-regression test for bug 675: a feature branch with exactly one commit
/// ahead of master must continue to merge successfully (happy path).
#[tokio::test]
async fn merge_agent_work_one_commit_ahead_merges_successfully() {
let _serial = serial_test_lock();
2026-04-29 00:11:52 +00:00
use std::fs;
use tempfile::tempdir;
crate::crdt_state::init_for_test();
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Feature branch: one commit ahead of master.
Command::new("git")
.args(["checkout", "-b", "feature/story-675_one_commit"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature_675.txt"), "feature content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feature file"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// Place the story file in 4_merge.
let merge_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&merge_dir).unwrap();
fs::write(
merge_dir.join("675_one_commit.md"),
"---\nname: One commit test\n---\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "place story in 4_merge"])
.current_dir(repo)
.output()
.unwrap();
let pool = Arc::new(AgentPool::new_test(3001));
let job = run_merge_to_completion(&pool, repo, "675_one_commit").await;
// The merge must not fail with "no commits to merge".
match &job.status {
MergeJobStatus::Failed(e) => {
assert!(
!e.contains("no commits to merge"),
"one-commit-ahead branch must NOT fail with 'no commits to merge': {e}"
);
// Gate failures (no script/test) are acceptable in test env.
}
MergeJobStatus::Completed(report) => {
// Success or gate failure — both acceptable; the key invariant is
// that we didn't fail with the zero-commits early-exit.
2026-05-13 16:26:09 +00:00
let is_success = matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
);
let is_gate_failure = matches!(
report.result,
crate::agents::merge::MergeResult::GateFailure { .. }
);
2026-04-29 00:11:52 +00:00
assert!(
2026-05-13 16:26:09 +00:00
is_success || is_gate_failure,
"unexpected result variant: {:?}",
report.result
2026-04-29 00:11:52 +00:00
);
}
MergeJobStatus::Running => panic!("should not still be running"),
}
}