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::path::{Path, PathBuf};
791 use std::process::Command;
792 use std::time::Duration;
793 use std::{fs, thread};
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, "stale lock").expect("failed to write lock file");
1145 let lock_cleanup = thread::spawn(move || {
1146 thread::sleep(Duration::from_millis(250));
1147 let _ = fs::remove_file(index_lock_path);
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 .join()
1160 .expect("failed to join lock cleanup thread");
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_tracked_worktree_status_ignores_untracked_repo_paths() {
1304 let dir = tempdir().expect("failed to create temp dir");
1306 setup_test_git_repo(dir.path());
1307 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1308 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1309
1310 let status = tracked_worktree_status(dir.path().to_path_buf())
1312 .await
1313 .expect("failed to read tracked worktree status");
1314
1315 assert!(status.contains("README.md"));
1317 assert!(!status.contains("new-file.txt"));
1318 }
1319
1320 #[tokio::test]
1321 async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1322 let dir = tempdir().expect("failed to create temp dir");
1324 setup_test_git_repo(dir.path());
1325
1326 let repo_root = main_repo_root(dir.path().to_path_buf())
1328 .await
1329 .expect("failed to resolve main repo root");
1330
1331 assert_eq!(
1333 canonicalize_test_path(&repo_root),
1334 canonicalize_test_path(dir.path())
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1340 let dir = tempdir().expect("failed to create temp dir");
1342 setup_test_git_repo(dir.path());
1343 let linked_worktree = dir.path().join("linked-worktree");
1344 create_worktree(
1345 dir.path().to_path_buf(),
1346 linked_worktree.clone(),
1347 "wt/main-repo-root-test".to_string(),
1348 "main".to_string(),
1349 )
1350 .await
1351 .expect("failed to create linked worktree");
1352
1353 let repo_root = main_repo_root(linked_worktree)
1355 .await
1356 .expect("failed to resolve shared repo root");
1357
1358 assert_eq!(
1360 canonicalize_test_path(&repo_root),
1361 canonicalize_test_path(dir.path())
1362 );
1363 }
1364
1365 #[tokio::test]
1366 async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1367 let dir = tempdir().expect("failed to create temp dir");
1369 setup_test_git_repo(dir.path());
1370
1371 let result = abort_rebase(dir.path().to_path_buf()).await;
1373
1374 assert!(result.is_err());
1376 }
1377
1378 #[tokio::test]
1379 async fn test_ref_hash_resolves_branch_head() {
1380 let dir = tempdir().expect("failed to create temp dir");
1382 setup_test_git_repo(dir.path());
1383 let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1384
1385 let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1387 .await
1388 .expect("failed to resolve main hash");
1389
1390 assert_eq!(resolved_hash, expected_hash);
1392 }
1393
1394 #[tokio::test]
1395 async fn test_rebase_onto_start_replays_commits_after_old_base() {
1396 let dir = tempdir().expect("failed to create temp dir");
1398 setup_test_git_repo(dir.path());
1399 run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1400 fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1401 run_git_command(dir.path(), &["add", "parent.txt"]);
1402 run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1403 let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1404 run_git_command(dir.path(), &["checkout", "-b", "child"]);
1405 fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1406 run_git_command(dir.path(), &["add", "child.txt"]);
1407 run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1408 run_git_command(dir.path(), &["checkout", "main"]);
1409 fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1410 run_git_command(dir.path(), &["add", "main.txt"]);
1411 run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1412 run_git_command(dir.path(), &["checkout", "child"]);
1413
1414 let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1416 .await
1417 .expect("failed to start rebase --onto");
1418 let child_only_subjects = run_git_command_stdout(
1419 dir.path(),
1420 &["log", "--format=%s", "--reverse", "main..HEAD"],
1421 );
1422
1423 assert_eq!(result, RebaseStepResult::Completed);
1425 assert_eq!(child_only_subjects, "Child change");
1426 assert!(!dir.path().join("parent.txt").exists());
1427 assert!(dir.path().join("child.txt").exists());
1428 }
1429
1430 #[tokio::test]
1431 async fn test_pull_rebase_returns_error_without_upstream() {
1432 let dir = tempdir().expect("failed to create temp dir");
1434 setup_test_git_repo(dir.path());
1435
1436 let result = pull_rebase(dir.path().to_path_buf()).await;
1438
1439 assert!(result.is_err());
1441 }
1442
1443 #[tokio::test]
1444 async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1445 let dir = tempdir().expect("failed to create temp dir");
1447 let remote_dir = tempdir().expect("failed to create remote temp dir");
1448 setup_test_git_repo(dir.path());
1449 run_git_command(remote_dir.path(), &["init", "--bare"]);
1450
1451 let remote_path = remote_dir.path().to_string_lossy().to_string();
1452 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1453 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1454
1455 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1456 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1457 run_git_command(dir.path(), &["add", "feature.txt"]);
1458 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1459 run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1460 run_git_command(dir.path(), &["checkout", "main"]);
1461
1462 run_git_command(
1463 dir.path(),
1464 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1465 );
1466
1467 let pull_without_explicit_target = Command::new("git")
1468 .args(["pull", "--rebase"])
1469 .current_dir(dir.path())
1470 .output()
1471 .expect("failed to run pull --rebase");
1472
1473 assert!(
1474 !pull_without_explicit_target.status.success(),
1475 "expected plain pull --rebase to fail in ambiguous merge-target setup"
1476 );
1477 assert!(
1478 String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1479 .contains("Cannot rebase onto multiple branches"),
1480 "expected ambiguous merge-target failure"
1481 );
1482
1483 let result = pull_rebase(dir.path().to_path_buf()).await;
1485
1486 assert!(
1488 matches!(result, Ok(PullRebaseResult::Completed)),
1489 "pull_rebase should complete: {result:?}"
1490 );
1491 }
1492
1493 #[tokio::test]
1494 async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1495 let dir = tempdir().expect("failed to create temp dir");
1497 setup_test_git_repo(dir.path());
1498
1499 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1500 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1501 run_git_command(dir.path(), &["add", "feature.txt"]);
1502 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1503 run_git_command(dir.path(), &["checkout", "main"]);
1504
1505 run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1506 run_git_command(
1507 dir.path(),
1508 &[
1509 "config",
1510 "--replace-all",
1511 "branch.main.merge",
1512 "refs/heads/main",
1513 ],
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 with local upstream should complete: {result:?}"
1543 );
1544 }
1545
1546 #[tokio::test]
1547 async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1548 let dir = tempdir().expect("failed to create temp dir");
1550 setup_test_git_repo(dir.path());
1551
1552 let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1554
1555 assert!(result.is_err());
1557 }
1558
1559 #[tokio::test]
1560 async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1561 let dir = tempdir().expect("failed to create temp dir");
1563 let remote_dir = tempdir().expect("failed to create remote temp dir");
1564 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1565 let contributor_clone_path = contributor_dir.path().join("clone");
1566 setup_test_git_repo(dir.path());
1567 run_git_command(remote_dir.path(), &["init", "--bare"]);
1568
1569 let remote_path = remote_dir.path().to_string_lossy().to_string();
1570 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1571 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1572 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1573
1574 run_git_command(
1575 contributor_dir.path(),
1576 &["clone", &remote_path, &contributor_clone_path_text],
1577 );
1578 run_git_command(
1579 &contributor_clone_path,
1580 &["config", "user.name", "Contributor User"],
1581 );
1582 run_git_command(
1583 &contributor_clone_path,
1584 &["config", "user.email", "contributor@example.com"],
1585 );
1586 run_git_command(
1587 &contributor_clone_path,
1588 &["checkout", "-B", "main", "origin/main"],
1589 );
1590 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1591 .expect("failed to write remote change");
1592 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1593 run_git_command(
1594 &contributor_clone_path,
1595 &["commit", "-m", "Remote commit title"],
1596 );
1597 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1598 run_git_command(dir.path(), &["fetch", "origin"]);
1599
1600 let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1602 .await
1603 .expect("failed to list upstream commit titles");
1604
1605 assert_eq!(titles, vec!["Remote commit title".to_string()]);
1607 }
1608
1609 #[tokio::test]
1610 async fn test_list_local_commit_titles_returns_error_without_upstream() {
1611 let dir = tempdir().expect("failed to create temp dir");
1613 setup_test_git_repo(dir.path());
1614
1615 let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1617
1618 assert!(result.is_err());
1620 }
1621
1622 #[tokio::test]
1623 async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1624 let dir = tempdir().expect("failed to create temp dir");
1626 let remote_dir = tempdir().expect("failed to create remote temp dir");
1627 setup_test_git_repo(dir.path());
1628 run_git_command(remote_dir.path(), &["init", "--bare"]);
1629
1630 let remote_path = remote_dir.path().to_string_lossy().to_string();
1631 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1632 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1633
1634 fs::write(dir.path().join("local_1.txt"), "local change 1")
1635 .expect("failed to write local change 1");
1636 run_git_command(dir.path(), &["add", "local_1.txt"]);
1637 run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1638
1639 fs::write(dir.path().join("local_2.txt"), "local change 2")
1640 .expect("failed to write local change 2");
1641 run_git_command(dir.path(), &["add", "local_2.txt"]);
1642 run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1643
1644 let titles = list_local_commit_titles(dir.path().to_path_buf())
1646 .await
1647 .expect("failed to list local commit titles");
1648
1649 assert_eq!(
1651 titles,
1652 vec![
1653 "Local commit title one".to_string(),
1654 "Local commit title two".to_string(),
1655 ]
1656 );
1657 }
1658
1659 #[tokio::test]
1660 async fn test_push_current_branch_returns_error_without_remote() {
1661 let dir = tempdir().expect("failed to create temp dir");
1663 setup_test_git_repo(dir.path());
1664
1665 let result = push_current_branch(dir.path().to_path_buf()).await;
1667
1668 assert!(result.is_err());
1670 }
1671
1672 #[tokio::test]
1673 async fn test_push_current_branch_returns_upstream_reference() {
1674 let dir = tempdir().expect("failed to create temp dir");
1676 let remote_dir = tempdir().expect("failed to create remote temp dir");
1677 setup_test_git_repo(dir.path());
1678 run_git_command(remote_dir.path(), &["init", "--bare"]);
1679 let remote_path = remote_dir.path().to_string_lossy().to_string();
1680 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1681
1682 let upstream_reference = push_current_branch(dir.path().to_path_buf())
1684 .await
1685 .expect("push should set upstream");
1686
1687 assert_eq!(upstream_reference, "origin/main");
1689 }
1690
1691 #[tokio::test]
1692 async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1693 let dir = tempdir().expect("failed to create temp dir");
1695 let remote_dir = tempdir().expect("failed to create remote temp dir");
1696 setup_test_git_repo(dir.path());
1697 run_git_command(remote_dir.path(), &["init", "--bare"]);
1698 let remote_path = remote_dir.path().to_string_lossy().to_string();
1699 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1700
1701 let upstream_reference = push_current_branch_to_remote_branch(
1703 dir.path().to_path_buf(),
1704 "review/custom-branch".to_string(),
1705 )
1706 .await
1707 .expect("push should set a custom upstream");
1708
1709 assert_eq!(upstream_reference, "origin/review/custom-branch");
1711 }
1712
1713 #[test]
1714 fn test_is_no_upstream_error_detects_upstream_hint() {
1715 let detail = "fatal: The current branch main has no upstream branch.";
1717
1718 let is_no_upstream = sync::is_no_upstream_error(detail);
1720
1721 assert!(is_no_upstream);
1723 }
1724
1725 #[test]
1726 fn test_is_rebase_conflict_detects_conflict_keyword() {
1727 let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1729
1730 assert!(rebase::is_rebase_conflict(detail));
1732 }
1733
1734 #[test]
1735 fn test_is_rebase_conflict_detects_could_not_apply() {
1736 let detail = "error: could not apply abc1234... Update handler";
1738
1739 assert!(rebase::is_rebase_conflict(detail));
1741 }
1742
1743 #[test]
1744 fn test_is_rebase_conflict_detects_mark_as_resolved() {
1745 let detail = "hint: mark them as resolved using git add";
1747
1748 assert!(rebase::is_rebase_conflict(detail));
1750 }
1751
1752 #[test]
1753 fn test_is_rebase_conflict_detects_unresolved_conflict() {
1754 let detail = "fatal: Exiting because of an unresolved conflict.";
1756
1757 assert!(rebase::is_rebase_conflict(detail));
1759 }
1760
1761 #[test]
1762 fn test_is_rebase_conflict_detects_committing_not_possible() {
1763 let detail = "error: Committing is not possible because you have unmerged files.";
1765
1766 assert!(rebase::is_rebase_conflict(detail));
1768 }
1769
1770 #[test]
1771 fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1772 let detail = "fatal: not a git repository (or any parent up to mount point /)";
1774
1775 assert!(!rebase::is_rebase_conflict(detail));
1777 }
1778
1779 #[test]
1780 fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1781 let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1783
1784 assert!(!rebase::is_rebase_conflict(detail));
1786 }
1787}