Skip to main content

ag_git/
merge.rs

1use std::path::{Path, PathBuf};
2
3use tempfile::tempdir;
4use tokio::task::spawn_blocking;
5
6use super::error::GitError;
7use super::repo::{command_output_detail, run_git_command_output_sync, run_git_command_sync};
8use super::worktree::detect_git_info_sync;
9
10/// Outcome of attempting a squash merge operation.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum SquashMergeOutcome {
13    /// Squash merge staged changes and created a commit.
14    Committed,
15    /// Squash merge staged nothing because changes already exist in target.
16    AlreadyPresentInTarget,
17}
18
19/// Outcome classification for one attempted `merge-tree --write-tree` probe.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum MergeTreeAttempt {
22    Clean,
23    Conflict,
24    Unsupported,
25    Failed,
26}
27
28/// Captured output from the compatibility merge command.
29#[derive(Clone, Debug, Eq, PartialEq)]
30struct CompatibilityMergeOutput {
31    stderr: Vec<u8>,
32    stdout: Vec<u8>,
33    success: bool,
34}
35
36/// Executes the Git commands used by the compatibility merge probe.
37#[cfg_attr(test, mockall::automock)]
38trait CompatibilityMergeRunner: Send + Sync {
39    /// Runs a Git command that must succeed and returns its standard output.
40    fn run_git_command(
41        &self,
42        repo_path: &Path,
43        args: &[String],
44        error_context: &str,
45    ) -> Result<String, GitError>;
46
47    /// Runs the merge command and returns its status and captured output.
48    fn run_git_command_output(
49        &self,
50        repo_path: &Path,
51        args: &[String],
52    ) -> Result<CompatibilityMergeOutput, GitError>;
53}
54
55/// Compatibility merge runner backed by local Git subprocesses.
56struct ProcessCompatibilityMergeRunner;
57
58impl CompatibilityMergeRunner for ProcessCompatibilityMergeRunner {
59    fn run_git_command(
60        &self,
61        repo_path: &Path,
62        args: &[String],
63        error_context: &str,
64    ) -> Result<String, GitError> {
65        let args = args.iter().map(String::as_str).collect::<Vec<_>>();
66
67        run_git_command_sync(repo_path, &args, error_context)
68    }
69
70    fn run_git_command_output(
71        &self,
72        repo_path: &Path,
73        args: &[String],
74    ) -> Result<CompatibilityMergeOutput, GitError> {
75        let args = args.iter().map(String::as_str).collect::<Vec<_>>();
76        let output = run_git_command_output_sync(repo_path, &args)?;
77        let success = output.status.success();
78
79        Ok(CompatibilityMergeOutput {
80            stderr: output.stderr,
81            stdout: output.stdout,
82            success,
83        })
84    }
85}
86
87/// Returns whether merging `source_branch` into `target_branch` would produce
88/// conflicts without reading or changing the repository index or worktree.
89///
90/// # Errors
91/// Returns an error when either branch cannot be resolved or neither the
92/// native `git merge-tree` probe nor its compatibility fallback can compute
93/// the merge.
94pub(crate) async fn has_merge_conflicts(
95    repo_path: PathBuf,
96    source_branch: String,
97    target_branch: String,
98) -> Result<bool, GitError> {
99    spawn_blocking(move || {
100        let output = run_git_command_output_sync(
101            &repo_path,
102            &[
103                "merge-tree",
104                "--write-tree",
105                target_branch.as_str(),
106                source_branch.as_str(),
107            ],
108        )?;
109
110        let attempt = classify_merge_tree_attempt(
111            output.status.code(),
112            output.stdout.as_slice(),
113            output.stderr.as_slice(),
114        );
115
116        resolve_merge_tree_attempt(
117            &repo_path,
118            source_branch.as_str(),
119            target_branch.as_str(),
120            attempt,
121            output.stdout.as_slice(),
122            output.stderr.as_slice(),
123        )
124    })
125    .await?
126}
127
128/// Classifies native merge-tree output, including the pre-2.38 unsupported
129/// synopsis that does not advertise `--write-tree`.
130fn classify_merge_tree_attempt(
131    exit_code: Option<i32>,
132    stdout: &[u8],
133    stderr: &[u8],
134) -> MergeTreeAttempt {
135    match exit_code {
136        Some(0) => MergeTreeAttempt::Clean,
137        Some(1) if stderr.is_empty() => MergeTreeAttempt::Conflict,
138        Some(129)
139            if !String::from_utf8_lossy(stdout).contains("--write-tree")
140                && !String::from_utf8_lossy(stderr).contains("--write-tree") =>
141        {
142            MergeTreeAttempt::Unsupported
143        }
144        _ => MergeTreeAttempt::Failed,
145    }
146}
147
148/// Resolves a classified native probe, delegating unsupported Git versions to
149/// an isolated compatibility merge.
150fn resolve_merge_tree_attempt(
151    repo_path: &std::path::Path,
152    source_branch: &str,
153    target_branch: &str,
154    attempt: MergeTreeAttempt,
155    stdout: &[u8],
156    stderr: &[u8],
157) -> Result<bool, GitError> {
158    match attempt {
159        MergeTreeAttempt::Clean => Ok(false),
160        MergeTreeAttempt::Conflict => Ok(true),
161        MergeTreeAttempt::Unsupported => {
162            has_merge_conflicts_via_temporary_clone(repo_path, source_branch, target_branch)
163        }
164        MergeTreeAttempt::Failed => {
165            let detail = command_output_detail(stdout, stderr);
166
167            Err(GitError::CommandFailed {
168                command: format!("git merge-tree --write-tree {target_branch} {source_branch}"),
169                stderr: format!("Failed to inspect merge conflicts: {detail}"),
170            })
171        }
172    }
173}
174
175/// Computes the merge in a disposable local clone for Git versions whose
176/// `merge-tree` lacks `--write-tree`.
177fn has_merge_conflicts_via_temporary_clone(
178    repo_path: &Path,
179    source_branch: &str,
180    target_branch: &str,
181) -> Result<bool, GitError> {
182    let temporary_directory = tempdir()?;
183    let command_runner = ProcessCompatibilityMergeRunner;
184
185    has_merge_conflicts_via_temporary_clone_with_runner(
186        repo_path,
187        source_branch,
188        target_branch,
189        &temporary_directory,
190        &command_runner,
191    )
192}
193
194/// Computes a compatibility merge through an injectable command boundary.
195fn has_merge_conflicts_via_temporary_clone_with_runner(
196    repo_path: &Path,
197    source_branch: &str,
198    target_branch: &str,
199    temporary_directory: &tempfile::TempDir,
200    command_runner: &dyn CompatibilityMergeRunner,
201) -> Result<bool, GitError> {
202    let source_revision = format!("{source_branch}^{{commit}}");
203    let source_commit = command_runner.run_git_command(
204        repo_path,
205        &[
206            "rev-parse".to_string(),
207            "--verify".to_string(),
208            source_revision,
209        ],
210        "Failed to resolve merge source",
211    )?;
212    let target_revision = format!("{target_branch}^{{commit}}");
213    let target_commit = command_runner.run_git_command(
214        repo_path,
215        &[
216            "rev-parse".to_string(),
217            "--verify".to_string(),
218            target_revision,
219        ],
220        "Failed to resolve merge target",
221    )?;
222    let source_commit = source_commit.trim();
223    let target_commit = target_commit.trim();
224
225    let clone_path = temporary_directory.path().join("repository");
226    let clone_path_text = clone_path.to_string_lossy();
227    command_runner.run_git_command(
228        repo_path,
229        &[
230            "clone".to_string(),
231            "--shared".to_string(),
232            "--no-checkout".to_string(),
233            "--quiet".to_string(),
234            ".".to_string(),
235            clone_path_text.into_owned(),
236        ],
237        "Failed to create compatibility merge clone",
238    )?;
239    command_runner.run_git_command(
240        &clone_path,
241        &[
242            "checkout".to_string(),
243            "--detach".to_string(),
244            "--quiet".to_string(),
245            target_commit.to_string(),
246        ],
247        "Failed to check out compatibility merge target",
248    )?;
249
250    let disabled_hooks_path = temporary_directory.path().join("disabled-hooks");
251    let disabled_hooks_path = disabled_hooks_path.to_string_lossy();
252    let hooks_config = format!("core.hooksPath={disabled_hooks_path}");
253    let merge_output = command_runner.run_git_command_output(
254        &clone_path,
255        &[
256            "-c".to_string(),
257            hooks_config,
258            "-c".to_string(),
259            "user.name=Agentty".to_string(),
260            "-c".to_string(),
261            "user.email=agentty@localhost".to_string(),
262            "-c".to_string(),
263            "user.useConfigOnly=true".to_string(),
264            "merge".to_string(),
265            "--no-commit".to_string(),
266            "--no-ff".to_string(),
267            source_commit.to_string(),
268        ],
269    )?;
270    if merge_output.success {
271        return Ok(false);
272    }
273
274    let unmerged_files = command_runner.run_git_command(
275        &clone_path,
276        &["ls-files".to_string(), "--unmerged".to_string()],
277        "Failed to inspect compatibility merge conflicts",
278    )?;
279    if !unmerged_files.trim().is_empty() {
280        return Ok(true);
281    }
282
283    let detail = command_output_detail(&merge_output.stdout, &merge_output.stderr);
284
285    Err(GitError::CommandFailed {
286        command: format!("git merge --no-commit --no-ff {source_commit}"),
287        stderr: format!("Failed to inspect merge conflicts in compatibility clone: {detail}"),
288    })
289}
290
291/// Returns the full patch diff that will be squashed when merging a source
292/// branch into a target branch.
293///
294/// Uses `git diff <target>..<source>`.
295///
296/// # Arguments
297/// * `repo_path` - Path to the git repository root
298/// * `source_branch` - Name of the branch being merged
299/// * `target_branch` - Name of the branch receiving the squash merge
300///
301/// # Returns
302/// The full patch diff for the squash merge range.
303///
304/// # Errors
305/// Returns an error if invoking `git` fails or `git diff` exits with a
306/// non-zero status.
307pub(crate) async fn squash_merge_diff(
308    repo_path: PathBuf,
309    source_branch: String,
310    target_branch: String,
311) -> Result<String, GitError> {
312    spawn_blocking(move || {
313        let revision_range = format!("{target_branch}..{source_branch}");
314
315        run_git_command_sync(
316            &repo_path,
317            &["diff", revision_range.as_str()],
318            "Failed to read squash merge diff",
319        )
320    })
321    .await?
322}
323
324/// Performs a squash merge from a source branch to a target branch.
325///
326/// This function:
327/// 1. Verifies the repository is already on the target branch
328/// 2. Performs `git merge --squash` from the source branch
329/// 3. Commits the squashed changes, running configured commit hooks
330///
331/// The caller is responsible for ensuring `repo_path` is already checked out
332/// on `target_branch`. Switching branches here would disrupt the user's
333/// working directory.
334///
335/// # Arguments
336/// * `repo_path` - Path to the git repository root, already on `target_branch`
337/// * `source_branch` - Name of the branch to merge from (e.g., `wt/abc123`)
338/// * `target_branch` - Name of the branch to merge into (e.g., `main`)
339/// * `commit_message` - Message for the squash commit
340///
341/// # Returns
342/// A [`SquashMergeOutcome`] describing whether a squash commit was created.
343///
344/// # Errors
345/// Returns an error if the repository is on the wrong branch, the merge
346/// fails, or the commit or a configured commit hook fails.
347pub(crate) async fn squash_merge(
348    repo_path: PathBuf,
349    source_branch: String,
350    target_branch: String,
351    commit_message: String,
352) -> Result<SquashMergeOutcome, GitError> {
353    spawn_blocking(move || {
354        // Verify that `repo_path` is already on the target branch.
355        let current_branch = detect_git_info_sync(&repo_path).ok_or_else(|| {
356            GitError::OutputParse(format!(
357                "Failed to detect current branch in {}",
358                repo_path.display()
359            ))
360        })?;
361
362        if current_branch != target_branch {
363            return Err(GitError::CommandFailed {
364                command: "git merge --squash".to_string(),
365                stderr: format!(
366                    "Cannot merge: repository is on '{current_branch}' but expected \
367                     '{target_branch}'. Switch to '{target_branch}' first."
368                ),
369            });
370        }
371
372        run_git_command_sync(
373            &repo_path,
374            &["merge", "--squash", source_branch.as_str()],
375            &format!("Failed to squash merge {source_branch}"),
376        )?;
377
378        // `git diff --cached --quiet` exits 0 when index matches `HEAD`.
379        let cached_diff =
380            run_git_command_output_sync(&repo_path, &["diff", "--cached", "--quiet"])?;
381
382        if cached_diff.status.success() {
383            return Ok(SquashMergeOutcome::AlreadyPresentInTarget);
384        }
385
386        if cached_diff.status.code() != Some(1) {
387            let detail = command_output_detail(&cached_diff.stdout, &cached_diff.stderr);
388
389            return Err(GitError::CommandFailed {
390                command: "git diff --cached".to_string(),
391                stderr: detail,
392            });
393        }
394
395        run_git_command_sync(
396            &repo_path,
397            &["commit", "-m", commit_message.as_str()],
398            "Failed to commit squash merge",
399        )?;
400
401        Ok(SquashMergeOutcome::Committed)
402    })
403    .await?
404}
405
406#[cfg(test)]
407mod tests {
408    use std::fs;
409    #[cfg(unix)]
410    use std::os::unix::fs::PermissionsExt;
411    use std::path::Path;
412    use std::process::Command;
413
414    use mockall::Sequence;
415
416    use super::*;
417
418    /// Runs `git` in `repo_path` and asserts the command succeeds.
419    fn run_git_command(repo_path: &Path, args: &[&str]) {
420        let output = Command::new("git")
421            .args(args)
422            .current_dir(repo_path)
423            .output()
424            .expect("failed to run git command");
425
426        assert!(
427            output.status.success(),
428            "git command {:?} failed: {}",
429            args,
430            String::from_utf8_lossy(&output.stderr)
431        );
432    }
433
434    /// Runs `git` in `repo_path` and returns trimmed stdout.
435    fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
436        let output = Command::new("git")
437            .args(args)
438            .current_dir(repo_path)
439            .output()
440            .expect("failed to run git command");
441
442        assert!(
443            output.status.success(),
444            "git command {:?} failed: {}",
445            args,
446            String::from_utf8_lossy(&output.stderr)
447        );
448
449        String::from_utf8_lossy(&output.stdout).trim().to_string()
450    }
451
452    /// Creates a committed repository rooted at `repo_path`.
453    fn setup_test_git_repo(repo_path: &Path) {
454        run_git_command(repo_path, &["init", "-b", "main"]);
455        run_git_command(repo_path, &["config", "user.name", "Test User"]);
456        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
457        fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
458        run_git_command(repo_path, &["add", "README.md"]);
459        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
460    }
461
462    /// Creates diverged branches that edit the same line differently.
463    fn setup_conflicting_branches(repo_path: &Path) {
464        run_git_command(repo_path, &["checkout", "-b", "session-branch"]);
465        fs::write(repo_path.join("README.md"), "session\n")
466            .expect("failed to write session content");
467        run_git_command(repo_path, &["add", "README.md"]);
468        run_git_command(repo_path, &["commit", "-m", "Session change"]);
469        run_git_command(repo_path, &["checkout", "main"]);
470        fs::write(repo_path.join("README.md"), "main\n").expect("failed to write main content");
471        run_git_command(repo_path, &["add", "README.md"]);
472        run_git_command(repo_path, &["commit", "-m", "Main change"]);
473    }
474
475    /// Creates unrelated target and source branches that cannot be merged
476    /// without an explicit unrelated-histories override.
477    fn setup_unrelated_branches(repo_path: &Path) {
478        run_git_command(repo_path, &["checkout", "--orphan", "unrelated-branch"]);
479        run_git_command(repo_path, &["rm", "-rf", "."]);
480        fs::write(repo_path.join("unrelated.txt"), "unrelated\n")
481            .expect("failed to write unrelated content");
482        run_git_command(repo_path, &["add", "unrelated.txt"]);
483        run_git_command(repo_path, &["commit", "-m", "Unrelated change"]);
484        run_git_command(repo_path, &["checkout", "main"]);
485    }
486
487    /// Adds one ordered checked-command result to a compatibility runner.
488    fn expect_checked_command(
489        command_runner: &mut MockCompatibilityMergeRunner,
490        sequence: &mut Sequence,
491        result: Result<String, GitError>,
492    ) {
493        command_runner
494            .expect_run_git_command()
495            .times(1)
496            .in_sequence(sequence)
497            .return_once(move |_, _, _| result);
498    }
499
500    /// Adds successful source and target resolution to a compatibility runner.
501    fn expect_resolved_revisions(
502        command_runner: &mut MockCompatibilityMergeRunner,
503        sequence: &mut Sequence,
504    ) {
505        expect_checked_command(command_runner, sequence, Ok("source-commit\n".to_string()));
506        expect_checked_command(command_runner, sequence, Ok("target-commit\n".to_string()));
507    }
508
509    /// Adds successful clone creation and target checkout to a compatibility
510    /// runner.
511    fn expect_prepared_clone(
512        command_runner: &mut MockCompatibilityMergeRunner,
513        sequence: &mut Sequence,
514    ) {
515        expect_checked_command(command_runner, sequence, Ok(String::new()));
516        expect_checked_command(command_runner, sequence, Ok(String::new()));
517    }
518
519    /// Adds one ordered merge-command result to a compatibility runner.
520    fn expect_merge_command(
521        command_runner: &mut MockCompatibilityMergeRunner,
522        sequence: &mut Sequence,
523        result: Result<CompatibilityMergeOutput, GitError>,
524    ) {
525        command_runner
526            .expect_run_git_command_output()
527            .times(1)
528            .in_sequence(sequence)
529            .return_once(move |_, _| result);
530    }
531
532    /// Executes the compatibility probe through a configured mock runner.
533    fn run_compatibility_probe_with_mock(
534        command_runner: &MockCompatibilityMergeRunner,
535    ) -> Result<bool, GitError> {
536        let temporary_directory = tempdir().expect("failed to create temp dir");
537
538        has_merge_conflicts_via_temporary_clone_with_runner(
539            Path::new("repository"),
540            "session-branch",
541            "main",
542            &temporary_directory,
543            command_runner,
544        )
545    }
546
547    /// Creates a deterministic command failure for compatibility-probe tests.
548    fn compatibility_command_error(detail: &str) -> GitError {
549        GitError::CommandFailed {
550            command: "git compatibility-probe".to_string(),
551            stderr: detail.to_string(),
552        }
553    }
554
555    /// Returns merge output that requires an unmerged-file inspection.
556    fn conflicting_merge_output() -> CompatibilityMergeOutput {
557        CompatibilityMergeOutput {
558            stderr: b"merge conflict".to_vec(),
559            stdout: Vec::new(),
560            success: false,
561        }
562    }
563
564    #[test]
565    fn classify_merge_tree_attempt_recognizes_legacy_git_synopsis() {
566        // Arrange
567        let stderr = b"error: unknown option `write-tree'\nusage: git merge-tree <base-tree> <branch1> <branch2>\n";
568
569        // Act
570        let attempt = classify_merge_tree_attempt(Some(129), b"", stderr);
571
572        // Assert
573        assert_eq!(attempt, MergeTreeAttempt::Unsupported);
574    }
575
576    #[test]
577    fn classify_merge_tree_attempt_keeps_supported_usage_errors_failed() {
578        // Arrange
579        let stderr = b"error: unknown option `invalid'\nusage: git merge-tree [--write-tree] [<options>] <branch1> <branch2>\n";
580
581        // Act
582        let attempt = classify_merge_tree_attempt(Some(129), b"", stderr);
583
584        // Assert
585        assert_eq!(attempt, MergeTreeAttempt::Failed);
586    }
587
588    #[test]
589    fn compatibility_probe_preserves_source_resolution_error() {
590        // Arrange
591        let mut command_runner = MockCompatibilityMergeRunner::new();
592        let mut sequence = Sequence::new();
593        expect_checked_command(
594            &mut command_runner,
595            &mut sequence,
596            Err(compatibility_command_error("source resolution failed")),
597        );
598
599        // Act
600        let error = run_compatibility_probe_with_mock(&command_runner)
601            .expect_err("source resolution should fail the compatibility probe");
602
603        // Assert
604        assert_eq!(
605            error.to_string(),
606            "git compatibility-probe: source resolution failed"
607        );
608    }
609
610    #[test]
611    fn compatibility_probe_preserves_target_resolution_error() {
612        // Arrange
613        let mut command_runner = MockCompatibilityMergeRunner::new();
614        let mut sequence = Sequence::new();
615        expect_checked_command(
616            &mut command_runner,
617            &mut sequence,
618            Ok("source-commit\n".to_string()),
619        );
620        expect_checked_command(
621            &mut command_runner,
622            &mut sequence,
623            Err(compatibility_command_error("target resolution failed")),
624        );
625
626        // Act
627        let error = run_compatibility_probe_with_mock(&command_runner)
628            .expect_err("target resolution should fail the compatibility probe");
629
630        // Assert
631        assert_eq!(
632            error.to_string(),
633            "git compatibility-probe: target resolution failed"
634        );
635    }
636
637    #[test]
638    fn compatibility_probe_preserves_clone_creation_error() {
639        // Arrange
640        let mut command_runner = MockCompatibilityMergeRunner::new();
641        let mut sequence = Sequence::new();
642        expect_resolved_revisions(&mut command_runner, &mut sequence);
643        expect_checked_command(
644            &mut command_runner,
645            &mut sequence,
646            Err(compatibility_command_error("clone creation failed")),
647        );
648
649        // Act
650        let error = run_compatibility_probe_with_mock(&command_runner)
651            .expect_err("clone creation should fail the compatibility probe");
652
653        // Assert
654        assert_eq!(
655            error.to_string(),
656            "git compatibility-probe: clone creation failed"
657        );
658    }
659
660    #[test]
661    fn compatibility_probe_preserves_target_checkout_error() {
662        // Arrange
663        let mut command_runner = MockCompatibilityMergeRunner::new();
664        let mut sequence = Sequence::new();
665        expect_resolved_revisions(&mut command_runner, &mut sequence);
666        expect_checked_command(&mut command_runner, &mut sequence, Ok(String::new()));
667        expect_checked_command(
668            &mut command_runner,
669            &mut sequence,
670            Err(compatibility_command_error("target checkout failed")),
671        );
672
673        // Act
674        let error = run_compatibility_probe_with_mock(&command_runner)
675            .expect_err("target checkout should fail the compatibility probe");
676
677        // Assert
678        assert_eq!(
679            error.to_string(),
680            "git compatibility-probe: target checkout failed"
681        );
682    }
683
684    #[test]
685    fn compatibility_probe_preserves_merge_execution_error() {
686        // Arrange
687        let mut command_runner = MockCompatibilityMergeRunner::new();
688        let mut sequence = Sequence::new();
689        expect_resolved_revisions(&mut command_runner, &mut sequence);
690        expect_prepared_clone(&mut command_runner, &mut sequence);
691        expect_merge_command(
692            &mut command_runner,
693            &mut sequence,
694            Err(compatibility_command_error("merge execution failed")),
695        );
696
697        // Act
698        let error = run_compatibility_probe_with_mock(&command_runner)
699            .expect_err("merge execution should fail the compatibility probe");
700
701        // Assert
702        assert_eq!(
703            error.to_string(),
704            "git compatibility-probe: merge execution failed"
705        );
706    }
707
708    #[test]
709    fn compatibility_probe_preserves_unmerged_inspection_error() {
710        // Arrange
711        let mut command_runner = MockCompatibilityMergeRunner::new();
712        let mut sequence = Sequence::new();
713        expect_resolved_revisions(&mut command_runner, &mut sequence);
714        expect_prepared_clone(&mut command_runner, &mut sequence);
715        expect_merge_command(
716            &mut command_runner,
717            &mut sequence,
718            Ok(conflicting_merge_output()),
719        );
720        expect_checked_command(
721            &mut command_runner,
722            &mut sequence,
723            Err(compatibility_command_error("unmerged inspection failed")),
724        );
725
726        // Act
727        let error = run_compatibility_probe_with_mock(&command_runner)
728            .expect_err("unmerged inspection should fail the compatibility probe");
729
730        // Assert
731        assert_eq!(
732            error.to_string(),
733            "git compatibility-probe: unmerged inspection failed"
734        );
735    }
736
737    #[test]
738    fn compatibility_probe_returns_false_for_clean_merge() {
739        // Arrange
740        let temp_dir = tempdir().expect("failed to create temp dir");
741        setup_test_git_repo(temp_dir.path());
742        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
743        fs::write(temp_dir.path().join("session.txt"), "session\n")
744            .expect("failed to write session file");
745        run_git_command(temp_dir.path(), &["add", "session.txt"]);
746        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
747
748        // Act
749        let has_conflicts = resolve_merge_tree_attempt(
750            temp_dir.path(),
751            "session-branch",
752            "main",
753            MergeTreeAttempt::Unsupported,
754            b"",
755            b"legacy git usage",
756        )
757        .expect("compatibility probe should succeed");
758
759        // Assert
760        assert!(!has_conflicts);
761    }
762
763    #[test]
764    fn compatibility_probe_returns_true_for_conflicting_merge() {
765        // Arrange
766        let temp_dir = tempdir().expect("failed to create temp dir");
767        setup_test_git_repo(temp_dir.path());
768        setup_conflicting_branches(temp_dir.path());
769        let original_head = run_git_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
770
771        // Act
772        let has_conflicts = resolve_merge_tree_attempt(
773            temp_dir.path(),
774            "session-branch",
775            "main",
776            MergeTreeAttempt::Unsupported,
777            b"",
778            b"legacy git usage",
779        )
780        .expect("compatibility probe should succeed");
781
782        // Assert
783        assert!(has_conflicts);
784        assert_eq!(
785            run_git_stdout(temp_dir.path(), &["rev-parse", "HEAD"]),
786            original_head
787        );
788        assert!(run_git_stdout(temp_dir.path(), &["status", "--porcelain"]).is_empty());
789    }
790
791    #[test]
792    fn compatibility_probe_returns_error_when_merge_cannot_start() {
793        // Arrange
794        let temp_dir = tempdir().expect("failed to create temp dir");
795        setup_test_git_repo(temp_dir.path());
796        setup_unrelated_branches(temp_dir.path());
797
798        // Act
799        let error = resolve_merge_tree_attempt(
800            temp_dir.path(),
801            "unrelated-branch",
802            "main",
803            MergeTreeAttempt::Unsupported,
804            b"",
805            b"legacy git usage",
806        )
807        .expect_err("unrelated histories should fail the compatibility probe");
808
809        // Assert
810        assert!(
811            error
812                .to_string()
813                .contains("refusing to merge unrelated histories")
814        );
815    }
816
817    #[tokio::test]
818    async fn has_merge_conflicts_returns_false_for_clean_merge() {
819        // Arrange
820        let temp_dir = tempdir().expect("failed to create temp dir");
821        setup_test_git_repo(temp_dir.path());
822        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
823        fs::write(temp_dir.path().join("session.txt"), "session\n")
824            .expect("failed to write session file");
825        run_git_command(temp_dir.path(), &["add", "session.txt"]);
826        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
827
828        // Act
829        let has_conflicts = has_merge_conflicts(
830            temp_dir.path().to_path_buf(),
831            "session-branch".to_string(),
832            "main".to_string(),
833        )
834        .await
835        .expect("merge conflict probe should succeed");
836
837        // Assert
838        assert!(!has_conflicts);
839    }
840
841    #[tokio::test]
842    async fn has_merge_conflicts_returns_true_for_conflicting_merge() {
843        // Arrange
844        let temp_dir = tempdir().expect("failed to create temp dir");
845        setup_test_git_repo(temp_dir.path());
846        setup_conflicting_branches(temp_dir.path());
847
848        // Act
849        let has_conflicts = has_merge_conflicts(
850            temp_dir.path().to_path_buf(),
851            "session-branch".to_string(),
852            "main".to_string(),
853        )
854        .await
855        .expect("merge conflict probe should succeed");
856
857        // Assert
858        assert!(has_conflicts);
859    }
860
861    #[tokio::test]
862    async fn has_merge_conflicts_returns_error_for_missing_branch() {
863        // Arrange
864        let temp_dir = tempdir().expect("failed to create temp dir");
865        setup_test_git_repo(temp_dir.path());
866
867        // Act
868        let error = has_merge_conflicts(
869            temp_dir.path().to_path_buf(),
870            "missing-branch".to_string(),
871            "main".to_string(),
872        )
873        .await
874        .expect_err("missing branch should fail conflict detection");
875
876        // Assert
877        assert!(error.to_string().contains("git merge-tree"));
878        assert!(error.to_string().contains("missing-branch"));
879    }
880
881    #[tokio::test]
882    async fn has_merge_conflicts_returns_error_for_missing_repository() {
883        // Arrange
884        let temp_dir = tempdir().expect("failed to create temp dir");
885        let missing_repo = temp_dir.path().join("missing");
886
887        // Act
888        let error = has_merge_conflicts(
889            missing_repo,
890            "session-branch".to_string(),
891            "main".to_string(),
892        )
893        .await
894        .expect_err("missing repository should fail conflict detection");
895
896        // Assert
897        assert!(error.to_string().contains("git merge-tree"));
898    }
899
900    #[tokio::test]
901    async fn squash_merge_returns_branch_mismatch_error_when_target_is_not_checked_out() {
902        // Arrange
903        let temp_dir = tempdir().expect("failed to create temp dir");
904        setup_test_git_repo(temp_dir.path());
905        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
906
907        // Act
908        let result = squash_merge(
909            temp_dir.path().to_path_buf(),
910            "feature-branch".to_string(),
911            "main".to_string(),
912            "Merge feature".to_string(),
913        )
914        .await;
915
916        // Assert
917        let error = result.expect_err("branch mismatch should fail").to_string();
918        assert!(error.contains("repository is on 'feature-branch'"));
919        assert!(error.contains("Switch to 'main' first."));
920    }
921
922    #[tokio::test]
923    async fn squash_merge_commits_the_provided_multiline_message() {
924        // Arrange
925        let temp_dir = tempdir().expect("failed to create temp dir");
926        setup_test_git_repo(temp_dir.path());
927        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
928        fs::write(temp_dir.path().join("feature.txt"), "feature content")
929            .expect("failed to write feature file");
930        run_git_command(temp_dir.path(), &["add", "feature.txt"]);
931        run_git_command(temp_dir.path(), &["commit", "-m", "Add feature"]);
932        run_git_command(temp_dir.path(), &["checkout", "main"]);
933        let commit_message = "Refine merge flow\n\n- Reuse the session commit body".to_string();
934
935        // Act
936        let result = squash_merge(
937            temp_dir.path().to_path_buf(),
938            "feature-branch".to_string(),
939            "main".to_string(),
940            commit_message.clone(),
941        )
942        .await;
943        let head_message = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
944
945        // Assert
946        assert_eq!(
947            result.expect("squash merge should succeed"),
948            SquashMergeOutcome::Committed,
949        );
950        assert_eq!(head_message, commit_message);
951    }
952
953    #[cfg(unix)]
954    #[tokio::test]
955    async fn squash_merge_runs_pre_commit_hook() {
956        // Arrange
957        let temp_dir = tempdir().expect("failed to create temp dir");
958        setup_test_git_repo(temp_dir.path());
959        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
960        fs::write(temp_dir.path().join("feature.txt"), "feature content")
961            .expect("failed to write feature file");
962        run_git_command(temp_dir.path(), &["add", "feature.txt"]);
963        run_git_command(temp_dir.path(), &["commit", "-m", "Add feature"]);
964        run_git_command(temp_dir.path(), &["checkout", "main"]);
965        let hooks_dir = temp_dir.path().join("test-hooks");
966        fs::create_dir(&hooks_dir).expect("failed to create hooks directory");
967        let hook_path = hooks_dir.join("pre-commit");
968        fs::write(&hook_path, "#!/bin/sh\necho hook-blocked >&2\nexit 1\n")
969            .expect("failed to write pre-commit hook");
970        let mut permissions = fs::metadata(&hook_path)
971            .expect("failed to read hook metadata")
972            .permissions();
973        permissions.set_mode(0o755);
974        fs::set_permissions(&hook_path, permissions).expect("failed to make hook executable");
975        run_git_command(temp_dir.path(), &["config", "core.hooksPath", "test-hooks"]);
976
977        // Act
978        let error = squash_merge(
979            temp_dir.path().to_path_buf(),
980            "feature-branch".to_string(),
981            "main".to_string(),
982            "Squash merge feature".to_string(),
983        )
984        .await
985        .expect_err("pre-commit hook should block the squash commit");
986
987        // Assert
988        assert!(error.to_string().contains("hook-blocked"));
989    }
990
991    #[tokio::test]
992    async fn squash_merge_skips_commit_creation_when_changes_are_already_present() {
993        // Arrange
994        let temp_dir = tempdir().expect("failed to create temp dir");
995        setup_test_git_repo(temp_dir.path());
996        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
997        fs::write(temp_dir.path().join("session.txt"), "session change")
998            .expect("failed to write session file");
999        run_git_command(temp_dir.path(), &["add", "session.txt"]);
1000        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1001        run_git_command(temp_dir.path(), &["checkout", "main"]);
1002        fs::write(temp_dir.path().join("session.txt"), "session change")
1003            .expect("failed to write main file");
1004        run_git_command(temp_dir.path(), &["add", "session.txt"]);
1005        run_git_command(
1006            temp_dir.path(),
1007            &["commit", "-m", "Apply same change on main"],
1008        );
1009        let commit_count_before = run_git_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
1010        let head_message_before = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
1011
1012        // Act
1013        let result = squash_merge(
1014            temp_dir.path().to_path_buf(),
1015            "session-branch".to_string(),
1016            "main".to_string(),
1017            "Merge session".to_string(),
1018        )
1019        .await;
1020        let commit_count_after = run_git_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
1021        let head_message_after = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
1022
1023        // Assert
1024        assert_eq!(
1025            result.expect("squash merge should succeed"),
1026            SquashMergeOutcome::AlreadyPresentInTarget,
1027        );
1028        assert_eq!(commit_count_after, commit_count_before);
1029        assert_eq!(head_message_after, head_message_before);
1030    }
1031
1032    #[tokio::test]
1033    async fn squash_merge_returns_command_detail_for_missing_source_branch() {
1034        // Arrange
1035        let temp_dir = tempdir().expect("failed to create temp dir");
1036        setup_test_git_repo(temp_dir.path());
1037
1038        // Act
1039        let result = squash_merge(
1040            temp_dir.path().to_path_buf(),
1041            "missing-branch".to_string(),
1042            "main".to_string(),
1043            "Merge feature".to_string(),
1044        )
1045        .await;
1046
1047        // Assert
1048        let error = result.expect_err("missing branch should fail").to_string();
1049        assert!(error.contains("Failed to squash merge missing-branch"));
1050        assert!(error.contains("missing-branch"));
1051    }
1052}