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_unmerged_paths,
18 head_commit_message, head_hash, head_short_hash, in_progress_operation, is_rebase_in_progress,
19 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_remote_branch, rebase,
22 rebase_continue, rebase_onto_start, rebase_start, ref_hash, remote_branch_exists,
23 remove_worktree, repo_url, squash_merge, squash_merge_diff, stage_all, sync,
24 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 create_worktree(
61 &self,
62 repo_path: PathBuf,
63 worktree_path: PathBuf,
64 branch_name: String,
65 start_ref: String,
66 ) -> GitFuture<Result<(), GitError>>;
67
68 fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>>;
74
75 fn squash_merge_diff(
81 &self,
82 repo_path: PathBuf,
83 source_branch: String,
84 target_branch: String,
85 ) -> GitFuture<Result<String, GitError>>;
86
87 fn squash_merge(
93 &self,
94 repo_path: PathBuf,
95 source_branch: String,
96 target_branch: String,
97 commit_message: String,
98 ) -> GitFuture<Result<SquashMergeOutcome, GitError>>;
99
100 fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>>;
105
106 fn rebase_start(
112 &self,
113 repo_path: PathBuf,
114 target_branch: String,
115 ) -> GitFuture<Result<RebaseStepResult, GitError>>;
116
117 fn rebase_onto_start(
122 &self,
123 repo_path: PathBuf,
124 new_base: String,
125 old_base: String,
126 ) -> GitFuture<Result<RebaseStepResult, GitError>>;
127
128 fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>>;
133
134 fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
139
140 fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
145
146 fn in_progress_operation(
151 &self,
152 repo_path: PathBuf,
153 ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>>;
154
155 fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>>;
160
161 fn list_staged_conflict_marker_files(
167 &self,
168 repo_path: PathBuf,
169 paths: Vec<String>,
170 ) -> GitFuture<Result<Vec<String>, GitError>>;
171
172 fn list_conflicted_files(&self, repo_path: PathBuf)
177 -> GitFuture<Result<Vec<String>, GitError>>;
178
179 fn commit_all(
186 &self,
187 repo_path: PathBuf,
188 message: String,
189 no_verify: bool,
190 ) -> GitFuture<Result<(), GitError>>;
191
192 fn commit_all_preserving_single_commit(
201 &self,
202 repo_path: PathBuf,
203 base_branch: String,
204 commit_message: String,
205 message_strategy: SingleCommitMessageStrategy,
206 no_verify: bool,
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 remote_branch_exists(
348 &self,
349 repo_path: PathBuf,
350 remote_branch_name: String,
351 ) -> GitFuture<Result<bool, GitError>>;
352
353 fn current_upstream_reference(&self, repo_path: PathBuf)
358 -> GitFuture<Result<String, GitError>>;
359
360 fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>>;
365
366 fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>>;
371
372 fn get_ref_ahead_behind(
380 &self,
381 repo_path: PathBuf,
382 left_ref: String,
383 right_ref: String,
384 ) -> GitFuture<Result<(u32, u32), GitError>>;
385
386 fn branch_tracking_statuses(
395 &self,
396 repo_path: PathBuf,
397 ) -> GitFuture<Result<BranchTrackingMap, GitError>>;
398
399 fn list_upstream_commit_titles(
406 &self,
407 repo_path: PathBuf,
408 ) -> GitFuture<Result<Vec<String>, GitError>>;
409
410 fn list_local_commit_titles(
416 &self,
417 repo_path: PathBuf,
418 ) -> GitFuture<Result<Vec<String>, GitError>>;
419
420 fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
425
426 fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>>;
431
432 fn main_checkout_working_tree(
440 &self,
441 repo_path: PathBuf,
442 ) -> GitFuture<Result<Option<PathBuf>, GitError>>;
443}
444
445pub struct RealGitClient;
447
448impl GitClient for RealGitClient {
449 fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>> {
450 Box::pin(async move { detect_git_info(dir).await })
451 }
452
453 fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>> {
454 Box::pin(async move { find_git_repo_root(dir).await })
455 }
456
457 fn check_pre_commit_hook_ready(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
458 Box::pin(async move { check_pre_commit_hook_ready(repo_path).await })
459 }
460
461 fn create_worktree(
462 &self,
463 repo_path: PathBuf,
464 worktree_path: PathBuf,
465 branch_name: String,
466 start_ref: String,
467 ) -> GitFuture<Result<(), GitError>> {
468 Box::pin(
469 async move { create_worktree(repo_path, worktree_path, branch_name, start_ref).await },
470 )
471 }
472
473 fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>> {
474 Box::pin(async move { remove_worktree(worktree_path).await })
475 }
476
477 fn squash_merge_diff(
478 &self,
479 repo_path: PathBuf,
480 source_branch: String,
481 target_branch: String,
482 ) -> GitFuture<Result<String, GitError>> {
483 Box::pin(async move { squash_merge_diff(repo_path, source_branch, target_branch).await })
484 }
485
486 fn squash_merge(
487 &self,
488 repo_path: PathBuf,
489 source_branch: String,
490 target_branch: String,
491 commit_message: String,
492 ) -> GitFuture<Result<SquashMergeOutcome, GitError>> {
493 Box::pin(async move {
494 squash_merge(repo_path, source_branch, target_branch, commit_message).await
495 })
496 }
497
498 fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>> {
499 Box::pin(async move { rebase::rebase(repo_path, target_branch).await })
500 }
501
502 fn rebase_start(
503 &self,
504 repo_path: PathBuf,
505 target_branch: String,
506 ) -> GitFuture<Result<RebaseStepResult, GitError>> {
507 Box::pin(async move { rebase_start(repo_path, target_branch).await })
508 }
509
510 fn rebase_onto_start(
511 &self,
512 repo_path: PathBuf,
513 new_base: String,
514 old_base: String,
515 ) -> GitFuture<Result<RebaseStepResult, GitError>> {
516 Box::pin(async move { rebase_onto_start(repo_path, new_base, old_base).await })
517 }
518
519 fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>> {
520 Box::pin(async move { rebase_continue(repo_path).await })
521 }
522
523 fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
524 Box::pin(async move { abort_rebase(repo_path).await })
525 }
526
527 fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
528 Box::pin(async move { is_rebase_in_progress(repo_path).await })
529 }
530
531 fn in_progress_operation(
532 &self,
533 repo_path: PathBuf,
534 ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>> {
535 Box::pin(async move { in_progress_operation(repo_path).await })
536 }
537
538 fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
539 Box::pin(async move { has_unmerged_paths(repo_path).await })
540 }
541
542 fn list_staged_conflict_marker_files(
543 &self,
544 repo_path: PathBuf,
545 paths: Vec<String>,
546 ) -> GitFuture<Result<Vec<String>, GitError>> {
547 Box::pin(async move { list_staged_conflict_marker_files(repo_path, paths).await })
548 }
549
550 fn list_conflicted_files(
551 &self,
552 repo_path: PathBuf,
553 ) -> GitFuture<Result<Vec<String>, GitError>> {
554 Box::pin(async move { list_conflicted_files(repo_path).await })
555 }
556
557 fn commit_all(
558 &self,
559 repo_path: PathBuf,
560 message: String,
561 no_verify: bool,
562 ) -> GitFuture<Result<(), GitError>> {
563 Box::pin(async move { commit_all(repo_path, message, no_verify).await })
564 }
565
566 fn commit_all_preserving_single_commit(
567 &self,
568 repo_path: PathBuf,
569 base_branch: String,
570 commit_message: String,
571 message_strategy: SingleCommitMessageStrategy,
572 no_verify: bool,
573 ) -> GitFuture<Result<(), GitError>> {
574 Box::pin(async move {
575 commit_all_preserving_single_commit(
576 repo_path,
577 base_branch,
578 commit_message,
579 message_strategy,
580 no_verify,
581 )
582 .await
583 })
584 }
585
586 fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
587 Box::pin(async move { stage_all(repo_path).await })
588 }
589
590 fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
591 Box::pin(async move { head_short_hash(repo_path).await })
592 }
593
594 fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
595 Box::pin(async move { head_hash(repo_path).await })
596 }
597
598 fn ref_hash(
599 &self,
600 repo_path: PathBuf,
601 reference: String,
602 ) -> GitFuture<Result<String, GitError>> {
603 Box::pin(async move { ref_hash(repo_path, reference).await })
604 }
605
606 fn head_commit_message(
607 &self,
608 repo_path: PathBuf,
609 ) -> GitFuture<Result<Option<String>, GitError>> {
610 Box::pin(async move { head_commit_message(repo_path).await })
611 }
612
613 fn delete_branch(
614 &self,
615 repo_path: PathBuf,
616 branch_name: String,
617 ) -> GitFuture<Result<(), GitError>> {
618 Box::pin(async move { delete_branch(repo_path, branch_name).await })
619 }
620
621 fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>> {
622 Box::pin(async move { diff(repo_path, base_branch).await })
623 }
624
625 fn diff_changed_files(
626 &self,
627 repo_path: PathBuf,
628 base_branch: String,
629 ) -> GitFuture<Result<Vec<String>, GitError>> {
630 Box::pin(async move { diff_changed_files(repo_path, base_branch).await })
631 }
632
633 fn read_worktree_file(
634 &self,
635 repo_path: PathBuf,
636 relative_path: String,
637 ) -> GitFuture<Result<WorktreeFileContent, GitError>> {
638 Box::pin(async move { sync::read_worktree_file(repo_path, relative_path).await })
639 }
640
641 fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
642 Box::pin(async move { is_worktree_clean(repo_path).await })
643 }
644
645 fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
646 Box::pin(async move { worktree_status(repo_path).await })
647 }
648
649 fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
650 Box::pin(async move { tracked_worktree_status(repo_path).await })
651 }
652
653 fn has_commits_since(
654 &self,
655 repo_path: PathBuf,
656 base_branch: String,
657 ) -> GitFuture<Result<bool, GitError>> {
658 Box::pin(async move { has_commits_since(repo_path, base_branch).await })
659 }
660
661 fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>> {
662 Box::pin(async move { pull_rebase(repo_path).await })
663 }
664
665 fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
666 Box::pin(async move { push_current_branch(repo_path).await })
667 }
668
669 fn push_current_branch_to_remote_branch(
670 &self,
671 repo_path: PathBuf,
672 remote_branch_name: String,
673 ) -> GitFuture<Result<String, GitError>> {
674 Box::pin(async move {
675 push_current_branch_to_remote_branch(repo_path, remote_branch_name).await
676 })
677 }
678
679 fn remote_branch_exists(
680 &self,
681 repo_path: PathBuf,
682 remote_branch_name: String,
683 ) -> GitFuture<Result<bool, GitError>> {
684 Box::pin(async move { remote_branch_exists(repo_path, remote_branch_name).await })
685 }
686
687 fn current_upstream_reference(
688 &self,
689 repo_path: PathBuf,
690 ) -> GitFuture<Result<String, GitError>> {
691 Box::pin(async move { current_upstream_reference(repo_path).await })
692 }
693
694 fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
695 Box::pin(async move { fetch_remote(repo_path).await })
696 }
697
698 fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>> {
699 Box::pin(async move { get_ahead_behind(repo_path).await })
700 }
701
702 fn get_ref_ahead_behind(
703 &self,
704 repo_path: PathBuf,
705 left_ref: String,
706 right_ref: String,
707 ) -> GitFuture<Result<(u32, u32), GitError>> {
708 Box::pin(async move { get_ref_ahead_behind(repo_path, left_ref, right_ref).await })
709 }
710
711 fn branch_tracking_statuses(
712 &self,
713 repo_path: PathBuf,
714 ) -> GitFuture<Result<BranchTrackingMap, GitError>> {
715 Box::pin(async move { branch_tracking_statuses(repo_path).await })
716 }
717
718 fn list_upstream_commit_titles(
719 &self,
720 repo_path: PathBuf,
721 ) -> GitFuture<Result<Vec<String>, GitError>> {
722 Box::pin(async move { list_upstream_commit_titles(repo_path).await })
723 }
724
725 fn list_local_commit_titles(
726 &self,
727 repo_path: PathBuf,
728 ) -> GitFuture<Result<Vec<String>, GitError>> {
729 Box::pin(async move { list_local_commit_titles(repo_path).await })
730 }
731
732 fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
733 Box::pin(async move { repo_url(repo_path).await })
734 }
735
736 fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>> {
737 Box::pin(async move { main_repo_root(repo_path).await })
738 }
739
740 fn main_checkout_working_tree(
741 &self,
742 repo_path: PathBuf,
743 ) -> GitFuture<Result<Option<PathBuf>, GitError>> {
744 Box::pin(async move { main_checkout_working_tree(repo_path).await })
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use std::path::{Path, PathBuf};
751 use std::process::Command;
752 use std::time::Duration;
753 use std::{fs, thread};
754
755 use tempfile::tempdir;
756
757 use super::*;
758
759 fn canonicalize_test_path(path: &Path) -> PathBuf {
762 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
763 }
764
765 fn run_git_command(repo_path: &Path, args: &[&str]) {
766 let output = Command::new("git")
767 .args(args)
768 .current_dir(repo_path)
769 .output()
770 .expect("failed to run git command");
771
772 assert!(
773 output.status.success(),
774 "git command {:?} failed: {}",
775 args,
776 String::from_utf8_lossy(&output.stderr)
777 );
778 }
779
780 fn run_git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
781 let output = Command::new("git")
782 .args(args)
783 .current_dir(repo_path)
784 .output()
785 .expect("failed to run git command");
786
787 assert!(
788 output.status.success(),
789 "git command {:?} failed: {}",
790 args,
791 String::from_utf8_lossy(&output.stderr)
792 );
793
794 String::from_utf8_lossy(&output.stdout).trim().to_string()
795 }
796
797 fn setup_test_git_repo(repo_path: &Path) {
798 run_git_command(repo_path, &["init", "-b", "main"]);
799 run_git_command(repo_path, &["config", "user.name", "Test User"]);
800 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
801
802 fs::write(repo_path.join("README.md"), "test repo").expect("failed to write file");
803 run_git_command(repo_path, &["add", "README.md"]);
804 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
805 }
806
807 #[tokio::test]
808 async fn test_real_git_client_reads_worktree_file() {
809 let dir = tempdir().expect("failed to create temp dir");
811 fs::write(dir.path().join("README.md"), "# Preview")
812 .expect("failed to write markdown file");
813 let client = RealGitClient;
814
815 let result = client
817 .read_worktree_file(dir.path().to_path_buf(), "README.md".to_string())
818 .await
819 .expect("failed to read worktree file");
820
821 assert_eq!(result, WorktreeFileContent::Text("# Preview".to_string()));
823 }
824
825 #[tokio::test]
826 async fn test_real_git_client_lists_changed_files() {
827 let dir = tempdir().expect("failed to create temp dir");
829 setup_test_git_repo(dir.path());
830 fs::write(dir.path().join("new.txt"), "new content").expect("failed to write changed file");
831 let client = RealGitClient;
832
833 let changed_files = client
835 .diff_changed_files(dir.path().to_path_buf(), "main".to_string())
836 .await
837 .expect("failed to list changed files");
838
839 assert_eq!(changed_files, vec!["new.txt".to_string()]);
841 }
842
843 #[tokio::test]
844 async fn test_squash_merge_returns_committed_when_changes_exist() {
845 let dir = tempdir().expect("failed to create temp dir");
847 setup_test_git_repo(dir.path());
848 run_git_command(dir.path(), &["checkout", "-b", "feature-branch"]);
849 fs::write(dir.path().join("feature.txt"), "feature content").expect("failed to write file");
850 run_git_command(dir.path(), &["add", "feature.txt"]);
851 run_git_command(dir.path(), &["commit", "-m", "Add feature"]);
852 run_git_command(dir.path(), &["checkout", "main"]);
853
854 let result = squash_merge(
856 dir.path().to_path_buf(),
857 "feature-branch".to_string(),
858 "main".to_string(),
859 "Squash merge feature".to_string(),
860 )
861 .await;
862
863 assert_eq!(
865 result.expect("squash merge should succeed"),
866 SquashMergeOutcome::Committed,
867 );
868 }
869
870 #[tokio::test]
871 async fn test_squash_merge_returns_already_present_when_changes_exist_in_target() {
872 let dir = tempdir().expect("failed to create temp dir");
874 setup_test_git_repo(dir.path());
875 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
876 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
877 run_git_command(dir.path(), &["add", "session.txt"]);
878 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
879 run_git_command(dir.path(), &["checkout", "main"]);
880 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
881 run_git_command(dir.path(), &["add", "session.txt"]);
882 run_git_command(dir.path(), &["commit", "-m", "Apply same change on main"]);
883
884 let result = squash_merge(
886 dir.path().to_path_buf(),
887 "session-branch".to_string(),
888 "main".to_string(),
889 "Merge session".to_string(),
890 )
891 .await;
892
893 assert_eq!(
895 result.expect("squash merge should succeed"),
896 SquashMergeOutcome::AlreadyPresentInTarget,
897 );
898 }
899
900 #[tokio::test]
901 async fn test_commit_all_preserving_single_commit_creates_first_commit() {
902 let dir = tempdir().expect("failed to create temp dir");
904 setup_test_git_repo(dir.path());
905 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
906 let commit_message = "Session commit".to_string();
907 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
908
909 let result = commit_all_preserving_single_commit(
911 dir.path().to_path_buf(),
912 "main".to_string(),
913 commit_message.clone(),
914 SingleCommitMessageStrategy::Replace,
915 false,
916 )
917 .await;
918 let commit_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
919 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
920
921 assert!(
923 result.is_ok(),
924 "commit_all_preserving_single_commit should succeed: {result:?}"
925 );
926 assert_eq!(commit_count, "2");
927 assert_eq!(head_message, commit_message);
928 }
929
930 #[tokio::test]
931 async fn test_commit_all_preserving_single_commit_amends_existing_session_commit() {
932 let dir = tempdir().expect("failed to create temp dir");
934 setup_test_git_repo(dir.path());
935 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
936 let commit_message = "Session commit".to_string();
937 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
938 commit_all_preserving_single_commit(
939 dir.path().to_path_buf(),
940 "main".to_string(),
941 commit_message.clone(),
942 SingleCommitMessageStrategy::Replace,
943 false,
944 )
945 .await
946 .expect("failed to create first session commit");
947 let first_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
948 let first_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
949
950 fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
952 let result = commit_all_preserving_single_commit(
953 dir.path().to_path_buf(),
954 "main".to_string(),
955 commit_message.clone(),
956 SingleCommitMessageStrategy::Replace,
957 false,
958 )
959 .await;
960 let second_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
961 let second_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
962
963 assert!(result.is_ok(), "amend commit should succeed: {result:?}");
965 assert_ne!(first_hash, second_hash);
966 assert_eq!(first_count, second_count);
967 }
968
969 #[tokio::test]
970 async fn test_commit_all_preserving_single_commit_replaces_amended_message() {
971 let dir = tempdir().expect("failed to create temp dir");
973 setup_test_git_repo(dir.path());
974 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
975 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
976 commit_all_preserving_single_commit(
977 dir.path().to_path_buf(),
978 "main".to_string(),
979 "First session message".to_string(),
980 SingleCommitMessageStrategy::Replace,
981 false,
982 )
983 .await
984 .expect("failed to create first session commit");
985
986 fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
988 let result = commit_all_preserving_single_commit(
989 dir.path().to_path_buf(),
990 "main".to_string(),
991 "Refined session message".to_string(),
992 SingleCommitMessageStrategy::Replace,
993 false,
994 )
995 .await;
996 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
997
998 assert!(
1000 result.is_ok(),
1001 "replace amended message should succeed: {result:?}"
1002 );
1003 assert_eq!(head_message, "Refined session message");
1004 }
1005
1006 #[tokio::test]
1007 async fn test_commit_all_preserving_single_commit_retries_index_lock_and_succeeds() {
1008 let dir = tempdir().expect("failed to create temp dir");
1010 setup_test_git_repo(dir.path());
1011 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1012 let commit_message = "Session commit".to_string();
1013 fs::write(dir.path().join("work.txt"), "locked change").expect("failed to write file");
1014 let index_lock_path = dir.path().join(".git").join("index.lock");
1015 fs::write(&index_lock_path, "stale lock").expect("failed to write lock file");
1016 let lock_cleanup = thread::spawn(move || {
1017 thread::sleep(Duration::from_millis(250));
1018 let _ = fs::remove_file(index_lock_path);
1019 });
1020
1021 let result = commit_all_preserving_single_commit(
1023 dir.path().to_path_buf(),
1024 "main".to_string(),
1025 commit_message.clone(),
1026 SingleCommitMessageStrategy::Replace,
1027 false,
1028 )
1029 .await;
1030 lock_cleanup
1031 .join()
1032 .expect("failed to join lock cleanup thread");
1033 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1034
1035 assert!(
1037 result.is_ok(),
1038 "retry with index lock should succeed: {result:?}"
1039 );
1040 assert_eq!(head_message, commit_message);
1041 }
1042
1043 #[tokio::test]
1044 async fn test_diff_hides_leading_squash_merged_commit_for_non_rebased_session() {
1045 let dir = tempdir().expect("failed to create temp dir");
1047 setup_test_git_repo(dir.path());
1048 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1049 fs::write(dir.path().join("merged.txt"), "already merged change")
1050 .expect("failed to write merged file");
1051 run_git_command(dir.path(), &["add", "merged.txt"]);
1052 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1053 run_git_command(dir.path(), &["checkout", "main"]);
1054 run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1055 run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1056 run_git_command(dir.path(), &["checkout", "session-branch"]);
1057
1058 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1060 .await
1061 .expect("failed to load diff");
1062
1063 assert!(
1065 diff_output.trim().is_empty(),
1066 "expected no diff, got: {diff_output}"
1067 );
1068 }
1069
1070 #[tokio::test]
1071 async fn test_diff_keeps_new_commits_after_leading_squash_merged_commit() {
1072 let dir = tempdir().expect("failed to create temp dir");
1074 setup_test_git_repo(dir.path());
1075 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1076 fs::write(dir.path().join("merged.txt"), "already merged change")
1077 .expect("failed to write merged file");
1078 run_git_command(dir.path(), &["add", "merged.txt"]);
1079 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1080 run_git_command(dir.path(), &["checkout", "main"]);
1081 run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1082 run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1083 run_git_command(dir.path(), &["checkout", "session-branch"]);
1084 fs::write(dir.path().join("new.txt"), "new session-only change")
1085 .expect("failed to write new file");
1086 run_git_command(dir.path(), &["add", "new.txt"]);
1087 run_git_command(dir.path(), &["commit", "-m", "New session change"]);
1088
1089 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1091 .await
1092 .expect("failed to load diff");
1093
1094 assert!(diff_output.contains("new.txt"));
1096 assert!(!diff_output.contains("merged.txt"));
1097 }
1098
1099 #[tokio::test]
1100 async fn test_diff_does_not_include_base_only_commits() {
1101 let dir = tempdir().expect("failed to create temp dir");
1103 setup_test_git_repo(dir.path());
1104 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1105 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1106 run_git_command(dir.path(), &["add", "session.txt"]);
1107 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1108 run_git_command(dir.path(), &["checkout", "main"]);
1109 fs::write(dir.path().join("main-only.txt"), "base branch only")
1110 .expect("failed to write base-only file");
1111 run_git_command(dir.path(), &["add", "main-only.txt"]);
1112 run_git_command(dir.path(), &["commit", "-m", "Main branch change"]);
1113 run_git_command(dir.path(), &["checkout", "session-branch"]);
1114
1115 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1117 .await
1118 .expect("failed to load diff");
1119
1120 assert!(diff_output.contains("session.txt"));
1122 assert!(!diff_output.contains("main-only.txt"));
1123 }
1124
1125 #[tokio::test]
1126 async fn test_is_worktree_clean_returns_true_for_clean_repo() {
1127 let dir = tempdir().expect("failed to create temp dir");
1129 setup_test_git_repo(dir.path());
1130
1131 let is_clean = is_worktree_clean(dir.path().to_path_buf())
1133 .await
1134 .expect("failed to check worktree cleanliness");
1135
1136 assert!(is_clean);
1138 }
1139
1140 #[tokio::test]
1141 async fn test_is_worktree_clean_returns_false_for_dirty_repo() {
1142 let dir = tempdir().expect("failed to create temp dir");
1144 setup_test_git_repo(dir.path());
1145 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1146
1147 let is_clean = is_worktree_clean(dir.path().to_path_buf())
1149 .await
1150 .expect("failed to check worktree cleanliness");
1151
1152 assert!(!is_clean);
1154 }
1155
1156 #[tokio::test]
1157 async fn test_worktree_status_reports_dirty_repo_paths() {
1158 let dir = tempdir().expect("failed to create temp dir");
1160 setup_test_git_repo(dir.path());
1161 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1162 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1163
1164 let status = worktree_status(dir.path().to_path_buf())
1166 .await
1167 .expect("failed to read worktree status");
1168
1169 assert!(status.contains("README.md"));
1171 assert!(status.contains("new-file.txt"));
1172 }
1173
1174 #[tokio::test]
1175 async fn test_tracked_worktree_status_ignores_untracked_repo_paths() {
1176 let dir = tempdir().expect("failed to create temp dir");
1178 setup_test_git_repo(dir.path());
1179 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1180 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1181
1182 let status = tracked_worktree_status(dir.path().to_path_buf())
1184 .await
1185 .expect("failed to read tracked worktree status");
1186
1187 assert!(status.contains("README.md"));
1189 assert!(!status.contains("new-file.txt"));
1190 }
1191
1192 #[tokio::test]
1193 async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1194 let dir = tempdir().expect("failed to create temp dir");
1196 setup_test_git_repo(dir.path());
1197
1198 let repo_root = main_repo_root(dir.path().to_path_buf())
1200 .await
1201 .expect("failed to resolve main repo root");
1202
1203 assert_eq!(
1205 canonicalize_test_path(&repo_root),
1206 canonicalize_test_path(dir.path())
1207 );
1208 }
1209
1210 #[tokio::test]
1211 async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1212 let dir = tempdir().expect("failed to create temp dir");
1214 setup_test_git_repo(dir.path());
1215 let linked_worktree = dir.path().join("linked-worktree");
1216 create_worktree(
1217 dir.path().to_path_buf(),
1218 linked_worktree.clone(),
1219 "wt/main-repo-root-test".to_string(),
1220 "main".to_string(),
1221 )
1222 .await
1223 .expect("failed to create linked worktree");
1224
1225 let repo_root = main_repo_root(linked_worktree)
1227 .await
1228 .expect("failed to resolve shared repo root");
1229
1230 assert_eq!(
1232 canonicalize_test_path(&repo_root),
1233 canonicalize_test_path(dir.path())
1234 );
1235 }
1236
1237 #[tokio::test]
1238 async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1239 let dir = tempdir().expect("failed to create temp dir");
1241 setup_test_git_repo(dir.path());
1242
1243 let result = abort_rebase(dir.path().to_path_buf()).await;
1245
1246 assert!(result.is_err());
1248 }
1249
1250 #[tokio::test]
1251 async fn test_ref_hash_resolves_branch_head() {
1252 let dir = tempdir().expect("failed to create temp dir");
1254 setup_test_git_repo(dir.path());
1255 let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1256
1257 let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1259 .await
1260 .expect("failed to resolve main hash");
1261
1262 assert_eq!(resolved_hash, expected_hash);
1264 }
1265
1266 #[tokio::test]
1267 async fn test_rebase_onto_start_replays_commits_after_old_base() {
1268 let dir = tempdir().expect("failed to create temp dir");
1270 setup_test_git_repo(dir.path());
1271 run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1272 fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1273 run_git_command(dir.path(), &["add", "parent.txt"]);
1274 run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1275 let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1276 run_git_command(dir.path(), &["checkout", "-b", "child"]);
1277 fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1278 run_git_command(dir.path(), &["add", "child.txt"]);
1279 run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1280 run_git_command(dir.path(), &["checkout", "main"]);
1281 fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1282 run_git_command(dir.path(), &["add", "main.txt"]);
1283 run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1284 run_git_command(dir.path(), &["checkout", "child"]);
1285
1286 let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1288 .await
1289 .expect("failed to start rebase --onto");
1290 let child_only_subjects = run_git_command_stdout(
1291 dir.path(),
1292 &["log", "--format=%s", "--reverse", "main..HEAD"],
1293 );
1294
1295 assert_eq!(result, RebaseStepResult::Completed);
1297 assert_eq!(child_only_subjects, "Child change");
1298 assert!(!dir.path().join("parent.txt").exists());
1299 assert!(dir.path().join("child.txt").exists());
1300 }
1301
1302 #[tokio::test]
1303 async fn test_pull_rebase_returns_error_without_upstream() {
1304 let dir = tempdir().expect("failed to create temp dir");
1306 setup_test_git_repo(dir.path());
1307
1308 let result = pull_rebase(dir.path().to_path_buf()).await;
1310
1311 assert!(result.is_err());
1313 }
1314
1315 #[tokio::test]
1316 async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1317 let dir = tempdir().expect("failed to create temp dir");
1319 let remote_dir = tempdir().expect("failed to create remote temp dir");
1320 setup_test_git_repo(dir.path());
1321 run_git_command(remote_dir.path(), &["init", "--bare"]);
1322
1323 let remote_path = remote_dir.path().to_string_lossy().to_string();
1324 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1325 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1326
1327 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1328 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1329 run_git_command(dir.path(), &["add", "feature.txt"]);
1330 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1331 run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1332 run_git_command(dir.path(), &["checkout", "main"]);
1333
1334 run_git_command(
1335 dir.path(),
1336 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1337 );
1338
1339 let pull_without_explicit_target = Command::new("git")
1340 .args(["pull", "--rebase"])
1341 .current_dir(dir.path())
1342 .output()
1343 .expect("failed to run pull --rebase");
1344
1345 assert!(
1346 !pull_without_explicit_target.status.success(),
1347 "expected plain pull --rebase to fail in ambiguous merge-target setup"
1348 );
1349 assert!(
1350 String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1351 .contains("Cannot rebase onto multiple branches"),
1352 "expected ambiguous merge-target failure"
1353 );
1354
1355 let result = pull_rebase(dir.path().to_path_buf()).await;
1357
1358 assert!(
1360 matches!(result, Ok(PullRebaseResult::Completed)),
1361 "pull_rebase should complete: {result:?}"
1362 );
1363 }
1364
1365 #[tokio::test]
1366 async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1367 let dir = tempdir().expect("failed to create temp dir");
1369 setup_test_git_repo(dir.path());
1370
1371 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1372 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1373 run_git_command(dir.path(), &["add", "feature.txt"]);
1374 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1375 run_git_command(dir.path(), &["checkout", "main"]);
1376
1377 run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1378 run_git_command(
1379 dir.path(),
1380 &[
1381 "config",
1382 "--replace-all",
1383 "branch.main.merge",
1384 "refs/heads/main",
1385 ],
1386 );
1387 run_git_command(
1388 dir.path(),
1389 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1390 );
1391
1392 let pull_without_explicit_target = Command::new("git")
1393 .args(["pull", "--rebase"])
1394 .current_dir(dir.path())
1395 .output()
1396 .expect("failed to run pull --rebase");
1397
1398 assert!(
1399 !pull_without_explicit_target.status.success(),
1400 "expected plain pull --rebase to fail in ambiguous merge-target setup"
1401 );
1402 assert!(
1403 String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1404 .contains("Cannot rebase onto multiple branches"),
1405 "expected ambiguous merge-target failure"
1406 );
1407
1408 let result = pull_rebase(dir.path().to_path_buf()).await;
1410
1411 assert!(
1413 matches!(result, Ok(PullRebaseResult::Completed)),
1414 "pull_rebase with local upstream should complete: {result:?}"
1415 );
1416 }
1417
1418 #[tokio::test]
1419 async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1420 let dir = tempdir().expect("failed to create temp dir");
1422 setup_test_git_repo(dir.path());
1423
1424 let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1426
1427 assert!(result.is_err());
1429 }
1430
1431 #[tokio::test]
1432 async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1433 let dir = tempdir().expect("failed to create temp dir");
1435 let remote_dir = tempdir().expect("failed to create remote temp dir");
1436 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1437 let contributor_clone_path = contributor_dir.path().join("clone");
1438 setup_test_git_repo(dir.path());
1439 run_git_command(remote_dir.path(), &["init", "--bare"]);
1440
1441 let remote_path = remote_dir.path().to_string_lossy().to_string();
1442 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1443 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1444 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1445
1446 run_git_command(
1447 contributor_dir.path(),
1448 &["clone", &remote_path, &contributor_clone_path_text],
1449 );
1450 run_git_command(
1451 &contributor_clone_path,
1452 &["config", "user.name", "Contributor User"],
1453 );
1454 run_git_command(
1455 &contributor_clone_path,
1456 &["config", "user.email", "contributor@example.com"],
1457 );
1458 run_git_command(
1459 &contributor_clone_path,
1460 &["checkout", "-B", "main", "origin/main"],
1461 );
1462 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1463 .expect("failed to write remote change");
1464 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1465 run_git_command(
1466 &contributor_clone_path,
1467 &["commit", "-m", "Remote commit title"],
1468 );
1469 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1470 run_git_command(dir.path(), &["fetch", "origin"]);
1471
1472 let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1474 .await
1475 .expect("failed to list upstream commit titles");
1476
1477 assert_eq!(titles, vec!["Remote commit title".to_string()]);
1479 }
1480
1481 #[tokio::test]
1482 async fn test_list_local_commit_titles_returns_error_without_upstream() {
1483 let dir = tempdir().expect("failed to create temp dir");
1485 setup_test_git_repo(dir.path());
1486
1487 let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1489
1490 assert!(result.is_err());
1492 }
1493
1494 #[tokio::test]
1495 async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1496 let dir = tempdir().expect("failed to create temp dir");
1498 let remote_dir = tempdir().expect("failed to create remote temp dir");
1499 setup_test_git_repo(dir.path());
1500 run_git_command(remote_dir.path(), &["init", "--bare"]);
1501
1502 let remote_path = remote_dir.path().to_string_lossy().to_string();
1503 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1504 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1505
1506 fs::write(dir.path().join("local_1.txt"), "local change 1")
1507 .expect("failed to write local change 1");
1508 run_git_command(dir.path(), &["add", "local_1.txt"]);
1509 run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1510
1511 fs::write(dir.path().join("local_2.txt"), "local change 2")
1512 .expect("failed to write local change 2");
1513 run_git_command(dir.path(), &["add", "local_2.txt"]);
1514 run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1515
1516 let titles = list_local_commit_titles(dir.path().to_path_buf())
1518 .await
1519 .expect("failed to list local commit titles");
1520
1521 assert_eq!(
1523 titles,
1524 vec![
1525 "Local commit title one".to_string(),
1526 "Local commit title two".to_string(),
1527 ]
1528 );
1529 }
1530
1531 #[tokio::test]
1532 async fn test_push_current_branch_returns_error_without_remote() {
1533 let dir = tempdir().expect("failed to create temp dir");
1535 setup_test_git_repo(dir.path());
1536
1537 let result = push_current_branch(dir.path().to_path_buf()).await;
1539
1540 assert!(result.is_err());
1542 }
1543
1544 #[tokio::test]
1545 async fn test_push_current_branch_returns_upstream_reference() {
1546 let dir = tempdir().expect("failed to create temp dir");
1548 let remote_dir = tempdir().expect("failed to create remote temp dir");
1549 setup_test_git_repo(dir.path());
1550 run_git_command(remote_dir.path(), &["init", "--bare"]);
1551 let remote_path = remote_dir.path().to_string_lossy().to_string();
1552 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1553
1554 let upstream_reference = push_current_branch(dir.path().to_path_buf())
1556 .await
1557 .expect("push should set upstream");
1558
1559 assert_eq!(upstream_reference, "origin/main");
1561 }
1562
1563 #[tokio::test]
1564 async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1565 let dir = tempdir().expect("failed to create temp dir");
1567 let remote_dir = tempdir().expect("failed to create remote temp dir");
1568 setup_test_git_repo(dir.path());
1569 run_git_command(remote_dir.path(), &["init", "--bare"]);
1570 let remote_path = remote_dir.path().to_string_lossy().to_string();
1571 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1572
1573 let upstream_reference = push_current_branch_to_remote_branch(
1575 dir.path().to_path_buf(),
1576 "review/custom-branch".to_string(),
1577 )
1578 .await
1579 .expect("push should set a custom upstream");
1580
1581 assert_eq!(upstream_reference, "origin/review/custom-branch");
1583 }
1584
1585 #[test]
1586 fn test_is_no_upstream_error_detects_upstream_hint() {
1587 let detail = "fatal: The current branch main has no upstream branch.";
1589
1590 let is_no_upstream = sync::is_no_upstream_error(detail);
1592
1593 assert!(is_no_upstream);
1595 }
1596
1597 #[test]
1598 fn test_is_rebase_conflict_detects_conflict_keyword() {
1599 let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1601
1602 assert!(rebase::is_rebase_conflict(detail));
1604 }
1605
1606 #[test]
1607 fn test_is_rebase_conflict_detects_could_not_apply() {
1608 let detail = "error: could not apply abc1234... Update handler";
1610
1611 assert!(rebase::is_rebase_conflict(detail));
1613 }
1614
1615 #[test]
1616 fn test_is_rebase_conflict_detects_mark_as_resolved() {
1617 let detail = "hint: mark them as resolved using git add";
1619
1620 assert!(rebase::is_rebase_conflict(detail));
1622 }
1623
1624 #[test]
1625 fn test_is_rebase_conflict_detects_unresolved_conflict() {
1626 let detail = "fatal: Exiting because of an unresolved conflict.";
1628
1629 assert!(rebase::is_rebase_conflict(detail));
1631 }
1632
1633 #[test]
1634 fn test_is_rebase_conflict_detects_committing_not_possible() {
1635 let detail = "error: Committing is not possible because you have unmerged files.";
1637
1638 assert!(rebase::is_rebase_conflict(detail));
1640 }
1641
1642 #[test]
1643 fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1644 let detail = "fatal: not a git repository (or any parent up to mount point /)";
1646
1647 assert!(!rebase::is_rebase_conflict(detail));
1649 }
1650
1651 #[test]
1652 fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1653 let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1655
1656 assert!(!rebase::is_rebase_conflict(detail));
1658 }
1659}