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