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 `.storkit/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<String>,
    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<String>,
    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<SquashMergeResult, String> {
    // 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(".storkit").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<String> = 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!("storkit: 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 .storkit/ 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(".storkit/work/"));
        if !has_code_changes {
            all_output.push_str(
                "=== Merge commit contains only .storkit/ 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 .storkit/ — 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,
        });
    }

    // ── Verify code landed on the correct branch ──────────────────
    // Guard against the cherry-pick silently landing on the wrong branch
    // (e.g. a merge-queue branch from a concurrent merge).  If the current
    // branch is not the base branch, or the HEAD commit has no code diff,
    // treat the merge as failed so the story stays in the merge stage.
    let current_branch = Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .current_dir(project_root)
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_default();

    let base_branch = crate::config::ProjectConfig::load(project_root)
        .ok()
        .and_then(|c| c.base_branch.clone())
        .unwrap_or_else(|| "master".to_string());

    if current_branch != base_branch {
        all_output.push_str(&format!(
            "=== VERIFICATION FAILED: expected branch '{base_branch}' but HEAD is on \
             '{current_branch}'. Cherry-pick landed on wrong branch. ===\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 landed on '{current_branch}' instead of '{base_branch}'"
            )),
            output: all_output,
            gates_passed: true,
        });
    }

    // Verify HEAD commit has actual code changes (not an empty cherry-pick).
    // Exclude .storkit/work/ (pipeline file moves) but keep .storkit/project.toml
    // and other config files which are legitimate deliverables.
    let diff_stat = Command::new("git")
        .args(["diff", "--stat", "HEAD~1..HEAD", "--", ".", ":(exclude).storkit/work"])
        .current_dir(project_root)
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_default();

    if diff_stat.is_empty() {
        all_output.push_str(
            "=== VERIFICATION FAILED: cherry-pick produced no code changes on master. ===\n",
        );
        cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
        return Ok(SquashMergeResult {
            success: false,
            had_conflicts,
            conflicts_resolved,
            conflict_details: Some(
                "Cherry-pick commit contains no code changes (empty diff)".to_string(),
            ),
            output: all_output,
            gates_passed: true,
        });
    }

    all_output.push_str(&format!(
        "=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n"
    ));

    // ── 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<String> {
    // 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(".storkit/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(".storkit/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", "storkit: 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(".storkit/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(".storkit/merge_workspace").exists(),
            "merge workspace should be cleaned up"
        );
    }

    /// Bug 226: Verifies that `run_squash_merge` fails when the feature branch
    /// only contains .storkit/ 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 .storkit/ file.
        Command::new("git")
            .args(["checkout", "-b", "feature/story-md_only_test"])
            .current_dir(repo)
            .output()
            .unwrap();
        let sk_dir = repo.join(".storkit/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 .storkit/ file, but should fail because
        // there are no code changes outside .storkit/.
        assert!(
            !result.success,
            "merge with only .storkit/ changes must fail: {}",
            result.output
        );

        // Cleanup should still happen.
        assert!(
            !repo.join(".storkit/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(".storkit/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(".storkit/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(".storkit/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 `.storkit/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 .storkit/project.toml with a component whose setup writes a
        // sentinel file so we can confirm the command ran.
        let sk_dir = repo.join(".storkit");
        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 .storkit/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
        );
    }
}
