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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum SquashMergeOutcome {
13 Committed,
15 AlreadyPresentInTarget,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum MergeTreeAttempt {
22 Clean,
23 Conflict,
24 Unsupported,
25 Failed,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30struct CompatibilityMergeOutput {
31 stderr: Vec<u8>,
32 stdout: Vec<u8>,
33 success: bool,
34}
35
36#[cfg_attr(test, mockall::automock)]
38trait CompatibilityMergeRunner: Send + Sync {
39 fn run_git_command(
41 &self,
42 repo_path: &Path,
43 args: &[String],
44 error_context: &str,
45 ) -> Result<String, GitError>;
46
47 fn run_git_command_output(
49 &self,
50 repo_path: &Path,
51 args: &[String],
52 ) -> Result<CompatibilityMergeOutput, GitError>;
53}
54
55struct 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
87pub(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
128fn 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
148fn 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
175fn 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
194fn 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
291pub(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
324pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let stderr = b"error: unknown option `write-tree'\nusage: git merge-tree <base-tree> <branch1> <branch2>\n";
568
569 let attempt = classify_merge_tree_attempt(Some(129), b"", stderr);
571
572 assert_eq!(attempt, MergeTreeAttempt::Unsupported);
574 }
575
576 #[test]
577 fn classify_merge_tree_attempt_keeps_supported_usage_errors_failed() {
578 let stderr = b"error: unknown option `invalid'\nusage: git merge-tree [--write-tree] [<options>] <branch1> <branch2>\n";
580
581 let attempt = classify_merge_tree_attempt(Some(129), b"", stderr);
583
584 assert_eq!(attempt, MergeTreeAttempt::Failed);
586 }
587
588 #[test]
589 fn compatibility_probe_preserves_source_resolution_error() {
590 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 let error = run_compatibility_probe_with_mock(&command_runner)
601 .expect_err("source resolution should fail the compatibility probe");
602
603 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 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 let error = run_compatibility_probe_with_mock(&command_runner)
628 .expect_err("target resolution should fail the compatibility probe");
629
630 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 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 let error = run_compatibility_probe_with_mock(&command_runner)
651 .expect_err("clone creation should fail the compatibility probe");
652
653 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 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 let error = run_compatibility_probe_with_mock(&command_runner)
675 .expect_err("target checkout should fail the compatibility probe");
676
677 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 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 let error = run_compatibility_probe_with_mock(&command_runner)
699 .expect_err("merge execution should fail the compatibility probe");
700
701 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 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 let error = run_compatibility_probe_with_mock(&command_runner)
728 .expect_err("unmerged inspection should fail the compatibility probe");
729
730 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 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 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!(!has_conflicts);
761 }
762
763 #[test]
764 fn compatibility_probe_returns_true_for_conflicting_merge() {
765 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 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!(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 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 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!(
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 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 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!(!has_conflicts);
839 }
840
841 #[tokio::test]
842 async fn has_merge_conflicts_returns_true_for_conflicting_merge() {
843 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 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!(has_conflicts);
859 }
860
861 #[tokio::test]
862 async fn has_merge_conflicts_returns_error_for_missing_branch() {
863 let temp_dir = tempdir().expect("failed to create temp dir");
865 setup_test_git_repo(temp_dir.path());
866
867 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!(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 let temp_dir = tempdir().expect("failed to create temp dir");
885 let missing_repo = temp_dir.path().join("missing");
886
887 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!(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 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 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 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 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 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_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 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 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!(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 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 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_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 let temp_dir = tempdir().expect("failed to create temp dir");
1036 setup_test_git_repo(temp_dir.path());
1037
1038 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 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}