huskies: merge 831
This commit is contained in:
@@ -0,0 +1,975 @@
|
||||
//! 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 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"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user