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; /// Global lock ensuring only one squash-merge runs at a time. /// /// The merge pipeline uses a shared `.story_kit/merge_workspace` directory and /// temporary `merge-queue/{story_id}` branches. If two merges run concurrently, /// the second call's initial cleanup destroys the first call's branch mid-flight, /// 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, story_id: &str, ) -> Result { // Acquire the merge lock so concurrent calls don't clobber each other. let _lock = MERGE_LOCK .lock() .map_err(|e| format!("Merge lock poisoned: {e}"))?; let mut all_output = String::new(); let merge_branch = format!("merge-queue/{story_id}"); let merge_wt_path = project_root .join(".story_kit") .join("merge_workspace"); // Ensure we start clean: remove any leftover merge workspace. cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); // ── Create merge-queue branch at current HEAD ───────────────── all_output.push_str(&format!( "=== Creating merge-queue branch '{merge_branch}' ===\n" )); let create_branch = Command::new("git") .args(["branch", &merge_branch]) .current_dir(project_root) .output() .map_err(|e| format!("Failed to create merge-queue branch: {e}"))?; if !create_branch.status.success() { let stderr = String::from_utf8_lossy(&create_branch.stderr); all_output.push_str(&format!("Branch creation failed: {stderr}\n")); return Err(format!("Failed to create merge-queue branch: {stderr}")); } // ── Create temporary worktree ───────────────────────────────── all_output.push_str("=== Creating temporary merge worktree ===\n"); let wt_str = merge_wt_path.to_string_lossy().to_string(); let create_wt = Command::new("git") .args(["worktree", "add", &wt_str, &merge_branch]) .current_dir(project_root) .output() .map_err(|e| format!("Failed to create merge worktree: {e}"))?; if !create_wt.status.success() { let stderr = String::from_utf8_lossy(&create_wt.stderr); all_output.push_str(&format!("Worktree creation failed: {stderr}\n")); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Err(format!("Failed to create merge worktree: {stderr}")); } // ── Squash-merge in the temporary worktree ──────────────────── all_output.push_str(&format!("=== git merge --squash {branch} ===\n")); let merge = Command::new("git") .args(["merge", "--squash", branch]) .current_dir(&merge_wt_path) .output() .map_err(|e| format!("Failed to run git merge: {e}"))?; let merge_stdout = String::from_utf8_lossy(&merge.stdout).to_string(); let merge_stderr = String::from_utf8_lossy(&merge.stderr).to_string(); all_output.push_str(&merge_stdout); all_output.push_str(&merge_stderr); all_output.push('\n'); let mut had_conflicts = false; let mut conflicts_resolved = false; let mut conflict_details: Option = None; if !merge.status.success() { had_conflicts = true; all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n"); // Try to automatically resolve simple conflicts. match try_resolve_conflicts(&merge_wt_path) { Ok((resolved, resolution_log)) => { all_output.push_str(&resolution_log); if resolved { conflicts_resolved = true; all_output .push_str("=== All conflicts resolved automatically ===\n"); } else { // Could not resolve — abort, clean up, and report. let details = format!( "Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}" ); conflict_details = Some(details); all_output .push_str("=== Unresolvable conflicts, aborting merge ===\n"); cleanup_merge_workspace( project_root, &merge_wt_path, &merge_branch, ); return Ok(SquashMergeResult { success: false, had_conflicts: true, conflicts_resolved: false, conflict_details, output: all_output, gates_passed: false, }); } } Err(e) => { all_output.push_str(&format!("Auto-resolution error: {e}\n")); cleanup_merge_workspace( project_root, &merge_wt_path, &merge_branch, ); return Ok(SquashMergeResult { success: false, had_conflicts: true, conflicts_resolved: false, conflict_details: Some(format!( "Merge conflicts in branch '{branch}' (auto-resolution failed: {e}):\n{merge_stdout}{merge_stderr}" )), output: all_output, gates_passed: false, }); } } } // ── Commit in the temporary worktree ────────────────────────── all_output.push_str("=== git commit ===\n"); let commit_msg = format!("story-kit: merge {story_id}"); let commit = Command::new("git") .args(["commit", "-m", &commit_msg]) .current_dir(&merge_wt_path) .output() .map_err(|e| format!("Failed to run git commit: {e}"))?; let commit_stdout = String::from_utf8_lossy(&commit.stdout).to_string(); let commit_stderr = String::from_utf8_lossy(&commit.stderr).to_string(); all_output.push_str(&commit_stdout); all_output.push_str(&commit_stderr); all_output.push('\n'); if !commit.status.success() { // Bug 226: "nothing to commit" means the feature branch has no changes // beyond what's already on master. This must NOT be treated as success // — it means the code was never actually merged. if commit_stderr.contains("nothing to commit") || commit_stdout.contains("nothing to commit") { all_output.push_str( "=== Nothing to commit — feature branch has no changes beyond master ===\n", ); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts, conflicts_resolved, conflict_details: Some( "Squash-merge resulted in an empty diff — the feature branch has no \ code changes to merge into master." .to_string(), ), output: all_output, gates_passed: false, }); } cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts, conflicts_resolved, conflict_details, output: all_output, gates_passed: false, }); } // ── Bug 226: Verify the commit contains real code changes ───── // If the merge only brought in .story_kit/ files (pipeline file moves), // there are no actual code changes to land on master. Abort. { let diff_check = Command::new("git") .args(["diff", "--name-only", "HEAD~1..HEAD"]) .current_dir(&merge_wt_path) .output() .map_err(|e| format!("Failed to check merge diff: {e}"))?; let changed_files = String::from_utf8_lossy(&diff_check.stdout); let has_code_changes = changed_files .lines() .any(|f| !f.starts_with(".story_kit/")); if !has_code_changes { all_output.push_str( "=== Merge commit contains only .story_kit/ file moves, no code changes ===\n", ); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts, conflicts_resolved, conflict_details: Some( "Feature branch has no code changes outside .story_kit/ — only \ pipeline file moves were found." .to_string(), ), output: all_output, gates_passed: false, }); } } // ── Run component setup from project.toml (same as worktree creation) ────────── { let config = ProjectConfig::load(&merge_wt_path).unwrap_or_default(); if !config.component.is_empty() { all_output.push_str("=== component setup (merge worktree) ===\n"); } for component in &config.component { let cmd_dir = merge_wt_path.join(&component.path); for cmd in &component.setup { all_output.push_str(&format!("--- {}: {cmd} ---\n", component.name)); match Command::new("sh") .args(["-c", cmd]) .current_dir(&cmd_dir) .output() { Ok(out) => { all_output.push_str(&String::from_utf8_lossy(&out.stdout)); all_output.push_str(&String::from_utf8_lossy(&out.stderr)); all_output.push('\n'); if !out.status.success() { all_output.push_str(&format!( "=== setup warning: '{}' failed: {cmd} ===\n", component.name )); } } Err(e) => { all_output.push_str(&format!( "=== setup warning: failed to run '{cmd}': {e} ===\n" )); } } } } } // ── Quality gates in merge workspace (BEFORE fast-forward) ──── // Run gates in the merge worktree so that failures abort before master moves. all_output.push_str("=== Running quality gates before fast-forward ===\n"); match run_merge_quality_gates(&merge_wt_path) { Ok((true, gate_out)) => { all_output.push_str(&gate_out); all_output.push('\n'); all_output.push_str("=== Quality gates passed ===\n"); } Ok((false, gate_out)) => { all_output.push_str(&gate_out); all_output.push('\n'); all_output .push_str("=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n"); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts, conflicts_resolved, conflict_details, output: all_output, gates_passed: false, }); } Err(e) => { all_output.push_str(&format!("Gate check error: {e}\n")); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts, conflicts_resolved, conflict_details, output: all_output, gates_passed: false, }); } } // ── Cherry-pick the squash commit onto master ────────────────── // We cherry-pick instead of fast-forward so that concurrent filesystem // watcher commits on master (e.g. pipeline file moves) don't block the // merge. Cherry-pick applies the diff of the squash commit cleanly on // top of master's current HEAD. all_output.push_str(&format!( "=== Cherry-picking squash commit from {merge_branch} onto master ===\n" )); let cp = Command::new("git") .args(["cherry-pick", &merge_branch]) .current_dir(project_root) .output() .map_err(|e| format!("Failed to cherry-pick merge-queue commit: {e}"))?; let cp_stdout = String::from_utf8_lossy(&cp.stdout).to_string(); let cp_stderr = String::from_utf8_lossy(&cp.stderr).to_string(); all_output.push_str(&cp_stdout); all_output.push_str(&cp_stderr); all_output.push('\n'); if !cp.status.success() { // Abort the cherry-pick so master is left clean. let _ = Command::new("git") .args(["cherry-pick", "--abort"]) .current_dir(project_root) .output(); all_output.push_str("=== Cherry-pick failed — aborting, master unchanged ===\n"); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts, conflicts_resolved, conflict_details: Some(format!( "Cherry-pick of squash commit failed (conflict with master?):\n{cp_stderr}" )), output: all_output, gates_passed: true, }); } // ── Clean up ────────────────────────────────────────────────── cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); all_output.push_str("=== Merge-queue cleanup complete ===\n"); Ok(SquashMergeResult { success: true, had_conflicts, conflicts_resolved, conflict_details, output: all_output, gates_passed: true, }) } /// 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, merge_branch: &str, ) { let wt_str = merge_wt_path.to_string_lossy().to_string(); let _ = Command::new("git") .args(["worktree", "remove", "--force", &wt_str]) .current_dir(project_root) .output(); // If the directory still exists (e.g. it was a plain directory from a // previous failed run, not a registered git worktree), remove it so // the next `git worktree add` can succeed. if merge_wt_path.exists() { let _ = std::fs::remove_dir_all(merge_wt_path); } let _ = Command::new("git") .args(["branch", "-D", merge_branch]) .current_dir(project_root) .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; let script_test = project_root.join("script").join("test"); if script_test.exists() { // Delegate entirely to script/test — it is the single source of truth // for the full test suite (clippy, cargo tests, frontend builds, etc.). let (success, output) = run_project_tests(project_root)?; all_output.push_str(&output); if !success { all_passed = false; } return Ok((all_passed, all_output)); } // No script/test — fall back to cargo gates for Rust projects. let cargo_toml = project_root.join("Cargo.toml"); if cargo_toml.exists() { let clippy = Command::new("cargo") .args(["clippy", "--all-targets", "--all-features"]) .current_dir(project_root) .output() .map_err(|e| format!("Failed to run cargo clippy: {e}"))?; all_output.push_str("=== cargo clippy ===\n"); let clippy_out = format!( "{}{}", String::from_utf8_lossy(&clippy.stdout), String::from_utf8_lossy(&clippy.stderr) ); all_output.push_str(&clippy_out); all_output.push('\n'); if !clippy.status.success() { all_passed = false; } let (test_success, test_out) = run_project_tests(project_root)?; all_output.push_str(&test_out); if !test_success { all_passed = false; } } Ok((all_passed, all_output)) } #[cfg(test)] mod tests { 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(); } // ── 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; 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}" ); // The merge should have had conflicts. assert!(result.had_conflicts, "should detect conflicts"); // Conflicts should have been auto-resolved (both are simple additions). if result.conflicts_resolved { assert!(result.success, "auto-resolved merge should succeed"); assert!( master_content.contains("master addition"), "master side should be present" ); assert!( master_content.contains("feature addition"), "feature side should be present" ); } // 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(".story_kit/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!(result.success, "clean merge should succeed"); assert!(!result.had_conflicts, "clean merge should have no conflicts"); assert!(!result.conflicts_resolved, "no conflicts means nothing to resolve"); 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(); 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; 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(".story_kit/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", "story-kit: 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!( result.success, "squash merge should succeed despite diverged master: {}", result.output ); assert!( !result.had_conflicts, "no conflicts expected" ); // 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(".story_kit/merge_workspace").exists(), "merge workspace should be cleaned up" ); } /// 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; 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").unwrap(); // Bug 226: empty diff must NOT be treated as success. assert!( !result.success, "empty diff merge must fail, not silently succeed: {}", result.output ); // Cleanup should still happen. assert!( !repo.join(".story_kit/merge_workspace").exists(), "merge workspace should be cleaned up" ); } /// Bug 226: Verifies that `run_squash_merge` fails when the feature branch /// only contains .story_kit/ file moves with no real code changes. #[tokio::test] async fn squash_merge_md_only_changes_fails() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a feature branch that only moves a .story_kit/ file. Command::new("git") .args(["checkout", "-b", "feature/story-md_only_test"]) .current_dir(repo) .output() .unwrap(); let sk_dir = repo.join(".story_kit/work/2_current"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("md_only_test.md"), "---\nname: Test\n---\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "move story file"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-md_only_test", "md_only_test").unwrap(); // The squash merge will commit the .story_kit/ file, but should fail because // there are no code changes outside .story_kit/. assert!( !result.success, "merge with only .story_kit/ changes must fail: {}", result.output ); // Cleanup should still happen. assert!( !repo.join(".story_kit/merge_workspace").exists(), "merge workspace should be cleaned up" ); } // ── 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; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Initial file with a shared base. fs::write(repo.join("module.rs"), "// module\npub fn existing() {}\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial module"]) .current_dir(repo) .output() .unwrap(); // Feature branch: appends feature_fn to the file. Command::new("git") .args(["checkout", "-b", "feature/story-238_additive"]) .current_dir(repo) .output() .unwrap(); fs::write( repo.join("module.rs"), "// module\npub fn existing() {}\npub fn feature_fn() {}\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add feature_fn"]) .current_dir(repo) .output() .unwrap(); // Simulate another branch already merged into master: appends master_fn. Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); fs::write( repo.join("module.rs"), "// module\npub fn existing() {}\npub fn master_fn() {}\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add master_fn (another branch merged)"]) .current_dir(repo) .output() .unwrap(); // Squash-merge the feature branch — conflicts because both appended to the same location. let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap(); // Conflict must be detected and auto-resolved. assert!(result.had_conflicts, "additive conflict should be detected"); assert!( result.conflicts_resolved, "additive conflict must be auto-resolved; output:\n{}", result.output ); // Master must contain both additions without conflict markers. let content = fs::read_to_string(repo.join("module.rs")).unwrap(); assert!(!content.contains("<<<<<<<"), "master must not contain conflict markers"); assert!(!content.contains(">>>>>>>"), "master must not contain conflict markers"); assert!( content.contains("feature_fn"), "feature branch addition must be preserved on master" ); assert!( content.contains("master_fn"), "master branch addition must be preserved on master" ); assert!(content.contains("existing"), "original function must be preserved"); // Cleanup: no leftover merge-queue branch or workspace. let branches = Command::new("git") .args(["branch", "--list", "merge-queue/*"]) .current_dir(repo) .output() .unwrap(); assert!( String::from_utf8_lossy(&branches.stdout).trim().is_empty(), "merge-queue branch must be cleaned up" ); assert!( !repo.join(".story_kit/merge_workspace").exists(), "merge workspace must be cleaned up" ); } // ── 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; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Add a script/test that always fails (quality gate). This must be on // master before the feature branch forks so it doesn't cause its own conflict. let script_dir = repo.join("script"); fs::create_dir_all(&script_dir).unwrap(); fs::write(script_dir.join("test"), "#!/bin/sh\nexit 1\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( script_dir.join("test"), std::fs::Permissions::from_mode(0o755), ) .unwrap(); } fs::write(repo.join("code.txt"), "// base\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial with failing script/test"]) .current_dir(repo) .output() .unwrap(); // Feature branch: appends feature content (creates future conflict point). Command::new("git") .args(["checkout", "-b", "feature/story-238_gates_fail"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("code.txt"), "// base\nfeature_addition\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature addition"]) .current_dir(repo) .output() .unwrap(); // Master: append different content at same location (creates conflict). Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("code.txt"), "// base\nmaster_addition\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "master addition"]) .current_dir(repo) .output() .unwrap(); // Squash-merge: conflict detected → auto-resolved → quality gates run → fail. let result = run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); assert!(result.had_conflicts, "conflict must be detected"); assert!(result.conflicts_resolved, "additive conflict must be auto-resolved"); assert!(!result.gates_passed, "quality gates must fail (script/test exits 1)"); assert!(!result.success, "merge must be reported as failed when gates fail"); assert!( !result.output.is_empty(), "output must contain gate failure details" ); // Master must NOT have been updated (cherry-pick was blocked by gate failure). let content = fs::read_to_string(repo.join("code.txt")).unwrap(); assert!(!content.contains("<<<<<<<"), "master must not contain conflict markers"); // master_addition was the last commit on master; feature_addition must NOT be there. assert!( !content.contains("feature_addition"), "feature code must not land on master when gates fail" ); // Cleanup must still happen. assert!( !repo.join(".story_kit/merge_workspace").exists(), "merge workspace must be cleaned up even on gate failure" ); } /// 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; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a feature branch with a file. Command::new("git") .args(["checkout", "-b", "feature/story-stale_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("stale.txt"), "content\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature: stale test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); // Simulate a stale merge workspace left from a previous failed merge. let stale_ws = repo.join(".story_kit/merge_workspace"); fs::create_dir_all(&stale_ws).unwrap(); fs::write(stale_ws.join("leftover.txt"), "stale").unwrap(); // Run the merge — it should clean up the stale workspace first. let result = run_squash_merge(repo, "feature/story-stale_test", "stale_test").unwrap(); assert!( result.success, "merge should succeed after cleaning up stale workspace: {}", result.output ); assert!( !stale_ws.exists(), "stale merge workspace should be cleaned up" ); } // ── story 216: merge worktree uses project.toml component setup ─────────── /// When the project has `[[component]]` entries in `.story_kit/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; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Add a .story_kit/project.toml with a component whose setup writes a // sentinel file so we can confirm the command ran. let sk_dir = repo.join(".story_kit"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[component]]\nname = \"sentinel\"\npath = \".\"\nsetup = [\"touch setup_ran.txt\"]\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add project.toml with component setup"]) .current_dir(repo) .output() .unwrap(); // Create feature branch with a change. Command::new("git") .args(["checkout", "-b", "feature/story-216_setup_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("feature.txt"), "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. Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-216_setup_test", "216_setup_test").unwrap(); // The output must mention component setup, proving the new code path ran. assert!( result.output.contains("component setup"), "merge output must mention component setup when project.toml has components, got:\n{}", result.output ); // The sentinel command must appear in the output. assert!( result.output.contains("sentinel"), "merge output must name the component, got:\n{}", result.output ); } /// 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; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // No .story_kit/project.toml — no component setup. fs::write(repo.join("file.txt"), "initial").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial commit"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "-b", "feature/story-216_no_components"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("change.txt"), "change").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-216_no_components", "216_no_components") .unwrap(); // No pnpm or frontend references should appear in the output. assert!( !result.output.contains("pnpm"), "output must not mention pnpm, got:\n{}", result.output ); assert!( !result.output.contains("frontend/"), "output must not mention frontend/, got:\n{}", result.output ); } }