2026-04-29 09:25:05 +00:00
|
|
|
//! Tests for squash-merge orchestration — basic cases.
|
2026-04-27 17:51:51 +00:00
|
|
|
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:?}"
|
|
|
|
|
);
|
2026-04-27 17:51:51 +00:00
|
|
|
|
|
|
|
|
// 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:?}"
|
2026-04-27 17:51:51 +00:00
|
|
|
);
|
|
|
|
|
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:?}"
|
|
|
|
|
);
|
2026-04-27 17:51:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
2026-04-27 17:51:51 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-04-27 17:51:51 +00:00
|
|
|
),
|
|
|
|
|
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
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|