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