Files
huskies/server/src/agents/merge/squash/tests_basic.rs
T

431 lines
13 KiB
Rust
Raw Normal View History

2026-04-29 09:25:05 +00:00
//! Tests for squash-merge orchestration — basic cases.
use super::*;
use std::process::Command;
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();
}
#[tokio::test]
async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a file that will be conflicted on master.
fs::write(repo.join("shared.txt"), "line 1\nline 2\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial shared file"])
.current_dir(repo)
.output()
.unwrap();
// Create a feature branch that modifies the file.
Command::new("git")
.args(["checkout", "-b", "feature/story-conflict_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(
repo.join("shared.txt"),
"line 1\nline 2\nfeature addition\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature: add line"])
.current_dir(repo)
.output()
.unwrap();
// Switch to master and make a conflicting change.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("shared.txt"), "line 1\nline 2\nmaster addition\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "master: add line"])
.current_dir(repo)
.output()
.unwrap();
// Run the squash merge.
let result = run_squash_merge(repo, "feature/story-conflict_test", "conflict_test").unwrap();
// Master should NEVER contain conflict markers, regardless of outcome.
let master_content = fs::read_to_string(repo.join("shared.txt")).unwrap();
assert!(
!master_content.contains("<<<<<<<"),
"master must never contain conflict markers, got:\n{master_content}"
);
assert!(
!master_content.contains(">>>>>>>"),
"master must never contain conflict markers, got:\n{master_content}"
);
2026-05-13 16:26:09 +00:00
// The merge should have had conflicts (returned as Conflict variant).
assert!(
matches!(result, super::MergeResult::Conflict { .. }),
"should detect conflicts; got: {result:?}"
);
// Verify no leftover merge-queue branch.
let branches = Command::new("git")
.args(["branch", "--list", "merge-queue/*"])
.current_dir(repo)
.output()
.unwrap();
let branch_list = String::from_utf8_lossy(&branches.stdout);
assert!(
branch_list.trim().is_empty(),
"merge-queue branch should be cleaned up, got: {branch_list}"
);
// Verify no leftover merge workspace directory.
assert!(
!repo.join(".huskies/merge_workspace").exists(),
"merge workspace should be cleaned up"
);
}
#[tokio::test]
async fn squash_merge_clean_merge_succeeds() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create feature branch with a new file.
Command::new("git")
.args(["checkout", "-b", "feature/story-clean_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("new_file.txt"), "new content").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add new file"])
.current_dir(repo)
.output()
.unwrap();
// Switch back to master.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test").unwrap();
assert!(
2026-05-13 16:26:09 +00:00
matches!(
result,
super::MergeResult::Success {
conflicts_resolved: false,
..
}
),
"clean merge should succeed without conflicts; got: {result:?}"
);
assert!(
repo.join("new_file.txt").exists(),
"merged file should exist on master"
);
}
#[tokio::test]
async fn squash_merge_nonexistent_branch_fails() {
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let result = run_squash_merge(repo, "feature/story-nope", "nope").unwrap();
2026-05-13 16:26:09 +00:00
assert!(
!matches!(result, super::MergeResult::Success { .. }),
"merge of nonexistent branch should fail; got: {result:?}"
);
}
#[tokio::test]
async fn squash_merge_succeeds_when_master_diverges() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create an initial file on master.
fs::write(repo.join("base.txt"), "base content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(repo)
.output()
.unwrap();
// Create a feature branch with a new file (clean merge, no conflicts).
Command::new("git")
.args(["checkout", "-b", "feature/story-diverge_test"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feature.txt"), "feature content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "feature: add file"])
.current_dir(repo)
.output()
.unwrap();
// Switch back to master and simulate a filesystem watcher commit
// (e.g. a pipeline file move) that advances master beyond the point
// where the merge-queue branch will be created.
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
let sk_dir = repo.join(".huskies/work/4_merge");
fs::create_dir_all(&sk_dir).unwrap();
fs::write(sk_dir.join("diverge_test.md"), "---\nname: test\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "huskies: queue diverge_test for merge"])
.current_dir(repo)
.output()
.unwrap();
// Run the squash merge. With the old fast-forward approach, this
// would fail because master diverged. With cherry-pick, it succeeds.
let result = run_squash_merge(repo, "feature/story-diverge_test", "diverge_test").unwrap();
assert!(
2026-05-13 16:26:09 +00:00
matches!(result, super::MergeResult::Success { .. }),
"squash merge should succeed despite diverged master: {:?}",
result
);
// Verify the feature file landed on master.
assert!(
repo.join("feature.txt").exists(),
"feature file should be on master after cherry-pick"
);
let feature_content = fs::read_to_string(repo.join("feature.txt")).unwrap();
assert_eq!(feature_content, "feature content\n");
// Verify the watcher commit's file is still present.
assert!(
sk_dir.join("diverge_test.md").exists(),
"watcher-committed file should still be on master"
);
// Verify cleanup: no merge-queue branch, no merge workspace.
let branches = Command::new("git")
.args(["branch", "--list", "merge-queue/*"])
.current_dir(repo)
.output()
.unwrap();
let branch_list = String::from_utf8_lossy(&branches.stdout);
assert!(
branch_list.trim().is_empty(),
"merge-queue branch should be cleaned up, got: {branch_list}"
);
assert!(
!repo.join(".huskies/merge_workspace").exists(),
"merge workspace should be cleaned up"
);
}
#[tokio::test]
async fn squash_merge_empty_diff_fails() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Create a file on master.
fs::write(repo.join("code.txt"), "content\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add code"])
.current_dir(repo)
.output()
.unwrap();
// Create a feature branch with NO additional changes (empty diff).
Command::new("git")
.args(["checkout", "-b", "feature/story-empty_test"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
let result = run_squash_merge(repo, "feature/story-empty_test", "empty_test");
// Bug 226 / 675: a zero-commit branch must not be treated as success.
// The pre-flight check (bug 675) returns Err for zero commits ahead;
// the older code path returned Ok(SquashMergeResult { success: false }).
// Either form is a failure — just not success.
match result {
Ok(r) => assert!(
2026-05-13 16:26:09 +00:00
!matches!(r, super::MergeResult::Success { .. }),
"empty diff merge must fail, not silently succeed: {:?}",
r
),
Err(e) => assert!(
e.contains("no commits to merge") || e.contains("nothing to commit"),
"unexpected error: {e}"
),
}
// Cleanup should still happen (no workspace was created for the Err path).
assert!(
!repo.join(".huskies/merge_workspace").exists(),
"merge workspace should be cleaned up"
);
}
2026-04-28 00:28:57 +00:00
/// Bug 777: a second `run_squash_merge` call after a successful first one must
/// return `success: true` (idempotent) so the caller advances the story to
/// `5_done` rather than overwriting that state with `merge_failure`. The
/// pre-flight `ahead == 0` check still catches truly empty feature branches.
#[tokio::test]
async fn idempotent_retry_after_successful_merge_returns_success() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Master has an initial file.
fs::write(repo.join("base.txt"), "base\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "base"])
.current_dir(repo)
.output()
.unwrap();
// Feature branch adds a new file.
Command::new("git")
.args(["checkout", "-b", "feature/story-777_idem"])
.current_dir(repo)
.output()
.unwrap();
fs::write(repo.join("feat.txt"), "feature\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add feat"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["checkout", "master"])
.current_dir(repo)
.output()
.unwrap();
// First merge: should succeed and land feature's content on master.
let r1 = run_squash_merge(repo, "feature/story-777_idem", "777_idem")
.expect("first merge produces Ok");
// The merge may fail gates in test env (no script/test); only require that
// the squash applied SOMETHING (cargo gates env-dependent).
2026-05-13 16:26:09 +00:00
if matches!(r1, super::MergeResult::Success { .. }) {
2026-04-28 00:28:57 +00:00
// Second merge of the SAME branch: must report success (idempotent),
// not merge_failure. Feature branch's content is already on master so
// the squash produces "nothing to commit" — bug 777 makes this success.
let r2 = run_squash_merge(repo, "feature/story-777_idem", "777_idem")
.expect("second merge produces Ok");
assert!(
2026-05-13 16:26:09 +00:00
matches!(
r2,
super::MergeResult::Success {
conflicts_resolved: false,
..
}
),
"idempotent retry must return Success without conflicts: {r2:?}"
2026-04-28 00:28:57 +00:00
);
}
}