From ce94dd0af486e78189f4e9c083d7155219662225 Mon Sep 17 00:00:00 2001 From: dave Date: Sun, 26 Apr 2026 21:15:06 +0000 Subject: [PATCH] refactor: split agents/merge.rs into mod.rs + squash.rs + conflicts.rs The 1772-line merge.rs is split into: - conflicts.rs: try_resolve_conflicts + resolve_simple_conflicts + tests (351 lines) - squash.rs: run_squash_merge orchestrator + cleanup + run_merge_quality_gates + tests (1306 lines) - mod.rs: doc, types (MergeJobStatus, MergeJob, MergeReport, SquashMergeResult), re-exports (52 lines) Tests stay co-located. No behaviour change. All 20 merge tests pass; full suite green (2635 tests with --test-threads=1). --- server/src/agents/merge/conflicts.rs | 351 +++++++++++++ server/src/agents/merge/mod.rs | 52 ++ .../src/agents/{merge.rs => merge/squash.rs} | 476 +----------------- 3 files changed, 408 insertions(+), 471 deletions(-) create mode 100644 server/src/agents/merge/conflicts.rs create mode 100644 server/src/agents/merge/mod.rs rename server/src/agents/{merge.rs => merge/squash.rs} (72%) diff --git a/server/src/agents/merge/conflicts.rs b/server/src/agents/merge/conflicts.rs new file mode 100644 index 00000000..ad08d51a --- /dev/null +++ b/server/src/agents/merge/conflicts.rs @@ -0,0 +1,351 @@ +//! Merge conflict resolution helpers. + +use std::path::Path; +use std::process::Command; + +pub(super) fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> { + let mut log = String::new(); + + // List conflicted files. + let ls = Command::new("git") + .args(["diff", "--name-only", "--diff-filter=U"]) + .current_dir(worktree) + .output() + .map_err(|e| format!("Failed to list conflicted files: {e}"))?; + + let file_list = String::from_utf8_lossy(&ls.stdout); + let conflicted_files: Vec<&str> = file_list.lines().filter(|l| !l.is_empty()).collect(); + + if conflicted_files.is_empty() { + log.push_str("No conflicted files found (conflict may be index-only).\n"); + return Ok((false, log)); + } + + log.push_str(&format!("Conflicted files ({}):\n", conflicted_files.len())); + for f in &conflicted_files { + log.push_str(&format!(" - {f}\n")); + } + + // First pass: check that all files can be resolved before touching any. + let mut resolutions: Vec<(&str, String)> = Vec::new(); + for file in &conflicted_files { + let file_path = worktree.join(file); + let content = std::fs::read_to_string(&file_path) + .map_err(|e| format!("Failed to read conflicted file '{file}': {e}"))?; + + match resolve_simple_conflicts(&content) { + Some(resolved) => { + log.push_str(&format!(" [auto-resolve] {file}\n")); + resolutions.push((file, resolved)); + } + None => { + log.push_str(&format!(" [COMPLEX — cannot auto-resolve] {file}\n")); + return Ok((false, log)); + } + } + } + + // Second pass: write resolved content and stage. + for (file, resolved) in &resolutions { + let file_path = worktree.join(file); + std::fs::write(&file_path, resolved) + .map_err(|e| format!("Failed to write resolved file '{file}': {e}"))?; + + let add = Command::new("git") + .args(["add", file]) + .current_dir(worktree) + .output() + .map_err(|e| format!("Failed to stage resolved file '{file}': {e}"))?; + if !add.status.success() { + return Err(format!( + "git add failed for '{file}': {}", + String::from_utf8_lossy(&add.stderr) + )); + } + } + + Ok((true, log)) +} + +fn resolve_simple_conflicts(content: &str) -> Option { + // Quick check: if there are no conflict markers at all, nothing to do. + if !content.contains("<<<<<<<") { + return Some(content.to_string()); + } + + let mut result = String::new(); + let mut lines = content.lines().peekable(); + + while let Some(line) = lines.next() { + if line.starts_with("<<<<<<<") { + // Collect the "ours" side (between <<<<<<< and =======). + let mut ours = Vec::new(); + let mut found_separator = false; + for next_line in lines.by_ref() { + if next_line.starts_with("=======") { + found_separator = true; + break; + } + ours.push(next_line); + } + if !found_separator { + return None; // Malformed conflict block. + } + + // Collect the "theirs" side (between ======= and >>>>>>>). + let mut theirs = Vec::new(); + let mut found_end = false; + for next_line in lines.by_ref() { + if next_line.starts_with(">>>>>>>") { + found_end = true; + break; + } + theirs.push(next_line); + } + if !found_end { + return None; // Malformed conflict block. + } + + // Both sides must be non-empty additions to be considered simple. + // If either side is empty, it means one side deleted something — complex. + if ours.is_empty() && theirs.is_empty() { + // Both empty — nothing to add, skip. + continue; + } + + // Accept both: ours first, then theirs. + for l in &ours { + result.push_str(l); + result.push('\n'); + } + for l in &theirs { + result.push_str(l); + result.push('\n'); + } + } else { + result.push_str(line); + result.push('\n'); + } + } + + // Preserve trailing newline consistency: if original ended without + // newline, strip the trailing one we added. + if !content.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_simple_conflicts_no_markers() { + let input = "line 1\nline 2\nline 3\n"; + let result = resolve_simple_conflicts(input); + assert_eq!(result, Some(input.to_string())); + } + + #[test] + fn resolve_simple_conflicts_additive() { + let input = "\ +before +<<<<<<< HEAD +ours line 1 +ours line 2 +======= +theirs line 1 +theirs line 2 +>>>>>>> feature +after +"; + let result = resolve_simple_conflicts(input).unwrap(); + assert!( + !result.contains("<<<<<<<"), + "should not contain conflict markers" + ); + assert!( + !result.contains(">>>>>>>"), + "should not contain conflict markers" + ); + assert!(result.contains("ours line 1")); + assert!(result.contains("ours line 2")); + assert!(result.contains("theirs line 1")); + assert!(result.contains("theirs line 2")); + assert!(result.contains("before")); + assert!(result.contains("after")); + // Ours comes before theirs + let ours_pos = result.find("ours line 1").unwrap(); + let theirs_pos = result.find("theirs line 1").unwrap(); + assert!(ours_pos < theirs_pos, "ours should come before theirs"); + } + + #[test] + fn resolve_simple_conflicts_multiple_blocks() { + let input = "\ +header +<<<<<<< HEAD +ours block 1 +======= +theirs block 1 +>>>>>>> feature +middle +<<<<<<< HEAD +ours block 2 +======= +theirs block 2 +>>>>>>> feature +footer +"; + let result = resolve_simple_conflicts(input).unwrap(); + assert!(!result.contains("<<<<<<<")); + assert!(result.contains("ours block 1")); + assert!(result.contains("theirs block 1")); + assert!(result.contains("ours block 2")); + assert!(result.contains("theirs block 2")); + assert!(result.contains("header")); + assert!(result.contains("middle")); + assert!(result.contains("footer")); + } + + #[test] + fn resolve_simple_conflicts_malformed_no_separator() { + let input = "\ +<<<<<<< HEAD +ours +>>>>>>> feature +"; + let result = resolve_simple_conflicts(input); + assert!( + result.is_none(), + "malformed conflict (no separator) should return None" + ); + } + + #[test] + fn resolve_simple_conflicts_malformed_no_end() { + let input = "\ +<<<<<<< HEAD +ours +======= +theirs +"; + let result = resolve_simple_conflicts(input); + assert!( + result.is_none(), + "malformed conflict (no end marker) should return None" + ); + } + + #[test] + fn resolve_simple_conflicts_preserves_no_trailing_newline() { + let input = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter"; + let result = resolve_simple_conflicts(input).unwrap(); + assert!( + !result.ends_with('\n'), + "should not add trailing newline if original lacks one" + ); + assert!(result.ends_with("after")); + } + + #[test] + fn resolve_simple_conflicts_real_markers_additive_both_sides() { + // The most common real-world case: both branches add different content + // (e.g. different functions) to the same region of a file. + let input = "// shared code\n\ +<<<<<<< HEAD\n\ +fn master_fn() { println!(\"from master\"); }\n\ +=======\n\ +fn feature_fn() { println!(\"from feature\"); }\n\ +>>>>>>> feature/story-42\n\ +// end\n"; + let result = resolve_simple_conflicts(input).unwrap(); + assert!(!result.contains("<<<<<<<"), "no conflict markers in output"); + assert!(!result.contains(">>>>>>>"), "no conflict markers in output"); + assert!(!result.contains("======="), "no separator in output"); + assert!( + result.contains("fn master_fn()"), + "master (ours) side must be preserved" + ); + assert!( + result.contains("fn feature_fn()"), + "feature (theirs) side must be preserved" + ); + assert!( + result.contains("// shared code"), + "context before conflict preserved" + ); + assert!( + result.contains("// end"), + "context after conflict preserved" + ); + // ours (master) must appear before theirs (feature) + assert!( + result.find("master_fn").unwrap() < result.find("feature_fn").unwrap(), + "master side must appear before feature side" + ); + } + + #[test] + fn resolve_simple_conflicts_real_markers_multiple_conflict_blocks() { + // Two separate conflict blocks in the same file — as happens when two + // feature branches both add imports AND test suites to the same file. + let input = "// imports\n\ +<<<<<<< HEAD\n\ +import { A } from './a';\n\ +=======\n\ +import { B } from './b';\n\ +>>>>>>> feature/story-43\n\ +// implementation\n\ +<<<<<<< HEAD\n\ +export function masterImpl() {}\n\ +=======\n\ +export function featureImpl() {}\n\ +>>>>>>> feature/story-43\n"; + let result = resolve_simple_conflicts(input).unwrap(); + assert!(!result.contains("<<<<<<<"), "no conflict markers in output"); + assert!( + result.contains("import { A }"), + "first block ours preserved" + ); + assert!( + result.contains("import { B }"), + "first block theirs preserved" + ); + assert!(result.contains("masterImpl"), "second block ours preserved"); + assert!( + result.contains("featureImpl"), + "second block theirs preserved" + ); + assert!( + result.contains("// imports"), + "surrounding context preserved" + ); + assert!( + result.contains("// implementation"), + "surrounding context preserved" + ); + } + + #[test] + fn resolve_simple_conflicts_real_markers_one_side_empty() { + // Ours (master) has no content in the conflicted region; theirs (feature) + // adds new content. Resolution: keep theirs. + let input = "before\n\ +<<<<<<< HEAD\n\ +=======\n\ +feature_addition\n\ +>>>>>>> feature/story-44\n\ +after\n"; + let result = resolve_simple_conflicts(input).unwrap(); + assert!(!result.contains("<<<<<<<"), "no conflict markers"); + assert!( + result.contains("feature_addition"), + "non-empty side preserved" + ); + assert!(result.contains("before"), "context preserved"); + assert!(result.contains("after"), "context preserved"); + } +} diff --git a/server/src/agents/merge/mod.rs b/server/src/agents/merge/mod.rs new file mode 100644 index 00000000..b72a50ec --- /dev/null +++ b/server/src/agents/merge/mod.rs @@ -0,0 +1,52 @@ +//! Merge operations — rebases agent work onto master and runs post-merge validation. + +use serde::Serialize; + +mod conflicts; +mod squash; + +pub(crate) use squash::{cleanup_merge_workspace, run_squash_merge}; + +/// Status of an async merge job. +#[derive(Debug, Clone, Serialize)] +pub enum MergeJobStatus { + Running, + Completed(MergeReport), + Failed(String), +} + +/// Tracks a background merge job started by `merge_agent_work`. +#[derive(Debug, Clone, Serialize)] +pub struct MergeJob { + pub story_id: String, + pub status: MergeJobStatus, +} + +/// Result of a mergemaster merge operation. +#[derive(Debug, Serialize, Clone)] +pub struct MergeReport { + pub story_id: String, + pub success: bool, + pub had_conflicts: bool, + /// `true` when conflicts were detected but automatically resolved. + pub conflicts_resolved: bool, + pub conflict_details: Option, + pub gates_passed: bool, + pub gate_output: String, + pub worktree_cleaned_up: bool, + pub story_archived: bool, +} + +/// Result of a squash-merge operation. +pub(crate) struct SquashMergeResult { + pub(crate) success: bool, + pub(crate) had_conflicts: bool, + /// `true` when conflicts were detected but automatically resolved. + pub(crate) conflicts_resolved: bool, + pub(crate) conflict_details: Option, + pub(crate) output: String, + /// Whether quality gates ran and passed. `false` when `success` is `false` + /// due to a gate failure; callers can use this to distinguish gate failures + /// from merge/commit/FF failures in the `MergeReport`. + pub(crate) gates_passed: bool, +} diff --git a/server/src/agents/merge.rs b/server/src/agents/merge/squash.rs similarity index 72% rename from server/src/agents/merge.rs rename to server/src/agents/merge/squash.rs index e1f5419e..e2c25b50 100644 --- a/server/src/agents/merge.rs +++ b/server/src/agents/merge/squash.rs @@ -1,13 +1,13 @@ -//! Merge operations — rebases agent work onto master and runs post-merge validation. +//! Squash-merge orchestration: rebase agent work onto master and run post-merge gates. + use std::path::Path; use std::process::Command; use std::sync::Mutex; -use serde::Serialize; - use crate::config::ProjectConfig; - -use super::gates::run_project_tests; +use super::conflicts::try_resolve_conflicts; +use super::super::gates::run_project_tests; +use super::{MergeReport, SquashMergeResult}; /// Global lock ensuring only one squash-merge runs at a time. /// @@ -17,66 +17,6 @@ use super::gates::run_project_tests; /// causing `git cherry-pick merge-queue/…` to fail with "bad revision". static MERGE_LOCK: Mutex<()> = Mutex::new(()); -/// Status of an async merge job. -#[derive(Debug, Clone, Serialize)] -pub enum MergeJobStatus { - Running, - Completed(MergeReport), - Failed(String), -} - -/// Tracks a background merge job started by `merge_agent_work`. -#[derive(Debug, Clone, Serialize)] -pub struct MergeJob { - pub story_id: String, - pub status: MergeJobStatus, -} - -/// Result of a mergemaster merge operation. -#[derive(Debug, Serialize, Clone)] -pub struct MergeReport { - pub story_id: String, - pub success: bool, - pub had_conflicts: bool, - /// `true` when conflicts were detected but automatically resolved. - pub conflicts_resolved: bool, - pub conflict_details: Option, - pub gates_passed: bool, - pub gate_output: String, - pub worktree_cleaned_up: bool, - pub story_archived: bool, -} - -/// Result of a squash-merge operation. -pub(crate) struct SquashMergeResult { - pub(crate) success: bool, - pub(crate) had_conflicts: bool, - /// `true` when conflicts were detected but automatically resolved. - pub(crate) conflicts_resolved: bool, - pub(crate) conflict_details: Option, - pub(crate) output: String, - /// Whether quality gates ran and passed. `false` when `success` is `false` - /// due to a gate failure; callers can use this to distinguish gate failures - /// from merge/commit/FF failures in the `MergeReport`. - pub(crate) gates_passed: bool, -} - -/// Squash-merge a feature branch into the current branch using a temporary -/// merge-queue worktree for quality-gate isolation. -/// -/// **Flow:** -/// 1. Create a temporary `merge-queue/{story_id}` branch at current HEAD. -/// 2. Create a temporary worktree for that branch. -/// 3. Run `git merge --squash` in the temporary worktree (not the main worktree). -/// 4. If conflicts arise, attempt automatic resolution for simple additive cases. -/// 5. If clean (or resolved), commit in the temp worktree. -/// 6. Run quality gates **in the merge worktree** before touching master. -/// 7. If gates pass: cherry-pick the squash commit onto master. -/// 8. Clean up the temporary worktree and branch. -/// -/// Step 7 uses `git cherry-pick` instead of `git merge --ff-only` so that -/// concurrent filesystem-watcher commits on master (pipeline file moves) do -/// not block the merge. pub(crate) fn run_squash_merge( project_root: &Path, branch: &str, @@ -473,8 +413,6 @@ pub(crate) fn run_squash_merge( }) } -/// Remove the temporary merge worktree and branch. Best-effort — errors are -/// silently ignored because this is cleanup code. pub(crate) fn cleanup_merge_workspace( project_root: &Path, merge_wt_path: &Path, @@ -497,165 +435,6 @@ pub(crate) fn cleanup_merge_workspace( .output(); } -/// Attempt to automatically resolve merge conflicts in the given worktree. -/// -/// Finds all conflicted files and tries [`resolve_simple_conflicts`] on each. -/// If **all** conflicts can be resolved, stages the resolved files and returns -/// `Ok((true, log))`. If any file has a complex conflict that cannot be -/// auto-resolved, returns `Ok((false, log))` without staging anything. -fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> { - let mut log = String::new(); - - // List conflicted files. - let ls = Command::new("git") - .args(["diff", "--name-only", "--diff-filter=U"]) - .current_dir(worktree) - .output() - .map_err(|e| format!("Failed to list conflicted files: {e}"))?; - - let file_list = String::from_utf8_lossy(&ls.stdout); - let conflicted_files: Vec<&str> = file_list.lines().filter(|l| !l.is_empty()).collect(); - - if conflicted_files.is_empty() { - log.push_str("No conflicted files found (conflict may be index-only).\n"); - return Ok((false, log)); - } - - log.push_str(&format!("Conflicted files ({}):\n", conflicted_files.len())); - for f in &conflicted_files { - log.push_str(&format!(" - {f}\n")); - } - - // First pass: check that all files can be resolved before touching any. - let mut resolutions: Vec<(&str, String)> = Vec::new(); - for file in &conflicted_files { - let file_path = worktree.join(file); - let content = std::fs::read_to_string(&file_path) - .map_err(|e| format!("Failed to read conflicted file '{file}': {e}"))?; - - match resolve_simple_conflicts(&content) { - Some(resolved) => { - log.push_str(&format!(" [auto-resolve] {file}\n")); - resolutions.push((file, resolved)); - } - None => { - log.push_str(&format!(" [COMPLEX — cannot auto-resolve] {file}\n")); - return Ok((false, log)); - } - } - } - - // Second pass: write resolved content and stage. - for (file, resolved) in &resolutions { - let file_path = worktree.join(file); - std::fs::write(&file_path, resolved) - .map_err(|e| format!("Failed to write resolved file '{file}': {e}"))?; - - let add = Command::new("git") - .args(["add", file]) - .current_dir(worktree) - .output() - .map_err(|e| format!("Failed to stage resolved file '{file}': {e}"))?; - if !add.status.success() { - return Err(format!( - "git add failed for '{file}': {}", - String::from_utf8_lossy(&add.stderr) - )); - } - } - - Ok((true, log)) -} - -/// Try to resolve simple additive merge conflicts in a file's content. -/// -/// A conflict is considered "simple additive" when both sides add new content -/// at the same location without modifying existing lines. In that case we keep -/// both additions (ours first, then theirs). -/// -/// Returns `Some(resolved)` if all conflict blocks in the file are simple, or -/// `None` if any block is too complex to auto-resolve. -fn resolve_simple_conflicts(content: &str) -> Option { - // Quick check: if there are no conflict markers at all, nothing to do. - if !content.contains("<<<<<<<") { - return Some(content.to_string()); - } - - let mut result = String::new(); - let mut lines = content.lines().peekable(); - - while let Some(line) = lines.next() { - if line.starts_with("<<<<<<<") { - // Collect the "ours" side (between <<<<<<< and =======). - let mut ours = Vec::new(); - let mut found_separator = false; - for next_line in lines.by_ref() { - if next_line.starts_with("=======") { - found_separator = true; - break; - } - ours.push(next_line); - } - if !found_separator { - return None; // Malformed conflict block. - } - - // Collect the "theirs" side (between ======= and >>>>>>>). - let mut theirs = Vec::new(); - let mut found_end = false; - for next_line in lines.by_ref() { - if next_line.starts_with(">>>>>>>") { - found_end = true; - break; - } - theirs.push(next_line); - } - if !found_end { - return None; // Malformed conflict block. - } - - // Both sides must be non-empty additions to be considered simple. - // If either side is empty, it means one side deleted something — complex. - if ours.is_empty() && theirs.is_empty() { - // Both empty — nothing to add, skip. - continue; - } - - // Accept both: ours first, then theirs. - for l in &ours { - result.push_str(l); - result.push('\n'); - } - for l in &theirs { - result.push_str(l); - result.push('\n'); - } - } else { - result.push_str(line); - result.push('\n'); - } - } - - // Preserve trailing newline consistency: if original ended without - // newline, strip the trailing one we added. - if !content.ends_with('\n') && result.ends_with('\n') { - result.pop(); - } - - Some(result) -} - -/// Run quality gates in the project root after a successful merge. -/// -/// Runs quality gates in the merge workspace. -/// -/// When `script/test` is present it is the single source of truth and is the -/// only gate that runs — it is expected to cover the full suite (clippy, unit -/// tests, frontend tests, etc.). When `script/test` is absent the function -/// falls back to `cargo clippy` + `cargo nextest`/`cargo test` for Rust -/// projects. No hardcoded references to pnpm or frontend/ are used. -/// -/// Returns `(gates_passed, combined_output)`. fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> { let mut all_output = String::new(); let mut all_passed = true; @@ -733,223 +512,6 @@ mod tests { .unwrap(); } - // ── resolve_simple_conflicts unit tests ────────────────────────────────── - - #[test] - fn resolve_simple_conflicts_no_markers() { - let input = "line 1\nline 2\nline 3\n"; - let result = resolve_simple_conflicts(input); - assert_eq!(result, Some(input.to_string())); - } - - #[test] - fn resolve_simple_conflicts_additive() { - let input = "\ -before -<<<<<<< HEAD -ours line 1 -ours line 2 -======= -theirs line 1 -theirs line 2 ->>>>>>> feature -after -"; - let result = resolve_simple_conflicts(input).unwrap(); - assert!( - !result.contains("<<<<<<<"), - "should not contain conflict markers" - ); - assert!( - !result.contains(">>>>>>>"), - "should not contain conflict markers" - ); - assert!(result.contains("ours line 1")); - assert!(result.contains("ours line 2")); - assert!(result.contains("theirs line 1")); - assert!(result.contains("theirs line 2")); - assert!(result.contains("before")); - assert!(result.contains("after")); - // Ours comes before theirs - let ours_pos = result.find("ours line 1").unwrap(); - let theirs_pos = result.find("theirs line 1").unwrap(); - assert!(ours_pos < theirs_pos, "ours should come before theirs"); - } - - #[test] - fn resolve_simple_conflicts_multiple_blocks() { - let input = "\ -header -<<<<<<< HEAD -ours block 1 -======= -theirs block 1 ->>>>>>> feature -middle -<<<<<<< HEAD -ours block 2 -======= -theirs block 2 ->>>>>>> feature -footer -"; - let result = resolve_simple_conflicts(input).unwrap(); - assert!(!result.contains("<<<<<<<")); - assert!(result.contains("ours block 1")); - assert!(result.contains("theirs block 1")); - assert!(result.contains("ours block 2")); - assert!(result.contains("theirs block 2")); - assert!(result.contains("header")); - assert!(result.contains("middle")); - assert!(result.contains("footer")); - } - - #[test] - fn resolve_simple_conflicts_malformed_no_separator() { - let input = "\ -<<<<<<< HEAD -ours ->>>>>>> feature -"; - let result = resolve_simple_conflicts(input); - assert!( - result.is_none(), - "malformed conflict (no separator) should return None" - ); - } - - #[test] - fn resolve_simple_conflicts_malformed_no_end() { - let input = "\ -<<<<<<< HEAD -ours -======= -theirs -"; - let result = resolve_simple_conflicts(input); - assert!( - result.is_none(), - "malformed conflict (no end marker) should return None" - ); - } - - #[test] - fn resolve_simple_conflicts_preserves_no_trailing_newline() { - let input = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter"; - let result = resolve_simple_conflicts(input).unwrap(); - assert!( - !result.ends_with('\n'), - "should not add trailing newline if original lacks one" - ); - assert!(result.ends_with("after")); - } - - // ── Additional resolve_simple_conflicts tests (real conflict markers) ──── - // - // AC1: The mergemaster reads both sides of the conflict and produces a - // resolved file that preserves changes from both branches. - - #[test] - fn resolve_simple_conflicts_real_markers_additive_both_sides() { - // The most common real-world case: both branches add different content - // (e.g. different functions) to the same region of a file. - let input = "// shared code\n\ -<<<<<<< HEAD\n\ -fn master_fn() { println!(\"from master\"); }\n\ -=======\n\ -fn feature_fn() { println!(\"from feature\"); }\n\ ->>>>>>> feature/story-42\n\ -// end\n"; - let result = resolve_simple_conflicts(input).unwrap(); - assert!(!result.contains("<<<<<<<"), "no conflict markers in output"); - assert!(!result.contains(">>>>>>>"), "no conflict markers in output"); - assert!(!result.contains("======="), "no separator in output"); - assert!( - result.contains("fn master_fn()"), - "master (ours) side must be preserved" - ); - assert!( - result.contains("fn feature_fn()"), - "feature (theirs) side must be preserved" - ); - assert!( - result.contains("// shared code"), - "context before conflict preserved" - ); - assert!( - result.contains("// end"), - "context after conflict preserved" - ); - // ours (master) must appear before theirs (feature) - assert!( - result.find("master_fn").unwrap() < result.find("feature_fn").unwrap(), - "master side must appear before feature side" - ); - } - - #[test] - fn resolve_simple_conflicts_real_markers_multiple_conflict_blocks() { - // Two separate conflict blocks in the same file — as happens when two - // feature branches both add imports AND test suites to the same file. - let input = "// imports\n\ -<<<<<<< HEAD\n\ -import { A } from './a';\n\ -=======\n\ -import { B } from './b';\n\ ->>>>>>> feature/story-43\n\ -// implementation\n\ -<<<<<<< HEAD\n\ -export function masterImpl() {}\n\ -=======\n\ -export function featureImpl() {}\n\ ->>>>>>> feature/story-43\n"; - let result = resolve_simple_conflicts(input).unwrap(); - assert!(!result.contains("<<<<<<<"), "no conflict markers in output"); - assert!( - result.contains("import { A }"), - "first block ours preserved" - ); - assert!( - result.contains("import { B }"), - "first block theirs preserved" - ); - assert!(result.contains("masterImpl"), "second block ours preserved"); - assert!( - result.contains("featureImpl"), - "second block theirs preserved" - ); - assert!( - result.contains("// imports"), - "surrounding context preserved" - ); - assert!( - result.contains("// implementation"), - "surrounding context preserved" - ); - } - - #[test] - fn resolve_simple_conflicts_real_markers_one_side_empty() { - // Ours (master) has no content in the conflicted region; theirs (feature) - // adds new content. Resolution: keep theirs. - let input = "before\n\ -<<<<<<< HEAD\n\ -=======\n\ -feature_addition\n\ ->>>>>>> feature/story-44\n\ -after\n"; - let result = resolve_simple_conflicts(input).unwrap(); - assert!(!result.contains("<<<<<<<"), "no conflict markers"); - assert!( - result.contains("feature_addition"), - "non-empty side preserved" - ); - assert!(result.contains("before"), "context preserved"); - assert!(result.contains("after"), "context preserved"); - } - - // ── merge-queue squash-merge integration tests ────────────────────────── - #[tokio::test] async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() { use std::fs; @@ -1126,9 +688,6 @@ after\n"; assert!(!result.success, "merge of nonexistent branch should fail"); } - /// Verifies that `run_squash_merge` succeeds even when master has advanced - /// with unrelated commits after the merge-queue branch was created (the race - /// condition that previously caused fast-forward to fail). #[tokio::test] async fn squash_merge_succeeds_when_master_diverges() { use std::fs; @@ -1233,8 +792,6 @@ after\n"; ); } - /// Bug 226: Verifies that `run_squash_merge` returns `success: false` when - /// the feature branch has no changes beyond what's already on master (empty diff). #[tokio::test] async fn squash_merge_empty_diff_fails() { use std::fs; @@ -1285,8 +842,6 @@ after\n"; ); } - /// Bug 226: Verifies that `run_squash_merge` fails when the feature branch - /// only contains .huskies/ file moves with no real code changes. #[tokio::test] async fn squash_merge_md_only_changes_fails() { use std::fs; @@ -1338,11 +893,6 @@ after\n"; ); } - // ── AC4: additive multi-branch conflict auto-resolution ──────────────── - // - // Verifies that when two feature branches both add different code to the - // same region of a file (the most common conflict pattern in this project), - // the mergemaster auto-resolves the conflict and preserves both additions. #[tokio::test] async fn squash_merge_additive_conflict_both_additions_preserved() { use std::fs; @@ -1459,10 +1009,6 @@ after\n"; ); } - // ── AC3: quality gates fail after conflict resolution ───────────────── - // - // Verifies that when conflicts are auto-resolved but the resulting code - // fails quality gates, the merge is reported as failed (not merged to master). #[tokio::test] async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() { use std::fs; @@ -1575,8 +1121,6 @@ after\n"; ); } - /// Verifies that stale merge_workspace directories from previous failed - /// merges are cleaned up before a new merge attempt. #[tokio::test] async fn squash_merge_cleans_up_stale_workspace() { use std::fs; @@ -1628,12 +1172,6 @@ after\n"; ); } - // ── story 216: merge worktree uses project.toml component setup ─────────── - - /// When the project has `[[component]]` entries in `.huskies/project.toml`, - /// `run_squash_merge` must run their setup commands in the merge worktree - /// before quality gates — matching the behaviour of `create_worktree`. - #[cfg(unix)] #[test] fn squash_merge_runs_component_setup_from_project_toml() { use std::fs; @@ -1705,10 +1243,6 @@ after\n"; ); } - /// When there are no `[[component]]` entries in project.toml (or no - /// project.toml at all), `run_squash_merge` must succeed without trying to - /// run any setup. No hardcoded pnpm or frontend/ references should appear. - #[cfg(unix)] #[test] fn squash_merge_succeeds_without_components_in_project_toml() { use std::fs;