1017 lines
32 KiB
Rust
1017 lines
32 KiB
Rust
//! Tests for the merge pipeline module.
|
|
|
|
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;
|
|
|
|
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() {
|
|
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:?}"
|
|
);
|
|
}
|
|
|
|
// ── 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() {
|
|
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()
|
|
);
|
|
}
|
|
|
|
// ── 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() {
|
|
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() {
|
|
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) => {
|
|
assert!(!report.success, "should fail when branch missing");
|
|
}
|
|
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() {
|
|
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!(!report.had_conflicts, "should have no conflicts");
|
|
assert!(
|
|
report.success
|
|
|| report.gate_output.contains("Failed to run")
|
|
|| !report.gates_passed,
|
|
"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() {
|
|
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!(
|
|
!result.success,
|
|
"run_squash_merge must report failure when gates fail"
|
|
);
|
|
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() {
|
|
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) => {
|
|
assert!(report.had_conflicts, "should report conflicts");
|
|
}
|
|
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() {
|
|
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;
|
|
|
|
// The job must have failed with a "no commits to merge" error.
|
|
match &job.status {
|
|
MergeJobStatus::Failed(e) => {
|
|
assert!(
|
|
e.contains("no commits to merge"),
|
|
"error must contain 'no commits to merge', got: {e}"
|
|
);
|
|
assert!(
|
|
e.contains("675_zero_commits"),
|
|
"error must name the story_id, got: {e}"
|
|
);
|
|
}
|
|
MergeJobStatus::Completed(report) => {
|
|
panic!(
|
|
"expected Failed status, got Completed with success={}: {}",
|
|
report.success, report.gate_output
|
|
);
|
|
}
|
|
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() {
|
|
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",
|
|
);
|
|
|
|
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!(
|
|
!report.had_conflicts,
|
|
"clean branch should have no conflicts"
|
|
);
|
|
if report.success {
|
|
// story_archived may or may not be true depending on gate env,
|
|
// but merge_failure must NOT be in the content store.
|
|
let content = crate::db::read_content("757a_happy");
|
|
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.
|
|
let content = crate::db::read_content("757a_happy");
|
|
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() {
|
|
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",
|
|
);
|
|
|
|
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,
|
|
MergeJobStatus::Completed(r) if !r.success
|
|
) || matches!(&job.status, MergeJobStatus::Failed(_));
|
|
assert!(
|
|
failed,
|
|
"conflicting branches must not succeed; status: {:?}",
|
|
job.status
|
|
);
|
|
|
|
// merge_failure must be set in the content store.
|
|
let content = crate::db::read_content("757b_conflict").expect("story content must be in store");
|
|
assert!(
|
|
content.contains("merge_failure"),
|
|
"merge_failure must be written to story on conflict: {content}"
|
|
);
|
|
|
|
// 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() {
|
|
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",
|
|
);
|
|
|
|
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!(
|
|
!report.success,
|
|
"gates should have failed; report: {report:?}"
|
|
);
|
|
assert!(
|
|
!report.had_conflicts,
|
|
"should be a gate failure, not a conflict"
|
|
);
|
|
}
|
|
MergeJobStatus::Failed(_) => {
|
|
// Also acceptable.
|
|
}
|
|
MergeJobStatus::Running => panic!("should not still be running"),
|
|
}
|
|
|
|
// merge_failure must be set in the content store.
|
|
let content = crate::db::read_content("757c_gates").expect("story content must be in store");
|
|
assert!(
|
|
content.contains("merge_failure"),
|
|
"merge_failure must be written when gates fail: {content}"
|
|
);
|
|
|
|
// 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() {
|
|
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.
|
|
assert!(
|
|
report.success || !report.gates_passed,
|
|
"unexpected state: success={} gates_passed={}",
|
|
report.success,
|
|
report.gates_passed
|
|
);
|
|
}
|
|
MergeJobStatus::Running => panic!("should not still be running"),
|
|
}
|
|
}
|