1use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6
7use super::error::GitError;
8use super::merge::SquashMergeOutcome;
9use super::rebase::{InProgressGitOperation, RebaseStepResult};
10use super::sync::{
11 BranchTrackingMap, PullRebaseResult, SingleCommitMessageStrategy, WorktreeFileContent,
12};
13use super::{
14 abort_rebase, branch_tracking_statuses, check_pre_commit_hook_ready, commit_all,
15 commit_all_preserving_single_commit, create_worktree, current_upstream_reference,
16 delete_branch, detect_git_info, diff, diff_changed_files, fetch_remote, find_git_repo_root,
17 get_ahead_behind, get_ref_ahead_behind, has_commits_since, has_merge_conflicts,
18 has_unmerged_paths, head_commit_message, head_hash, head_short_hash, in_progress_operation,
19 is_rebase_in_progress, is_worktree_clean, list_conflicted_files, list_local_commit_titles,
20 list_staged_conflict_marker_files, list_upstream_commit_titles, main_checkout_working_tree,
21 main_repo_root, pull_rebase, push_current_branch, push_current_branch_to_new_remote_branch,
22 push_current_branch_to_remote_branch, rebase, rebase_continue, rebase_onto_start, rebase_start,
23 ref_hash, remote_branch_exists, remove_worktree, repo_url, run_pre_commit_hook, squash_merge,
24 squash_merge_diff, stage_all, sync, tracked_worktree_status, worktree_status,
25};
26
27pub type GitFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
29
30#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
35pub trait GitClient: Send + Sync {
36 fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>>;
41
42 fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>>;
46
47 fn check_pre_commit_hook_ready(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
53
54 fn run_pre_commit_hook(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
62
63 fn create_worktree(
70 &self,
71 repo_path: PathBuf,
72 worktree_path: PathBuf,
73 branch_name: String,
74 start_ref: String,
75 ) -> GitFuture<Result<(), GitError>>;
76
77 fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>>;
83
84 fn squash_merge_diff(
90 &self,
91 repo_path: PathBuf,
92 source_branch: String,
93 target_branch: String,
94 ) -> GitFuture<Result<String, GitError>>;
95
96 fn squash_merge(
102 &self,
103 repo_path: PathBuf,
104 source_branch: String,
105 target_branch: String,
106 commit_message: String,
107 ) -> GitFuture<Result<SquashMergeOutcome, GitError>>;
108
109 fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>>;
114
115 fn rebase_start(
121 &self,
122 repo_path: PathBuf,
123 target_branch: String,
124 ) -> GitFuture<Result<RebaseStepResult, GitError>>;
125
126 fn rebase_onto_start(
131 &self,
132 repo_path: PathBuf,
133 new_base: String,
134 old_base: String,
135 ) -> GitFuture<Result<RebaseStepResult, GitError>>;
136
137 fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>>;
142
143 fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
148
149 fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
155
156 fn in_progress_operation(
161 &self,
162 repo_path: PathBuf,
163 ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>>;
164
165 fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
170
171 fn list_staged_conflict_marker_files(
177 &self,
178 repo_path: PathBuf,
179 paths: Vec<String>,
180 ) -> GitFuture<Result<Vec<String>, GitError>>;
181
182 fn list_conflicted_files(&self, repo_path: PathBuf)
187 -> GitFuture<Result<Vec<String>, GitError>>;
188
189 fn commit_all(&self, repo_path: PathBuf, message: String) -> GitFuture<Result<(), GitError>>;
194
195 fn commit_all_preserving_single_commit(
203 &self,
204 repo_path: PathBuf,
205 base_branch: String,
206 commit_message: String,
207 message_strategy: SingleCommitMessageStrategy,
208 ) -> GitFuture<Result<(), GitError>>;
209
210 fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
215
216 fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
221
222 fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
227
228 fn ref_hash(
233 &self,
234 repo_path: PathBuf,
235 reference: String,
236 ) -> GitFuture<Result<String, GitError>>;
237
238 fn head_commit_message(
244 &self,
245 repo_path: PathBuf,
246 ) -> GitFuture<Result<Option<String>, GitError>>;
247
248 fn delete_branch(
254 &self,
255 repo_path: PathBuf,
256 branch_name: String,
257 ) -> GitFuture<Result<(), GitError>>;
258
259 fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>>;
265
266 fn diff_changed_files(
273 &self,
274 repo_path: PathBuf,
275 base_branch: String,
276 ) -> GitFuture<Result<Vec<String>, GitError>>;
277
278 fn read_worktree_file(
283 &self,
284 repo_path: PathBuf,
285 relative_path: String,
286 ) -> GitFuture<Result<WorktreeFileContent, GitError>>;
287
288 fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
293
294 fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
299
300 fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
305
306 fn has_commits_since(
312 &self,
313 repo_path: PathBuf,
314 base_branch: String,
315 ) -> GitFuture<Result<bool, GitError>>;
316
317 fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>>;
322
323 fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
330
331 fn push_current_branch_to_remote_branch(
338 &self,
339 repo_path: PathBuf,
340 remote_branch_name: String,
341 ) -> GitFuture<Result<String, GitError>>;
342
343 fn push_current_branch_to_new_remote_branch(
349 &self,
350 repo_path: PathBuf,
351 remote_branch_name: String,
352 ) -> GitFuture<Result<String, GitError>>;
353
354 fn remote_branch_exists(
360 &self,
361 repo_path: PathBuf,
362 remote_branch_name: String,
363 ) -> GitFuture<Result<bool, GitError>>;
364
365 fn current_upstream_reference(&self, repo_path: PathBuf)
370 -> GitFuture<Result<String, GitError>>;
371
372 fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
377
378 fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>>;
383
384 fn get_ref_ahead_behind(
392 &self,
393 repo_path: PathBuf,
394 left_ref: String,
395 right_ref: String,
396 ) -> GitFuture<Result<(u32, u32), GitError>>;
397
398 fn has_merge_conflicts(
405 &self,
406 repo_path: PathBuf,
407 source_branch: String,
408 target_branch: String,
409 ) -> GitFuture<Result<bool, GitError>>;
410
411 fn branch_tracking_statuses(
420 &self,
421 repo_path: PathBuf,
422 ) -> GitFuture<Result<BranchTrackingMap, GitError>>;
423
424 fn list_upstream_commit_titles(
431 &self,
432 repo_path: PathBuf,
433 ) -> GitFuture<Result<Vec<String>, GitError>>;
434
435 fn list_local_commit_titles(
441 &self,
442 repo_path: PathBuf,
443 ) -> GitFuture<Result<Vec<String>, GitError>>;
444
445 fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
450
451 fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>>;
456
457 fn main_checkout_working_tree(
465 &self,
466 repo_path: PathBuf,
467 ) -> GitFuture<Result<Option<PathBuf>, GitError>>;
468}
469
470pub struct RealGitClient;
472
473impl GitClient for RealGitClient {
474 fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>> {
475 Box::pin(async move { detect_git_info(dir).await })
476 }
477
478 fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>> {
479 Box::pin(async move { find_git_repo_root(dir).await })
480 }
481
482 fn check_pre_commit_hook_ready(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
483 Box::pin(async move { check_pre_commit_hook_ready(repo_path).await })
484 }
485
486 fn run_pre_commit_hook(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
487 Box::pin(async move { run_pre_commit_hook(repo_path).await })
488 }
489
490 fn create_worktree(
491 &self,
492 repo_path: PathBuf,
493 worktree_path: PathBuf,
494 branch_name: String,
495 start_ref: String,
496 ) -> GitFuture<Result<(), GitError>> {
497 Box::pin(
498 async move { create_worktree(repo_path, worktree_path, branch_name, start_ref).await },
499 )
500 }
501
502 fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>> {
503 Box::pin(async move { remove_worktree(worktree_path).await })
504 }
505
506 fn squash_merge_diff(
507 &self,
508 repo_path: PathBuf,
509 source_branch: String,
510 target_branch: String,
511 ) -> GitFuture<Result<String, GitError>> {
512 Box::pin(async move { squash_merge_diff(repo_path, source_branch, target_branch).await })
513 }
514
515 fn squash_merge(
516 &self,
517 repo_path: PathBuf,
518 source_branch: String,
519 target_branch: String,
520 commit_message: String,
521 ) -> GitFuture<Result<SquashMergeOutcome, GitError>> {
522 Box::pin(async move {
523 squash_merge(repo_path, source_branch, target_branch, commit_message).await
524 })
525 }
526
527 fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>> {
528 Box::pin(async move { rebase::rebase(repo_path, target_branch).await })
529 }
530
531 fn rebase_start(
532 &self,
533 repo_path: PathBuf,
534 target_branch: String,
535 ) -> GitFuture<Result<RebaseStepResult, GitError>> {
536 Box::pin(async move { rebase_start(repo_path, target_branch).await })
537 }
538
539 fn rebase_onto_start(
540 &self,
541 repo_path: PathBuf,
542 new_base: String,
543 old_base: String,
544 ) -> GitFuture<Result<RebaseStepResult, GitError>> {
545 Box::pin(async move { rebase_onto_start(repo_path, new_base, old_base).await })
546 }
547
548 fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>> {
549 Box::pin(async move { rebase_continue(repo_path).await })
550 }
551
552 fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
553 Box::pin(async move { abort_rebase(repo_path).await })
554 }
555
556 fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
557 Box::pin(async move { is_rebase_in_progress(repo_path).await })
558 }
559
560 fn in_progress_operation(
561 &self,
562 repo_path: PathBuf,
563 ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>> {
564 Box::pin(async move { in_progress_operation(repo_path).await })
565 }
566
567 fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
568 Box::pin(async move { has_unmerged_paths(repo_path).await })
569 }
570
571 fn list_staged_conflict_marker_files(
572 &self,
573 repo_path: PathBuf,
574 paths: Vec<String>,
575 ) -> GitFuture<Result<Vec<String>, GitError>> {
576 Box::pin(async move { list_staged_conflict_marker_files(repo_path, paths).await })
577 }
578
579 fn list_conflicted_files(
580 &self,
581 repo_path: PathBuf,
582 ) -> GitFuture<Result<Vec<String>, GitError>> {
583 Box::pin(async move { list_conflicted_files(repo_path).await })
584 }
585
586 fn commit_all(&self, repo_path: PathBuf, message: String) -> GitFuture<Result<(), GitError>> {
587 Box::pin(async move { commit_all(repo_path, message).await })
588 }
589
590 fn commit_all_preserving_single_commit(
591 &self,
592 repo_path: PathBuf,
593 base_branch: String,
594 commit_message: String,
595 message_strategy: SingleCommitMessageStrategy,
596 ) -> GitFuture<Result<(), GitError>> {
597 Box::pin(async move {
598 commit_all_preserving_single_commit(
599 repo_path,
600 base_branch,
601 commit_message,
602 message_strategy,
603 )
604 .await
605 })
606 }
607
608 fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
609 Box::pin(async move { stage_all(repo_path).await })
610 }
611
612 fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
613 Box::pin(async move { head_short_hash(repo_path).await })
614 }
615
616 fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
617 Box::pin(async move { head_hash(repo_path).await })
618 }
619
620 fn ref_hash(
621 &self,
622 repo_path: PathBuf,
623 reference: String,
624 ) -> GitFuture<Result<String, GitError>> {
625 Box::pin(async move { ref_hash(repo_path, reference).await })
626 }
627
628 fn head_commit_message(
629 &self,
630 repo_path: PathBuf,
631 ) -> GitFuture<Result<Option<String>, GitError>> {
632 Box::pin(async move { head_commit_message(repo_path).await })
633 }
634
635 fn delete_branch(
636 &self,
637 repo_path: PathBuf,
638 branch_name: String,
639 ) -> GitFuture<Result<(), GitError>> {
640 Box::pin(async move { delete_branch(repo_path, branch_name).await })
641 }
642
643 fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>> {
644 Box::pin(async move { diff(repo_path, base_branch).await })
645 }
646
647 fn diff_changed_files(
648 &self,
649 repo_path: PathBuf,
650 base_branch: String,
651 ) -> GitFuture<Result<Vec<String>, GitError>> {
652 Box::pin(async move { diff_changed_files(repo_path, base_branch).await })
653 }
654
655 fn read_worktree_file(
656 &self,
657 repo_path: PathBuf,
658 relative_path: String,
659 ) -> GitFuture<Result<WorktreeFileContent, GitError>> {
660 Box::pin(async move { sync::read_worktree_file(repo_path, relative_path).await })
661 }
662
663 fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
664 Box::pin(async move { is_worktree_clean(repo_path).await })
665 }
666
667 fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
668 Box::pin(async move { worktree_status(repo_path).await })
669 }
670
671 fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
672 Box::pin(async move { tracked_worktree_status(repo_path).await })
673 }
674
675 fn has_commits_since(
676 &self,
677 repo_path: PathBuf,
678 base_branch: String,
679 ) -> GitFuture<Result<bool, GitError>> {
680 Box::pin(async move { has_commits_since(repo_path, base_branch).await })
681 }
682
683 fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>> {
684 Box::pin(async move { pull_rebase(repo_path).await })
685 }
686
687 fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
688 Box::pin(async move { push_current_branch(repo_path).await })
689 }
690
691 fn push_current_branch_to_remote_branch(
692 &self,
693 repo_path: PathBuf,
694 remote_branch_name: String,
695 ) -> GitFuture<Result<String, GitError>> {
696 Box::pin(async move {
697 push_current_branch_to_remote_branch(repo_path, remote_branch_name).await
698 })
699 }
700
701 fn push_current_branch_to_new_remote_branch(
702 &self,
703 repo_path: PathBuf,
704 remote_branch_name: String,
705 ) -> GitFuture<Result<String, GitError>> {
706 Box::pin(async move {
707 push_current_branch_to_new_remote_branch(repo_path, remote_branch_name).await
708 })
709 }
710
711 fn remote_branch_exists(
712 &self,
713 repo_path: PathBuf,
714 remote_branch_name: String,
715 ) -> GitFuture<Result<bool, GitError>> {
716 Box::pin(async move { remote_branch_exists(repo_path, remote_branch_name).await })
717 }
718
719 fn current_upstream_reference(
720 &self,
721 repo_path: PathBuf,
722 ) -> GitFuture<Result<String, GitError>> {
723 Box::pin(async move { current_upstream_reference(repo_path).await })
724 }
725
726 fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
727 Box::pin(async move { fetch_remote(repo_path).await })
728 }
729
730 fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>> {
731 Box::pin(async move { get_ahead_behind(repo_path).await })
732 }
733
734 fn get_ref_ahead_behind(
735 &self,
736 repo_path: PathBuf,
737 left_ref: String,
738 right_ref: String,
739 ) -> GitFuture<Result<(u32, u32), GitError>> {
740 Box::pin(async move { get_ref_ahead_behind(repo_path, left_ref, right_ref).await })
741 }
742
743 fn has_merge_conflicts(
744 &self,
745 repo_path: PathBuf,
746 source_branch: String,
747 target_branch: String,
748 ) -> GitFuture<Result<bool, GitError>> {
749 Box::pin(async move { has_merge_conflicts(repo_path, source_branch, target_branch).await })
750 }
751
752 fn branch_tracking_statuses(
753 &self,
754 repo_path: PathBuf,
755 ) -> GitFuture<Result<BranchTrackingMap, GitError>> {
756 Box::pin(async move { branch_tracking_statuses(repo_path).await })
757 }
758
759 fn list_upstream_commit_titles(
760 &self,
761 repo_path: PathBuf,
762 ) -> GitFuture<Result<Vec<String>, GitError>> {
763 Box::pin(async move { list_upstream_commit_titles(repo_path).await })
764 }
765
766 fn list_local_commit_titles(
767 &self,
768 repo_path: PathBuf,
769 ) -> GitFuture<Result<Vec<String>, GitError>> {
770 Box::pin(async move { list_local_commit_titles(repo_path).await })
771 }
772
773 fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
774 Box::pin(async move { repo_url(repo_path).await })
775 }
776
777 fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>> {
778 Box::pin(async move { main_repo_root(repo_path).await })
779 }
780
781 fn main_checkout_working_tree(
782 &self,
783 repo_path: PathBuf,
784 ) -> GitFuture<Result<Option<PathBuf>, GitError>> {
785 Box::pin(async move { main_checkout_working_tree(repo_path).await })
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use std::fs;
792 use std::path::{Path, PathBuf};
793 use std::process::Command;
794 use std::time::Duration;
795
796 use tempfile::tempdir;
797
798 use super::*;
799
800 fn canonicalize_test_path(path: &Path) -> PathBuf {
803 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
804 }
805
806 fn run_git_command(repo_path: &Path, args: &[&str]) {
807 let output = Command::new("git")
808 .args(args)
809 .current_dir(repo_path)
810 .output()
811 .expect("failed to run git command");
812
813 assert!(
814 output.status.success(),
815 "git command {:?} failed: {}",
816 args,
817 String::from_utf8_lossy(&output.stderr)
818 );
819 }
820
821 fn run_git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
822 let output = Command::new("git")
823 .args(args)
824 .current_dir(repo_path)
825 .output()
826 .expect("failed to run git command");
827
828 assert!(
829 output.status.success(),
830 "git command {:?} failed: {}",
831 args,
832 String::from_utf8_lossy(&output.stderr)
833 );
834
835 String::from_utf8_lossy(&output.stdout).trim().to_string()
836 }
837
838 fn setup_test_git_repo(repo_path: &Path) {
839 run_git_command(repo_path, &["init", "-b", "main"]);
840 run_git_command(repo_path, &["config", "user.name", "Test User"]);
841 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
842
843 fs::write(repo_path.join("README.md"), "test repo").expect("failed to write file");
844 run_git_command(repo_path, &["add", "README.md"]);
845 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
846 }
847
848 #[tokio::test]
849 async fn test_real_git_client_runs_hook_checks_and_commits() {
850 let dir = tempdir().expect("failed to create temp dir");
852 setup_test_git_repo(dir.path());
853 fs::write(dir.path().join("README.md"), "updated repo")
854 .expect("failed to update tracked file");
855 let client = RealGitClient;
856
857 client
859 .check_pre_commit_hook_ready(dir.path().to_path_buf())
860 .await
861 .expect("repository without hook configuration should be ready");
862 client
863 .run_pre_commit_hook(dir.path().to_path_buf())
864 .await
865 .expect("missing pre-commit hook should be accepted");
866 client
867 .commit_all(
868 dir.path().to_path_buf(),
869 "Update repository documentation".to_string(),
870 )
871 .await
872 .expect("real git client should commit changes");
873
874 assert_eq!(
876 run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%s"]),
877 "Update repository documentation"
878 );
879 }
880
881 #[tokio::test]
882 async fn test_real_git_client_detects_merge_conflicts() {
883 let dir = tempdir().expect("failed to create temp dir");
885 setup_test_git_repo(dir.path());
886 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
887 fs::write(dir.path().join("README.md"), "session content")
888 .expect("failed to write session content");
889 run_git_command(dir.path(), &["add", "README.md"]);
890 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
891 run_git_command(dir.path(), &["checkout", "main"]);
892 fs::write(dir.path().join("README.md"), "main content")
893 .expect("failed to write main content");
894 run_git_command(dir.path(), &["add", "README.md"]);
895 run_git_command(dir.path(), &["commit", "-m", "Main change"]);
896 let client = RealGitClient;
897
898 let has_conflicts = client
900 .has_merge_conflicts(
901 dir.path().to_path_buf(),
902 "session-branch".to_string(),
903 "main".to_string(),
904 )
905 .await
906 .expect("merge conflict query should succeed");
907
908 assert!(has_conflicts);
910 }
911
912 #[tokio::test]
913 async fn test_real_git_client_reads_worktree_file() {
914 let dir = tempdir().expect("failed to create temp dir");
916 fs::write(dir.path().join("README.md"), "# Preview")
917 .expect("failed to write markdown file");
918 let client = RealGitClient;
919
920 let result = client
922 .read_worktree_file(dir.path().to_path_buf(), "README.md".to_string())
923 .await
924 .expect("failed to read worktree file");
925
926 assert_eq!(result, WorktreeFileContent::Text("# Preview".to_string()));
928 }
929
930 #[tokio::test]
931 async fn test_real_git_client_lists_changed_files() {
932 let dir = tempdir().expect("failed to create temp dir");
934 setup_test_git_repo(dir.path());
935 fs::write(dir.path().join("new.txt"), "new content").expect("failed to write changed file");
936 let client = RealGitClient;
937
938 let changed_files = client
940 .diff_changed_files(dir.path().to_path_buf(), "main".to_string())
941 .await
942 .expect("failed to list changed files");
943
944 assert_eq!(changed_files, vec!["new.txt".to_string()]);
946 }
947
948 #[tokio::test]
949 async fn test_real_git_client_pushes_new_remote_branch() {
950 let repo_dir = tempdir().expect("failed to create temp dir");
952 let remote_dir = tempdir().expect("failed to create remote temp dir");
953 setup_test_git_repo(repo_dir.path());
954 run_git_command(remote_dir.path(), &["init", "--bare"]);
955 let remote_path = remote_dir.path().to_string_lossy().to_string();
956 run_git_command(repo_dir.path(), &["remote", "add", "origin", &remote_path]);
957 let client = RealGitClient;
958
959 let upstream_reference = client
961 .push_current_branch_to_new_remote_branch(
962 repo_dir.path().to_path_buf(),
963 "review/new-branch".to_string(),
964 )
965 .await
966 .expect("new remote branch push should succeed");
967 let local_head = run_git_command_stdout(repo_dir.path(), &["rev-parse", "HEAD"]);
968 let remote_head = run_git_command_stdout(
969 remote_dir.path(),
970 &["rev-parse", "refs/heads/review/new-branch"],
971 );
972
973 assert_eq!(upstream_reference, "origin/review/new-branch");
975 assert_eq!(local_head, remote_head);
976 }
977
978 #[tokio::test]
979 async fn test_squash_merge_returns_committed_when_changes_exist() {
980 let dir = tempdir().expect("failed to create temp dir");
982 setup_test_git_repo(dir.path());
983 run_git_command(dir.path(), &["checkout", "-b", "feature-branch"]);
984 fs::write(dir.path().join("feature.txt"), "feature content").expect("failed to write file");
985 run_git_command(dir.path(), &["add", "feature.txt"]);
986 run_git_command(dir.path(), &["commit", "-m", "Add feature"]);
987 run_git_command(dir.path(), &["checkout", "main"]);
988
989 let result = squash_merge(
991 dir.path().to_path_buf(),
992 "feature-branch".to_string(),
993 "main".to_string(),
994 "Squash merge feature".to_string(),
995 )
996 .await;
997
998 assert_eq!(
1000 result.expect("squash merge should succeed"),
1001 SquashMergeOutcome::Committed,
1002 );
1003 }
1004
1005 #[tokio::test]
1006 async fn test_squash_merge_returns_already_present_when_changes_exist_in_target() {
1007 let dir = tempdir().expect("failed to create temp dir");
1009 setup_test_git_repo(dir.path());
1010 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1011 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1012 run_git_command(dir.path(), &["add", "session.txt"]);
1013 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1014 run_git_command(dir.path(), &["checkout", "main"]);
1015 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1016 run_git_command(dir.path(), &["add", "session.txt"]);
1017 run_git_command(dir.path(), &["commit", "-m", "Apply same change on main"]);
1018
1019 let result = squash_merge(
1021 dir.path().to_path_buf(),
1022 "session-branch".to_string(),
1023 "main".to_string(),
1024 "Merge session".to_string(),
1025 )
1026 .await;
1027
1028 assert_eq!(
1030 result.expect("squash merge should succeed"),
1031 SquashMergeOutcome::AlreadyPresentInTarget,
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn test_commit_all_preserving_single_commit_creates_first_commit() {
1037 let dir = tempdir().expect("failed to create temp dir");
1039 setup_test_git_repo(dir.path());
1040 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1041 let commit_message = "Session commit".to_string();
1042 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1043
1044 let result = commit_all_preserving_single_commit(
1046 dir.path().to_path_buf(),
1047 "main".to_string(),
1048 commit_message.clone(),
1049 SingleCommitMessageStrategy::Replace,
1050 )
1051 .await;
1052 let commit_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1053 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1054
1055 assert!(
1057 result.is_ok(),
1058 "commit_all_preserving_single_commit should succeed: {result:?}"
1059 );
1060 assert_eq!(commit_count, "2");
1061 assert_eq!(head_message, commit_message);
1062 }
1063
1064 #[tokio::test]
1065 async fn test_commit_all_preserving_single_commit_amends_existing_session_commit() {
1066 let dir = tempdir().expect("failed to create temp dir");
1068 setup_test_git_repo(dir.path());
1069 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1070 let commit_message = "Session commit".to_string();
1071 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1072 commit_all_preserving_single_commit(
1073 dir.path().to_path_buf(),
1074 "main".to_string(),
1075 commit_message.clone(),
1076 SingleCommitMessageStrategy::Replace,
1077 )
1078 .await
1079 .expect("failed to create first session commit");
1080 let first_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1081 let first_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1082
1083 fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1085 let result = commit_all_preserving_single_commit(
1086 dir.path().to_path_buf(),
1087 "main".to_string(),
1088 commit_message.clone(),
1089 SingleCommitMessageStrategy::Replace,
1090 )
1091 .await;
1092 let second_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1093 let second_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1094
1095 assert!(result.is_ok(), "amend commit should succeed: {result:?}");
1097 assert_ne!(first_hash, second_hash);
1098 assert_eq!(first_count, second_count);
1099 }
1100
1101 #[tokio::test]
1102 async fn test_commit_all_preserving_single_commit_replaces_amended_message() {
1103 let dir = tempdir().expect("failed to create temp dir");
1105 setup_test_git_repo(dir.path());
1106 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1107 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1108 commit_all_preserving_single_commit(
1109 dir.path().to_path_buf(),
1110 "main".to_string(),
1111 "First session message".to_string(),
1112 SingleCommitMessageStrategy::Replace,
1113 )
1114 .await
1115 .expect("failed to create first session commit");
1116
1117 fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1119 let result = commit_all_preserving_single_commit(
1120 dir.path().to_path_buf(),
1121 "main".to_string(),
1122 "Refined session message".to_string(),
1123 SingleCommitMessageStrategy::Replace,
1124 )
1125 .await;
1126 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1127
1128 assert!(
1130 result.is_ok(),
1131 "replace amended message should succeed: {result:?}"
1132 );
1133 assert_eq!(head_message, "Refined session message");
1134 }
1135
1136 #[tokio::test]
1137 async fn test_commit_all_preserving_single_commit_retries_index_lock_and_succeeds() {
1138 let dir = tempdir().expect("failed to create temp dir");
1140 setup_test_git_repo(dir.path());
1141 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1142 let commit_message = "Session commit".to_string();
1143 fs::write(dir.path().join("work.txt"), "locked change").expect("failed to write file");
1144 let index_lock_path = dir.path().join(".git").join("index.lock");
1145 fs::write(&index_lock_path, "active writer").expect("failed to write lock file");
1146 let lock_cleanup = tokio::spawn(async move {
1147 tokio::time::sleep(Duration::from_secs(1)).await;
1148 fs::remove_file(index_lock_path).expect("writer should release its lock");
1149 });
1150
1151 let result = commit_all_preserving_single_commit(
1153 dir.path().to_path_buf(),
1154 "main".to_string(),
1155 commit_message.clone(),
1156 SingleCommitMessageStrategy::Replace,
1157 )
1158 .await;
1159 lock_cleanup
1160 .await
1161 .expect("failed to join lock cleanup task");
1162 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1163
1164 assert!(
1166 result.is_ok(),
1167 "retry with index lock should succeed: {result:?}"
1168 );
1169 assert_eq!(head_message, commit_message);
1170 }
1171
1172 #[tokio::test]
1173 async fn test_diff_hides_leading_squash_merged_commit_for_non_rebased_session() {
1174 let dir = tempdir().expect("failed to create temp dir");
1176 setup_test_git_repo(dir.path());
1177 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1178 fs::write(dir.path().join("merged.txt"), "already merged change")
1179 .expect("failed to write merged file");
1180 run_git_command(dir.path(), &["add", "merged.txt"]);
1181 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1182 run_git_command(dir.path(), &["checkout", "main"]);
1183 run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1184 run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1185 run_git_command(dir.path(), &["checkout", "session-branch"]);
1186
1187 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1189 .await
1190 .expect("failed to load diff");
1191
1192 assert!(
1194 diff_output.trim().is_empty(),
1195 "expected no diff, got: {diff_output}"
1196 );
1197 }
1198
1199 #[tokio::test]
1200 async fn test_diff_keeps_new_commits_after_leading_squash_merged_commit() {
1201 let dir = tempdir().expect("failed to create temp dir");
1203 setup_test_git_repo(dir.path());
1204 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1205 fs::write(dir.path().join("merged.txt"), "already merged change")
1206 .expect("failed to write merged file");
1207 run_git_command(dir.path(), &["add", "merged.txt"]);
1208 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1209 run_git_command(dir.path(), &["checkout", "main"]);
1210 run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1211 run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1212 run_git_command(dir.path(), &["checkout", "session-branch"]);
1213 fs::write(dir.path().join("new.txt"), "new session-only change")
1214 .expect("failed to write new file");
1215 run_git_command(dir.path(), &["add", "new.txt"]);
1216 run_git_command(dir.path(), &["commit", "-m", "New session change"]);
1217
1218 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1220 .await
1221 .expect("failed to load diff");
1222
1223 assert!(diff_output.contains("new.txt"));
1225 assert!(!diff_output.contains("merged.txt"));
1226 }
1227
1228 #[tokio::test]
1229 async fn test_diff_does_not_include_base_only_commits() {
1230 let dir = tempdir().expect("failed to create temp dir");
1232 setup_test_git_repo(dir.path());
1233 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1234 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1235 run_git_command(dir.path(), &["add", "session.txt"]);
1236 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1237 run_git_command(dir.path(), &["checkout", "main"]);
1238 fs::write(dir.path().join("main-only.txt"), "base branch only")
1239 .expect("failed to write base-only file");
1240 run_git_command(dir.path(), &["add", "main-only.txt"]);
1241 run_git_command(dir.path(), &["commit", "-m", "Main branch change"]);
1242 run_git_command(dir.path(), &["checkout", "session-branch"]);
1243
1244 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1246 .await
1247 .expect("failed to load diff");
1248
1249 assert!(diff_output.contains("session.txt"));
1251 assert!(!diff_output.contains("main-only.txt"));
1252 }
1253
1254 #[tokio::test]
1255 async fn test_is_worktree_clean_returns_true_for_clean_repo() {
1256 let dir = tempdir().expect("failed to create temp dir");
1258 setup_test_git_repo(dir.path());
1259
1260 let is_clean = is_worktree_clean(dir.path().to_path_buf())
1262 .await
1263 .expect("failed to check worktree cleanliness");
1264
1265 assert!(is_clean);
1267 }
1268
1269 #[tokio::test]
1270 async fn test_is_worktree_clean_returns_false_for_dirty_repo() {
1271 let dir = tempdir().expect("failed to create temp dir");
1273 setup_test_git_repo(dir.path());
1274 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1275
1276 let is_clean = is_worktree_clean(dir.path().to_path_buf())
1278 .await
1279 .expect("failed to check worktree cleanliness");
1280
1281 assert!(!is_clean);
1283 }
1284
1285 #[tokio::test]
1286 async fn test_worktree_status_reports_dirty_repo_paths() {
1287 let dir = tempdir().expect("failed to create temp dir");
1289 setup_test_git_repo(dir.path());
1290 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1291 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1292
1293 let status = worktree_status(dir.path().to_path_buf())
1295 .await
1296 .expect("failed to read worktree status");
1297
1298 assert!(status.contains("README.md"));
1300 assert!(status.contains("new-file.txt"));
1301 }
1302
1303 #[tokio::test]
1304 async fn test_status_reads_preserve_index_with_stale_file_metadata() {
1305 let dir = tempdir().expect("failed to create temp dir");
1307 setup_test_git_repo(dir.path());
1308 let index_path = dir.path().join(".git/index");
1309 let original_index = fs::read(&index_path).expect("failed to read index");
1310 let readme = fs::File::options()
1311 .write(true)
1312 .open(dir.path().join("README.md"))
1313 .expect("failed to open tracked file");
1314 readme
1315 .set_times(fs::FileTimes::new().set_modified(std::time::SystemTime::UNIX_EPOCH))
1316 .expect("failed to invalidate cached file metadata");
1317
1318 let status = worktree_status(dir.path().to_path_buf())
1320 .await
1321 .expect("failed to read worktree status");
1322 let tracked_status = tracked_worktree_status(dir.path().to_path_buf())
1323 .await
1324 .expect("failed to read tracked status");
1325 let sync_status = crate::repo::run_git_command_sync(
1326 dir.path(),
1327 &["status", "--porcelain"],
1328 "Failed to read synchronous status",
1329 )
1330 .expect("failed to read synchronous status");
1331
1332 assert_eq!(status, "");
1334 assert_eq!(tracked_status, "");
1335 assert_eq!(sync_status, "");
1336 assert_eq!(
1337 fs::read(&index_path).expect("failed to read index"),
1338 original_index
1339 );
1340 let refresh = Command::new("git")
1343 .args(["status", "--porcelain"])
1344 .env("GIT_OPTIONAL_LOCKS", "1")
1345 .current_dir(dir.path())
1346 .output()
1347 .expect("failed to refresh index");
1348 assert!(refresh.status.success());
1349 assert_ne!(
1350 fs::read(index_path).expect("failed to read refreshed index"),
1351 original_index
1352 );
1353 }
1354
1355 #[tokio::test]
1356 async fn test_tracked_worktree_status_ignores_untracked_repo_paths() {
1357 let dir = tempdir().expect("failed to create temp dir");
1359 setup_test_git_repo(dir.path());
1360 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1361 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1362
1363 let status = tracked_worktree_status(dir.path().to_path_buf())
1365 .await
1366 .expect("failed to read tracked worktree status");
1367
1368 assert!(status.contains("README.md"));
1370 assert!(!status.contains("new-file.txt"));
1371 }
1372
1373 #[tokio::test]
1374 async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1375 let dir = tempdir().expect("failed to create temp dir");
1377 setup_test_git_repo(dir.path());
1378
1379 let repo_root = main_repo_root(dir.path().to_path_buf())
1381 .await
1382 .expect("failed to resolve main repo root");
1383
1384 assert_eq!(
1386 canonicalize_test_path(&repo_root),
1387 canonicalize_test_path(dir.path())
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1393 let dir = tempdir().expect("failed to create temp dir");
1395 setup_test_git_repo(dir.path());
1396 let linked_worktree = dir.path().join("linked-worktree");
1397 create_worktree(
1398 dir.path().to_path_buf(),
1399 linked_worktree.clone(),
1400 "wt/main-repo-root-test".to_string(),
1401 "main".to_string(),
1402 )
1403 .await
1404 .expect("failed to create linked worktree");
1405
1406 let repo_root = main_repo_root(linked_worktree)
1408 .await
1409 .expect("failed to resolve shared repo root");
1410
1411 assert_eq!(
1413 canonicalize_test_path(&repo_root),
1414 canonicalize_test_path(dir.path())
1415 );
1416 }
1417
1418 #[tokio::test]
1419 async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1420 let dir = tempdir().expect("failed to create temp dir");
1422 setup_test_git_repo(dir.path());
1423
1424 let result = abort_rebase(dir.path().to_path_buf()).await;
1426
1427 assert!(result.is_err());
1429 }
1430
1431 #[tokio::test]
1432 async fn test_ref_hash_resolves_branch_head() {
1433 let dir = tempdir().expect("failed to create temp dir");
1435 setup_test_git_repo(dir.path());
1436 let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1437
1438 let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1440 .await
1441 .expect("failed to resolve main hash");
1442
1443 assert_eq!(resolved_hash, expected_hash);
1445 }
1446
1447 #[tokio::test]
1448 async fn test_rebase_onto_start_replays_commits_after_old_base() {
1449 let dir = tempdir().expect("failed to create temp dir");
1451 setup_test_git_repo(dir.path());
1452 run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1453 fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1454 run_git_command(dir.path(), &["add", "parent.txt"]);
1455 run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1456 let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1457 run_git_command(dir.path(), &["checkout", "-b", "child"]);
1458 fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1459 run_git_command(dir.path(), &["add", "child.txt"]);
1460 run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1461 run_git_command(dir.path(), &["checkout", "main"]);
1462 fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1463 run_git_command(dir.path(), &["add", "main.txt"]);
1464 run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1465 run_git_command(dir.path(), &["checkout", "child"]);
1466
1467 let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1469 .await
1470 .expect("failed to start rebase --onto");
1471 let child_only_subjects = run_git_command_stdout(
1472 dir.path(),
1473 &["log", "--format=%s", "--reverse", "main..HEAD"],
1474 );
1475
1476 assert_eq!(result, RebaseStepResult::Completed);
1478 assert_eq!(child_only_subjects, "Child change");
1479 assert!(!dir.path().join("parent.txt").exists());
1480 assert!(dir.path().join("child.txt").exists());
1481 }
1482
1483 #[tokio::test]
1484 async fn test_pull_rebase_returns_error_without_upstream() {
1485 let dir = tempdir().expect("failed to create temp dir");
1487 setup_test_git_repo(dir.path());
1488
1489 let result = pull_rebase(dir.path().to_path_buf()).await;
1491
1492 assert!(result.is_err());
1494 }
1495
1496 #[tokio::test]
1497 async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1498 let dir = tempdir().expect("failed to create temp dir");
1500 let remote_dir = tempdir().expect("failed to create remote temp dir");
1501 setup_test_git_repo(dir.path());
1502 run_git_command(remote_dir.path(), &["init", "--bare"]);
1503
1504 let remote_path = remote_dir.path().to_string_lossy().to_string();
1505 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1506 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1507
1508 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1509 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1510 run_git_command(dir.path(), &["add", "feature.txt"]);
1511 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1512 run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1513 run_git_command(dir.path(), &["checkout", "main"]);
1514
1515 run_git_command(
1516 dir.path(),
1517 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1518 );
1519
1520 let pull_without_explicit_target = Command::new("git")
1521 .args(["pull", "--rebase"])
1522 .current_dir(dir.path())
1523 .output()
1524 .expect("failed to run pull --rebase");
1525
1526 assert!(
1527 !pull_without_explicit_target.status.success(),
1528 "expected plain pull --rebase to fail in ambiguous merge-target setup"
1529 );
1530 assert!(
1531 String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1532 .contains("Cannot rebase onto multiple branches"),
1533 "expected ambiguous merge-target failure"
1534 );
1535
1536 let result = pull_rebase(dir.path().to_path_buf()).await;
1538
1539 assert!(
1541 matches!(result, Ok(PullRebaseResult::Completed)),
1542 "pull_rebase should complete: {result:?}"
1543 );
1544 }
1545
1546 #[tokio::test]
1547 async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1548 let dir = tempdir().expect("failed to create temp dir");
1550 setup_test_git_repo(dir.path());
1551
1552 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1553 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1554 run_git_command(dir.path(), &["add", "feature.txt"]);
1555 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1556 run_git_command(dir.path(), &["checkout", "main"]);
1557
1558 run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1559 run_git_command(
1560 dir.path(),
1561 &[
1562 "config",
1563 "--replace-all",
1564 "branch.main.merge",
1565 "refs/heads/main",
1566 ],
1567 );
1568 run_git_command(
1569 dir.path(),
1570 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1571 );
1572
1573 let pull_without_explicit_target = Command::new("git")
1574 .args(["pull", "--rebase"])
1575 .current_dir(dir.path())
1576 .output()
1577 .expect("failed to run pull --rebase");
1578
1579 assert!(
1580 !pull_without_explicit_target.status.success(),
1581 "expected plain pull --rebase to fail in ambiguous merge-target setup"
1582 );
1583 assert!(
1584 String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1585 .contains("Cannot rebase onto multiple branches"),
1586 "expected ambiguous merge-target failure"
1587 );
1588
1589 let result = pull_rebase(dir.path().to_path_buf()).await;
1591
1592 assert!(
1594 matches!(result, Ok(PullRebaseResult::Completed)),
1595 "pull_rebase with local upstream should complete: {result:?}"
1596 );
1597 }
1598
1599 #[tokio::test]
1600 async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1601 let dir = tempdir().expect("failed to create temp dir");
1603 setup_test_git_repo(dir.path());
1604
1605 let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1607
1608 assert!(result.is_err());
1610 }
1611
1612 #[tokio::test]
1613 async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1614 let dir = tempdir().expect("failed to create temp dir");
1616 let remote_dir = tempdir().expect("failed to create remote temp dir");
1617 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1618 let contributor_clone_path = contributor_dir.path().join("clone");
1619 setup_test_git_repo(dir.path());
1620 run_git_command(remote_dir.path(), &["init", "--bare"]);
1621
1622 let remote_path = remote_dir.path().to_string_lossy().to_string();
1623 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1624 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1625 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1626
1627 run_git_command(
1628 contributor_dir.path(),
1629 &["clone", &remote_path, &contributor_clone_path_text],
1630 );
1631 run_git_command(
1632 &contributor_clone_path,
1633 &["config", "user.name", "Contributor User"],
1634 );
1635 run_git_command(
1636 &contributor_clone_path,
1637 &["config", "user.email", "contributor@example.com"],
1638 );
1639 run_git_command(
1640 &contributor_clone_path,
1641 &["checkout", "-B", "main", "origin/main"],
1642 );
1643 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1644 .expect("failed to write remote change");
1645 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1646 run_git_command(
1647 &contributor_clone_path,
1648 &["commit", "-m", "Remote commit title"],
1649 );
1650 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1651 run_git_command(dir.path(), &["fetch", "origin"]);
1652
1653 let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1655 .await
1656 .expect("failed to list upstream commit titles");
1657
1658 assert_eq!(titles, vec!["Remote commit title".to_string()]);
1660 }
1661
1662 #[tokio::test]
1663 async fn test_list_local_commit_titles_returns_error_without_upstream() {
1664 let dir = tempdir().expect("failed to create temp dir");
1666 setup_test_git_repo(dir.path());
1667
1668 let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1670
1671 assert!(result.is_err());
1673 }
1674
1675 #[tokio::test]
1676 async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1677 let dir = tempdir().expect("failed to create temp dir");
1679 let remote_dir = tempdir().expect("failed to create remote temp dir");
1680 setup_test_git_repo(dir.path());
1681 run_git_command(remote_dir.path(), &["init", "--bare"]);
1682
1683 let remote_path = remote_dir.path().to_string_lossy().to_string();
1684 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1685 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1686
1687 fs::write(dir.path().join("local_1.txt"), "local change 1")
1688 .expect("failed to write local change 1");
1689 run_git_command(dir.path(), &["add", "local_1.txt"]);
1690 run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1691
1692 fs::write(dir.path().join("local_2.txt"), "local change 2")
1693 .expect("failed to write local change 2");
1694 run_git_command(dir.path(), &["add", "local_2.txt"]);
1695 run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1696
1697 let titles = list_local_commit_titles(dir.path().to_path_buf())
1699 .await
1700 .expect("failed to list local commit titles");
1701
1702 assert_eq!(
1704 titles,
1705 vec![
1706 "Local commit title one".to_string(),
1707 "Local commit title two".to_string(),
1708 ]
1709 );
1710 }
1711
1712 #[tokio::test]
1713 async fn test_push_current_branch_returns_error_without_remote() {
1714 let dir = tempdir().expect("failed to create temp dir");
1716 setup_test_git_repo(dir.path());
1717
1718 let result = push_current_branch(dir.path().to_path_buf()).await;
1720
1721 assert!(result.is_err());
1723 }
1724
1725 #[tokio::test]
1726 async fn test_push_current_branch_returns_upstream_reference() {
1727 let dir = tempdir().expect("failed to create temp dir");
1729 let remote_dir = tempdir().expect("failed to create remote temp dir");
1730 setup_test_git_repo(dir.path());
1731 run_git_command(remote_dir.path(), &["init", "--bare"]);
1732 let remote_path = remote_dir.path().to_string_lossy().to_string();
1733 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1734
1735 let upstream_reference = push_current_branch(dir.path().to_path_buf())
1737 .await
1738 .expect("push should set upstream");
1739
1740 assert_eq!(upstream_reference, "origin/main");
1742 }
1743
1744 #[tokio::test]
1745 async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1746 let dir = tempdir().expect("failed to create temp dir");
1748 let remote_dir = tempdir().expect("failed to create remote temp dir");
1749 setup_test_git_repo(dir.path());
1750 run_git_command(remote_dir.path(), &["init", "--bare"]);
1751 let remote_path = remote_dir.path().to_string_lossy().to_string();
1752 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1753
1754 let upstream_reference = push_current_branch_to_remote_branch(
1756 dir.path().to_path_buf(),
1757 "review/custom-branch".to_string(),
1758 )
1759 .await
1760 .expect("push should set a custom upstream");
1761
1762 assert_eq!(upstream_reference, "origin/review/custom-branch");
1764 }
1765
1766 #[test]
1767 fn test_is_no_upstream_error_detects_upstream_hint() {
1768 let detail = "fatal: The current branch main has no upstream branch.";
1770
1771 let is_no_upstream = sync::is_no_upstream_error(detail);
1773
1774 assert!(is_no_upstream);
1776 }
1777
1778 #[test]
1779 fn test_is_rebase_conflict_detects_conflict_keyword() {
1780 let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1782
1783 assert!(rebase::is_rebase_conflict(detail));
1785 }
1786
1787 #[test]
1788 fn test_is_rebase_conflict_detects_could_not_apply() {
1789 let detail = "error: could not apply abc1234... Update handler";
1791
1792 assert!(rebase::is_rebase_conflict(detail));
1794 }
1795
1796 #[test]
1797 fn test_is_rebase_conflict_detects_mark_as_resolved() {
1798 let detail = "hint: mark them as resolved using git add";
1800
1801 assert!(rebase::is_rebase_conflict(detail));
1803 }
1804
1805 #[test]
1806 fn test_is_rebase_conflict_detects_unresolved_conflict() {
1807 let detail = "fatal: Exiting because of an unresolved conflict.";
1809
1810 assert!(rebase::is_rebase_conflict(detail));
1812 }
1813
1814 #[test]
1815 fn test_is_rebase_conflict_detects_committing_not_possible() {
1816 let detail = "error: Committing is not possible because you have unmerged files.";
1818
1819 assert!(rebase::is_rebase_conflict(detail));
1821 }
1822
1823 #[test]
1824 fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1825 let detail = "fatal: not a git repository (or any parent up to mount point /)";
1827
1828 assert!(!rebase::is_rebase_conflict(detail));
1830 }
1831
1832 #[test]
1833 fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1834 let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1836
1837 assert!(!rebase::is_rebase_conflict(detail));
1839 }
1840}