1use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6
7use super::error::GitError;
8use super::merge::SquashMergeOutcome;
9use super::rebase::{InProgressGitOperation, RebaseStepResult};
10use super::sync::{
11 BranchTrackingMap, PullRebaseResult, SingleCommitMessageStrategy, WorktreeFileContent,
12};
13use super::{
14 abort_rebase, branch_tracking_statuses, check_pre_commit_hook_ready, commit_all,
15 commit_all_preserving_single_commit, create_worktree, current_upstream_reference,
16 delete_branch, detect_git_info, diff, diff_changed_files, fetch_remote, find_git_repo_root,
17 get_ahead_behind, get_ref_ahead_behind, has_commits_since, has_merge_conflicts,
18 has_unmerged_paths, head_commit_message, head_hash, head_short_hash, in_progress_operation,
19 is_rebase_in_progress, is_worktree_clean, list_conflicted_files, list_local_commit_titles,
20 list_staged_conflict_marker_files, list_upstream_commit_titles, main_checkout_working_tree,
21 main_repo_root, pull_rebase, push_current_branch, push_current_branch_to_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 has_merge_conflicts(
393 &self,
394 repo_path: PathBuf,
395 source_branch: String,
396 target_branch: String,
397 ) -> GitFuture<Result<bool, GitError>>;
398
399 fn branch_tracking_statuses(
408 &self,
409 repo_path: PathBuf,
410 ) -> GitFuture<Result<BranchTrackingMap, GitError>>;
411
412 fn list_upstream_commit_titles(
419 &self,
420 repo_path: PathBuf,
421 ) -> GitFuture<Result<Vec<String>, GitError>>;
422
423 fn list_local_commit_titles(
429 &self,
430 repo_path: PathBuf,
431 ) -> GitFuture<Result<Vec<String>, GitError>>;
432
433 fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>>;
438
439 fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>>;
444
445 fn main_checkout_working_tree(
453 &self,
454 repo_path: PathBuf,
455 ) -> GitFuture<Result<Option<PathBuf>, GitError>>;
456}
457
458pub struct RealGitClient;
460
461impl GitClient for RealGitClient {
462 fn detect_git_info(&self, dir: PathBuf) -> GitFuture<Option<String>> {
463 Box::pin(async move { detect_git_info(dir).await })
464 }
465
466 fn find_git_repo_root(&self, dir: PathBuf) -> GitFuture<Option<PathBuf>> {
467 Box::pin(async move { find_git_repo_root(dir).await })
468 }
469
470 fn check_pre_commit_hook_ready(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
471 Box::pin(async move { check_pre_commit_hook_ready(repo_path).await })
472 }
473
474 fn create_worktree(
475 &self,
476 repo_path: PathBuf,
477 worktree_path: PathBuf,
478 branch_name: String,
479 start_ref: String,
480 ) -> GitFuture<Result<(), GitError>> {
481 Box::pin(
482 async move { create_worktree(repo_path, worktree_path, branch_name, start_ref).await },
483 )
484 }
485
486 fn remove_worktree(&self, worktree_path: PathBuf) -> GitFuture<Result<(), GitError>> {
487 Box::pin(async move { remove_worktree(worktree_path).await })
488 }
489
490 fn squash_merge_diff(
491 &self,
492 repo_path: PathBuf,
493 source_branch: String,
494 target_branch: String,
495 ) -> GitFuture<Result<String, GitError>> {
496 Box::pin(async move { squash_merge_diff(repo_path, source_branch, target_branch).await })
497 }
498
499 fn squash_merge(
500 &self,
501 repo_path: PathBuf,
502 source_branch: String,
503 target_branch: String,
504 commit_message: String,
505 ) -> GitFuture<Result<SquashMergeOutcome, GitError>> {
506 Box::pin(async move {
507 squash_merge(repo_path, source_branch, target_branch, commit_message).await
508 })
509 }
510
511 fn rebase(&self, repo_path: PathBuf, target_branch: String) -> GitFuture<Result<(), GitError>> {
512 Box::pin(async move { rebase::rebase(repo_path, target_branch).await })
513 }
514
515 fn rebase_start(
516 &self,
517 repo_path: PathBuf,
518 target_branch: String,
519 ) -> GitFuture<Result<RebaseStepResult, GitError>> {
520 Box::pin(async move { rebase_start(repo_path, target_branch).await })
521 }
522
523 fn rebase_onto_start(
524 &self,
525 repo_path: PathBuf,
526 new_base: String,
527 old_base: String,
528 ) -> GitFuture<Result<RebaseStepResult, GitError>> {
529 Box::pin(async move { rebase_onto_start(repo_path, new_base, old_base).await })
530 }
531
532 fn rebase_continue(&self, repo_path: PathBuf) -> GitFuture<Result<RebaseStepResult, GitError>> {
533 Box::pin(async move { rebase_continue(repo_path).await })
534 }
535
536 fn abort_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
537 Box::pin(async move { abort_rebase(repo_path).await })
538 }
539
540 fn is_rebase_in_progress(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
541 Box::pin(async move { is_rebase_in_progress(repo_path).await })
542 }
543
544 fn in_progress_operation(
545 &self,
546 repo_path: PathBuf,
547 ) -> GitFuture<Result<Option<InProgressGitOperation>, GitError>> {
548 Box::pin(async move { in_progress_operation(repo_path).await })
549 }
550
551 fn has_unmerged_paths(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
552 Box::pin(async move { has_unmerged_paths(repo_path).await })
553 }
554
555 fn list_staged_conflict_marker_files(
556 &self,
557 repo_path: PathBuf,
558 paths: Vec<String>,
559 ) -> GitFuture<Result<Vec<String>, GitError>> {
560 Box::pin(async move { list_staged_conflict_marker_files(repo_path, paths).await })
561 }
562
563 fn list_conflicted_files(
564 &self,
565 repo_path: PathBuf,
566 ) -> GitFuture<Result<Vec<String>, GitError>> {
567 Box::pin(async move { list_conflicted_files(repo_path).await })
568 }
569
570 fn commit_all(
571 &self,
572 repo_path: PathBuf,
573 message: String,
574 no_verify: bool,
575 ) -> GitFuture<Result<(), GitError>> {
576 Box::pin(async move { commit_all(repo_path, message, no_verify).await })
577 }
578
579 fn commit_all_preserving_single_commit(
580 &self,
581 repo_path: PathBuf,
582 base_branch: String,
583 commit_message: String,
584 message_strategy: SingleCommitMessageStrategy,
585 no_verify: bool,
586 ) -> GitFuture<Result<(), GitError>> {
587 Box::pin(async move {
588 commit_all_preserving_single_commit(
589 repo_path,
590 base_branch,
591 commit_message,
592 message_strategy,
593 no_verify,
594 )
595 .await
596 })
597 }
598
599 fn stage_all(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
600 Box::pin(async move { stage_all(repo_path).await })
601 }
602
603 fn head_short_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
604 Box::pin(async move { head_short_hash(repo_path).await })
605 }
606
607 fn head_hash(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
608 Box::pin(async move { head_hash(repo_path).await })
609 }
610
611 fn ref_hash(
612 &self,
613 repo_path: PathBuf,
614 reference: String,
615 ) -> GitFuture<Result<String, GitError>> {
616 Box::pin(async move { ref_hash(repo_path, reference).await })
617 }
618
619 fn head_commit_message(
620 &self,
621 repo_path: PathBuf,
622 ) -> GitFuture<Result<Option<String>, GitError>> {
623 Box::pin(async move { head_commit_message(repo_path).await })
624 }
625
626 fn delete_branch(
627 &self,
628 repo_path: PathBuf,
629 branch_name: String,
630 ) -> GitFuture<Result<(), GitError>> {
631 Box::pin(async move { delete_branch(repo_path, branch_name).await })
632 }
633
634 fn diff(&self, repo_path: PathBuf, base_branch: String) -> GitFuture<Result<String, GitError>> {
635 Box::pin(async move { diff(repo_path, base_branch).await })
636 }
637
638 fn diff_changed_files(
639 &self,
640 repo_path: PathBuf,
641 base_branch: String,
642 ) -> GitFuture<Result<Vec<String>, GitError>> {
643 Box::pin(async move { diff_changed_files(repo_path, base_branch).await })
644 }
645
646 fn read_worktree_file(
647 &self,
648 repo_path: PathBuf,
649 relative_path: String,
650 ) -> GitFuture<Result<WorktreeFileContent, GitError>> {
651 Box::pin(async move { sync::read_worktree_file(repo_path, relative_path).await })
652 }
653
654 fn is_worktree_clean(&self, repo_path: PathBuf) -> GitFuture<Result<bool, GitError>> {
655 Box::pin(async move { is_worktree_clean(repo_path).await })
656 }
657
658 fn worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
659 Box::pin(async move { worktree_status(repo_path).await })
660 }
661
662 fn tracked_worktree_status(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
663 Box::pin(async move { tracked_worktree_status(repo_path).await })
664 }
665
666 fn has_commits_since(
667 &self,
668 repo_path: PathBuf,
669 base_branch: String,
670 ) -> GitFuture<Result<bool, GitError>> {
671 Box::pin(async move { has_commits_since(repo_path, base_branch).await })
672 }
673
674 fn pull_rebase(&self, repo_path: PathBuf) -> GitFuture<Result<PullRebaseResult, GitError>> {
675 Box::pin(async move { pull_rebase(repo_path).await })
676 }
677
678 fn push_current_branch(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
679 Box::pin(async move { push_current_branch(repo_path).await })
680 }
681
682 fn push_current_branch_to_remote_branch(
683 &self,
684 repo_path: PathBuf,
685 remote_branch_name: String,
686 ) -> GitFuture<Result<String, GitError>> {
687 Box::pin(async move {
688 push_current_branch_to_remote_branch(repo_path, remote_branch_name).await
689 })
690 }
691
692 fn remote_branch_exists(
693 &self,
694 repo_path: PathBuf,
695 remote_branch_name: String,
696 ) -> GitFuture<Result<bool, GitError>> {
697 Box::pin(async move { remote_branch_exists(repo_path, remote_branch_name).await })
698 }
699
700 fn current_upstream_reference(
701 &self,
702 repo_path: PathBuf,
703 ) -> GitFuture<Result<String, GitError>> {
704 Box::pin(async move { current_upstream_reference(repo_path).await })
705 }
706
707 fn fetch_remote(&self, repo_path: PathBuf) -> GitFuture<Result<(), GitError>> {
708 Box::pin(async move { fetch_remote(repo_path).await })
709 }
710
711 fn get_ahead_behind(&self, repo_path: PathBuf) -> GitFuture<Result<(u32, u32), GitError>> {
712 Box::pin(async move { get_ahead_behind(repo_path).await })
713 }
714
715 fn get_ref_ahead_behind(
716 &self,
717 repo_path: PathBuf,
718 left_ref: String,
719 right_ref: String,
720 ) -> GitFuture<Result<(u32, u32), GitError>> {
721 Box::pin(async move { get_ref_ahead_behind(repo_path, left_ref, right_ref).await })
722 }
723
724 fn has_merge_conflicts(
725 &self,
726 repo_path: PathBuf,
727 source_branch: String,
728 target_branch: String,
729 ) -> GitFuture<Result<bool, GitError>> {
730 Box::pin(async move { has_merge_conflicts(repo_path, source_branch, target_branch).await })
731 }
732
733 fn branch_tracking_statuses(
734 &self,
735 repo_path: PathBuf,
736 ) -> GitFuture<Result<BranchTrackingMap, GitError>> {
737 Box::pin(async move { branch_tracking_statuses(repo_path).await })
738 }
739
740 fn list_upstream_commit_titles(
741 &self,
742 repo_path: PathBuf,
743 ) -> GitFuture<Result<Vec<String>, GitError>> {
744 Box::pin(async move { list_upstream_commit_titles(repo_path).await })
745 }
746
747 fn list_local_commit_titles(
748 &self,
749 repo_path: PathBuf,
750 ) -> GitFuture<Result<Vec<String>, GitError>> {
751 Box::pin(async move { list_local_commit_titles(repo_path).await })
752 }
753
754 fn repo_url(&self, repo_path: PathBuf) -> GitFuture<Result<String, GitError>> {
755 Box::pin(async move { repo_url(repo_path).await })
756 }
757
758 fn main_repo_root(&self, repo_path: PathBuf) -> GitFuture<Result<PathBuf, GitError>> {
759 Box::pin(async move { main_repo_root(repo_path).await })
760 }
761
762 fn main_checkout_working_tree(
763 &self,
764 repo_path: PathBuf,
765 ) -> GitFuture<Result<Option<PathBuf>, GitError>> {
766 Box::pin(async move { main_checkout_working_tree(repo_path).await })
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use std::path::{Path, PathBuf};
773 use std::process::Command;
774 use std::time::Duration;
775 use std::{fs, thread};
776
777 use tempfile::tempdir;
778
779 use super::*;
780
781 fn canonicalize_test_path(path: &Path) -> PathBuf {
784 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
785 }
786
787 fn run_git_command(repo_path: &Path, args: &[&str]) {
788 let output = Command::new("git")
789 .args(args)
790 .current_dir(repo_path)
791 .output()
792 .expect("failed to run git command");
793
794 assert!(
795 output.status.success(),
796 "git command {:?} failed: {}",
797 args,
798 String::from_utf8_lossy(&output.stderr)
799 );
800 }
801
802 fn run_git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
803 let output = Command::new("git")
804 .args(args)
805 .current_dir(repo_path)
806 .output()
807 .expect("failed to run git command");
808
809 assert!(
810 output.status.success(),
811 "git command {:?} failed: {}",
812 args,
813 String::from_utf8_lossy(&output.stderr)
814 );
815
816 String::from_utf8_lossy(&output.stdout).trim().to_string()
817 }
818
819 fn setup_test_git_repo(repo_path: &Path) {
820 run_git_command(repo_path, &["init", "-b", "main"]);
821 run_git_command(repo_path, &["config", "user.name", "Test User"]);
822 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
823
824 fs::write(repo_path.join("README.md"), "test repo").expect("failed to write file");
825 run_git_command(repo_path, &["add", "README.md"]);
826 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
827 }
828
829 #[tokio::test]
830 async fn test_real_git_client_detects_merge_conflicts() {
831 let dir = tempdir().expect("failed to create temp dir");
833 setup_test_git_repo(dir.path());
834 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
835 fs::write(dir.path().join("README.md"), "session content")
836 .expect("failed to write session content");
837 run_git_command(dir.path(), &["add", "README.md"]);
838 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
839 run_git_command(dir.path(), &["checkout", "main"]);
840 fs::write(dir.path().join("README.md"), "main content")
841 .expect("failed to write main content");
842 run_git_command(dir.path(), &["add", "README.md"]);
843 run_git_command(dir.path(), &["commit", "-m", "Main change"]);
844 let client = RealGitClient;
845
846 let has_conflicts = client
848 .has_merge_conflicts(
849 dir.path().to_path_buf(),
850 "session-branch".to_string(),
851 "main".to_string(),
852 )
853 .await
854 .expect("merge conflict query should succeed");
855
856 assert!(has_conflicts);
858 }
859
860 #[tokio::test]
861 async fn test_real_git_client_reads_worktree_file() {
862 let dir = tempdir().expect("failed to create temp dir");
864 fs::write(dir.path().join("README.md"), "# Preview")
865 .expect("failed to write markdown file");
866 let client = RealGitClient;
867
868 let result = client
870 .read_worktree_file(dir.path().to_path_buf(), "README.md".to_string())
871 .await
872 .expect("failed to read worktree file");
873
874 assert_eq!(result, WorktreeFileContent::Text("# Preview".to_string()));
876 }
877
878 #[tokio::test]
879 async fn test_real_git_client_lists_changed_files() {
880 let dir = tempdir().expect("failed to create temp dir");
882 setup_test_git_repo(dir.path());
883 fs::write(dir.path().join("new.txt"), "new content").expect("failed to write changed file");
884 let client = RealGitClient;
885
886 let changed_files = client
888 .diff_changed_files(dir.path().to_path_buf(), "main".to_string())
889 .await
890 .expect("failed to list changed files");
891
892 assert_eq!(changed_files, vec!["new.txt".to_string()]);
894 }
895
896 #[tokio::test]
897 async fn test_squash_merge_returns_committed_when_changes_exist() {
898 let dir = tempdir().expect("failed to create temp dir");
900 setup_test_git_repo(dir.path());
901 run_git_command(dir.path(), &["checkout", "-b", "feature-branch"]);
902 fs::write(dir.path().join("feature.txt"), "feature content").expect("failed to write file");
903 run_git_command(dir.path(), &["add", "feature.txt"]);
904 run_git_command(dir.path(), &["commit", "-m", "Add feature"]);
905 run_git_command(dir.path(), &["checkout", "main"]);
906
907 let result = squash_merge(
909 dir.path().to_path_buf(),
910 "feature-branch".to_string(),
911 "main".to_string(),
912 "Squash merge feature".to_string(),
913 )
914 .await;
915
916 assert_eq!(
918 result.expect("squash merge should succeed"),
919 SquashMergeOutcome::Committed,
920 );
921 }
922
923 #[tokio::test]
924 async fn test_squash_merge_returns_already_present_when_changes_exist_in_target() {
925 let dir = tempdir().expect("failed to create temp dir");
927 setup_test_git_repo(dir.path());
928 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
929 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
930 run_git_command(dir.path(), &["add", "session.txt"]);
931 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
932 run_git_command(dir.path(), &["checkout", "main"]);
933 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
934 run_git_command(dir.path(), &["add", "session.txt"]);
935 run_git_command(dir.path(), &["commit", "-m", "Apply same change on main"]);
936
937 let result = squash_merge(
939 dir.path().to_path_buf(),
940 "session-branch".to_string(),
941 "main".to_string(),
942 "Merge session".to_string(),
943 )
944 .await;
945
946 assert_eq!(
948 result.expect("squash merge should succeed"),
949 SquashMergeOutcome::AlreadyPresentInTarget,
950 );
951 }
952
953 #[tokio::test]
954 async fn test_commit_all_preserving_single_commit_creates_first_commit() {
955 let dir = tempdir().expect("failed to create temp dir");
957 setup_test_git_repo(dir.path());
958 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
959 let commit_message = "Session commit".to_string();
960 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
961
962 let result = commit_all_preserving_single_commit(
964 dir.path().to_path_buf(),
965 "main".to_string(),
966 commit_message.clone(),
967 SingleCommitMessageStrategy::Replace,
968 false,
969 )
970 .await;
971 let commit_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
972 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
973
974 assert!(
976 result.is_ok(),
977 "commit_all_preserving_single_commit should succeed: {result:?}"
978 );
979 assert_eq!(commit_count, "2");
980 assert_eq!(head_message, commit_message);
981 }
982
983 #[tokio::test]
984 async fn test_commit_all_preserving_single_commit_amends_existing_session_commit() {
985 let dir = tempdir().expect("failed to create temp dir");
987 setup_test_git_repo(dir.path());
988 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
989 let commit_message = "Session commit".to_string();
990 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
991 commit_all_preserving_single_commit(
992 dir.path().to_path_buf(),
993 "main".to_string(),
994 commit_message.clone(),
995 SingleCommitMessageStrategy::Replace,
996 false,
997 )
998 .await
999 .expect("failed to create first session commit");
1000 let first_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1001 let first_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1002
1003 fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1005 let result = commit_all_preserving_single_commit(
1006 dir.path().to_path_buf(),
1007 "main".to_string(),
1008 commit_message.clone(),
1009 SingleCommitMessageStrategy::Replace,
1010 false,
1011 )
1012 .await;
1013 let second_hash = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1014 let second_count = run_git_command_stdout(dir.path(), &["rev-list", "--count", "HEAD"]);
1015
1016 assert!(result.is_ok(), "amend commit should succeed: {result:?}");
1018 assert_ne!(first_hash, second_hash);
1019 assert_eq!(first_count, second_count);
1020 }
1021
1022 #[tokio::test]
1023 async fn test_commit_all_preserving_single_commit_replaces_amended_message() {
1024 let dir = tempdir().expect("failed to create temp dir");
1026 setup_test_git_repo(dir.path());
1027 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1028 fs::write(dir.path().join("work.txt"), "first change").expect("failed to write file");
1029 commit_all_preserving_single_commit(
1030 dir.path().to_path_buf(),
1031 "main".to_string(),
1032 "First session message".to_string(),
1033 SingleCommitMessageStrategy::Replace,
1034 false,
1035 )
1036 .await
1037 .expect("failed to create first session commit");
1038
1039 fs::write(dir.path().join("work.txt"), "second change").expect("failed to write file");
1041 let result = commit_all_preserving_single_commit(
1042 dir.path().to_path_buf(),
1043 "main".to_string(),
1044 "Refined session message".to_string(),
1045 SingleCommitMessageStrategy::Replace,
1046 false,
1047 )
1048 .await;
1049 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1050
1051 assert!(
1053 result.is_ok(),
1054 "replace amended message should succeed: {result:?}"
1055 );
1056 assert_eq!(head_message, "Refined session message");
1057 }
1058
1059 #[tokio::test]
1060 async fn test_commit_all_preserving_single_commit_retries_index_lock_and_succeeds() {
1061 let dir = tempdir().expect("failed to create temp dir");
1063 setup_test_git_repo(dir.path());
1064 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1065 let commit_message = "Session commit".to_string();
1066 fs::write(dir.path().join("work.txt"), "locked change").expect("failed to write file");
1067 let index_lock_path = dir.path().join(".git").join("index.lock");
1068 fs::write(&index_lock_path, "stale lock").expect("failed to write lock file");
1069 let lock_cleanup = thread::spawn(move || {
1070 thread::sleep(Duration::from_millis(250));
1071 let _ = fs::remove_file(index_lock_path);
1072 });
1073
1074 let result = commit_all_preserving_single_commit(
1076 dir.path().to_path_buf(),
1077 "main".to_string(),
1078 commit_message.clone(),
1079 SingleCommitMessageStrategy::Replace,
1080 false,
1081 )
1082 .await;
1083 lock_cleanup
1084 .join()
1085 .expect("failed to join lock cleanup thread");
1086 let head_message = run_git_command_stdout(dir.path(), &["log", "-1", "--pretty=%B"]);
1087
1088 assert!(
1090 result.is_ok(),
1091 "retry with index lock should succeed: {result:?}"
1092 );
1093 assert_eq!(head_message, commit_message);
1094 }
1095
1096 #[tokio::test]
1097 async fn test_diff_hides_leading_squash_merged_commit_for_non_rebased_session() {
1098 let dir = tempdir().expect("failed to create temp dir");
1100 setup_test_git_repo(dir.path());
1101 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1102 fs::write(dir.path().join("merged.txt"), "already merged change")
1103 .expect("failed to write merged file");
1104 run_git_command(dir.path(), &["add", "merged.txt"]);
1105 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1106 run_git_command(dir.path(), &["checkout", "main"]);
1107 run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1108 run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1109 run_git_command(dir.path(), &["checkout", "session-branch"]);
1110
1111 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1113 .await
1114 .expect("failed to load diff");
1115
1116 assert!(
1118 diff_output.trim().is_empty(),
1119 "expected no diff, got: {diff_output}"
1120 );
1121 }
1122
1123 #[tokio::test]
1124 async fn test_diff_keeps_new_commits_after_leading_squash_merged_commit() {
1125 let dir = tempdir().expect("failed to create temp dir");
1127 setup_test_git_repo(dir.path());
1128 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1129 fs::write(dir.path().join("merged.txt"), "already merged change")
1130 .expect("failed to write merged file");
1131 run_git_command(dir.path(), &["add", "merged.txt"]);
1132 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1133 run_git_command(dir.path(), &["checkout", "main"]);
1134 run_git_command(dir.path(), &["merge", "--squash", "session-branch"]);
1135 run_git_command(dir.path(), &["commit", "-m", "Squash merge session change"]);
1136 run_git_command(dir.path(), &["checkout", "session-branch"]);
1137 fs::write(dir.path().join("new.txt"), "new session-only change")
1138 .expect("failed to write new file");
1139 run_git_command(dir.path(), &["add", "new.txt"]);
1140 run_git_command(dir.path(), &["commit", "-m", "New session change"]);
1141
1142 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1144 .await
1145 .expect("failed to load diff");
1146
1147 assert!(diff_output.contains("new.txt"));
1149 assert!(!diff_output.contains("merged.txt"));
1150 }
1151
1152 #[tokio::test]
1153 async fn test_diff_does_not_include_base_only_commits() {
1154 let dir = tempdir().expect("failed to create temp dir");
1156 setup_test_git_repo(dir.path());
1157 run_git_command(dir.path(), &["checkout", "-b", "session-branch"]);
1158 fs::write(dir.path().join("session.txt"), "session change").expect("failed to write file");
1159 run_git_command(dir.path(), &["add", "session.txt"]);
1160 run_git_command(dir.path(), &["commit", "-m", "Session change"]);
1161 run_git_command(dir.path(), &["checkout", "main"]);
1162 fs::write(dir.path().join("main-only.txt"), "base branch only")
1163 .expect("failed to write base-only file");
1164 run_git_command(dir.path(), &["add", "main-only.txt"]);
1165 run_git_command(dir.path(), &["commit", "-m", "Main branch change"]);
1166 run_git_command(dir.path(), &["checkout", "session-branch"]);
1167
1168 let diff_output = diff(dir.path().to_path_buf(), "main".to_string())
1170 .await
1171 .expect("failed to load diff");
1172
1173 assert!(diff_output.contains("session.txt"));
1175 assert!(!diff_output.contains("main-only.txt"));
1176 }
1177
1178 #[tokio::test]
1179 async fn test_is_worktree_clean_returns_true_for_clean_repo() {
1180 let dir = tempdir().expect("failed to create temp dir");
1182 setup_test_git_repo(dir.path());
1183
1184 let is_clean = is_worktree_clean(dir.path().to_path_buf())
1186 .await
1187 .expect("failed to check worktree cleanliness");
1188
1189 assert!(is_clean);
1191 }
1192
1193 #[tokio::test]
1194 async fn test_is_worktree_clean_returns_false_for_dirty_repo() {
1195 let dir = tempdir().expect("failed to create temp dir");
1197 setup_test_git_repo(dir.path());
1198 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1199
1200 let is_clean = is_worktree_clean(dir.path().to_path_buf())
1202 .await
1203 .expect("failed to check worktree cleanliness");
1204
1205 assert!(!is_clean);
1207 }
1208
1209 #[tokio::test]
1210 async fn test_worktree_status_reports_dirty_repo_paths() {
1211 let dir = tempdir().expect("failed to create temp dir");
1213 setup_test_git_repo(dir.path());
1214 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1215 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1216
1217 let status = worktree_status(dir.path().to_path_buf())
1219 .await
1220 .expect("failed to read worktree status");
1221
1222 assert!(status.contains("README.md"));
1224 assert!(status.contains("new-file.txt"));
1225 }
1226
1227 #[tokio::test]
1228 async fn test_tracked_worktree_status_ignores_untracked_repo_paths() {
1229 let dir = tempdir().expect("failed to create temp dir");
1231 setup_test_git_repo(dir.path());
1232 fs::write(dir.path().join("README.md"), "dirty change").expect("failed to write change");
1233 fs::write(dir.path().join("new-file.txt"), "new").expect("failed to write new file");
1234
1235 let status = tracked_worktree_status(dir.path().to_path_buf())
1237 .await
1238 .expect("failed to read tracked worktree status");
1239
1240 assert!(status.contains("README.md"));
1242 assert!(!status.contains("new-file.txt"));
1243 }
1244
1245 #[tokio::test]
1246 async fn test_main_repo_root_returns_repo_root_for_main_worktree() {
1247 let dir = tempdir().expect("failed to create temp dir");
1249 setup_test_git_repo(dir.path());
1250
1251 let repo_root = main_repo_root(dir.path().to_path_buf())
1253 .await
1254 .expect("failed to resolve main repo root");
1255
1256 assert_eq!(
1258 canonicalize_test_path(&repo_root),
1259 canonicalize_test_path(dir.path())
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn test_main_repo_root_returns_shared_repo_root_for_linked_worktree() {
1265 let dir = tempdir().expect("failed to create temp dir");
1267 setup_test_git_repo(dir.path());
1268 let linked_worktree = dir.path().join("linked-worktree");
1269 create_worktree(
1270 dir.path().to_path_buf(),
1271 linked_worktree.clone(),
1272 "wt/main-repo-root-test".to_string(),
1273 "main".to_string(),
1274 )
1275 .await
1276 .expect("failed to create linked worktree");
1277
1278 let repo_root = main_repo_root(linked_worktree)
1280 .await
1281 .expect("failed to resolve shared repo root");
1282
1283 assert_eq!(
1285 canonicalize_test_path(&repo_root),
1286 canonicalize_test_path(dir.path())
1287 );
1288 }
1289
1290 #[tokio::test]
1291 async fn test_abort_rebase_returns_error_without_rebase_state_or_stale_metadata() {
1292 let dir = tempdir().expect("failed to create temp dir");
1294 setup_test_git_repo(dir.path());
1295
1296 let result = abort_rebase(dir.path().to_path_buf()).await;
1298
1299 assert!(result.is_err());
1301 }
1302
1303 #[tokio::test]
1304 async fn test_ref_hash_resolves_branch_head() {
1305 let dir = tempdir().expect("failed to create temp dir");
1307 setup_test_git_repo(dir.path());
1308 let expected_hash = run_git_command_stdout(dir.path(), &["rev-parse", "main"]);
1309
1310 let resolved_hash = ref_hash(dir.path().to_path_buf(), "main".to_string())
1312 .await
1313 .expect("failed to resolve main hash");
1314
1315 assert_eq!(resolved_hash, expected_hash);
1317 }
1318
1319 #[tokio::test]
1320 async fn test_rebase_onto_start_replays_commits_after_old_base() {
1321 let dir = tempdir().expect("failed to create temp dir");
1323 setup_test_git_repo(dir.path());
1324 run_git_command(dir.path(), &["checkout", "-b", "parent"]);
1325 fs::write(dir.path().join("parent.txt"), "parent").expect("failed to write parent file");
1326 run_git_command(dir.path(), &["add", "parent.txt"]);
1327 run_git_command(dir.path(), &["commit", "-m", "Parent change"]);
1328 let parent_tip = run_git_command_stdout(dir.path(), &["rev-parse", "HEAD"]);
1329 run_git_command(dir.path(), &["checkout", "-b", "child"]);
1330 fs::write(dir.path().join("child.txt"), "child").expect("failed to write child file");
1331 run_git_command(dir.path(), &["add", "child.txt"]);
1332 run_git_command(dir.path(), &["commit", "-m", "Child change"]);
1333 run_git_command(dir.path(), &["checkout", "main"]);
1334 fs::write(dir.path().join("main.txt"), "main").expect("failed to write main file");
1335 run_git_command(dir.path(), &["add", "main.txt"]);
1336 run_git_command(dir.path(), &["commit", "-m", "Main change"]);
1337 run_git_command(dir.path(), &["checkout", "child"]);
1338
1339 let result = rebase_onto_start(dir.path().to_path_buf(), "main".to_string(), parent_tip)
1341 .await
1342 .expect("failed to start rebase --onto");
1343 let child_only_subjects = run_git_command_stdout(
1344 dir.path(),
1345 &["log", "--format=%s", "--reverse", "main..HEAD"],
1346 );
1347
1348 assert_eq!(result, RebaseStepResult::Completed);
1350 assert_eq!(child_only_subjects, "Child change");
1351 assert!(!dir.path().join("parent.txt").exists());
1352 assert!(dir.path().join("child.txt").exists());
1353 }
1354
1355 #[tokio::test]
1356 async fn test_pull_rebase_returns_error_without_upstream() {
1357 let dir = tempdir().expect("failed to create temp dir");
1359 setup_test_git_repo(dir.path());
1360
1361 let result = pull_rebase(dir.path().to_path_buf()).await;
1363
1364 assert!(result.is_err());
1366 }
1367
1368 #[tokio::test]
1369 async fn test_pull_rebase_targets_single_upstream_when_merge_targets_are_ambiguous() {
1370 let dir = tempdir().expect("failed to create temp dir");
1372 let remote_dir = tempdir().expect("failed to create remote temp dir");
1373 setup_test_git_repo(dir.path());
1374 run_git_command(remote_dir.path(), &["init", "--bare"]);
1375
1376 let remote_path = remote_dir.path().to_string_lossy().to_string();
1377 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1378 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1379
1380 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1381 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1382 run_git_command(dir.path(), &["add", "feature.txt"]);
1383 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1384 run_git_command(dir.path(), &["push", "-u", "origin", "feature"]);
1385 run_git_command(dir.path(), &["checkout", "main"]);
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 should complete: {result:?}"
1415 );
1416 }
1417
1418 #[tokio::test]
1419 async fn test_pull_rebase_targets_local_upstream_when_upstream_name_has_no_remote_prefix() {
1420 let dir = tempdir().expect("failed to create temp dir");
1422 setup_test_git_repo(dir.path());
1423
1424 run_git_command(dir.path(), &["checkout", "-b", "feature"]);
1425 fs::write(dir.path().join("feature.txt"), "feature change").expect("failed to write file");
1426 run_git_command(dir.path(), &["add", "feature.txt"]);
1427 run_git_command(dir.path(), &["commit", "-m", "Add feature branch"]);
1428 run_git_command(dir.path(), &["checkout", "main"]);
1429
1430 run_git_command(dir.path(), &["config", "branch.main.remote", "."]);
1431 run_git_command(
1432 dir.path(),
1433 &[
1434 "config",
1435 "--replace-all",
1436 "branch.main.merge",
1437 "refs/heads/main",
1438 ],
1439 );
1440 run_git_command(
1441 dir.path(),
1442 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1443 );
1444
1445 let pull_without_explicit_target = Command::new("git")
1446 .args(["pull", "--rebase"])
1447 .current_dir(dir.path())
1448 .output()
1449 .expect("failed to run pull --rebase");
1450
1451 assert!(
1452 !pull_without_explicit_target.status.success(),
1453 "expected plain pull --rebase to fail in ambiguous merge-target setup"
1454 );
1455 assert!(
1456 String::from_utf8_lossy(&pull_without_explicit_target.stderr)
1457 .contains("Cannot rebase onto multiple branches"),
1458 "expected ambiguous merge-target failure"
1459 );
1460
1461 let result = pull_rebase(dir.path().to_path_buf()).await;
1463
1464 assert!(
1466 matches!(result, Ok(PullRebaseResult::Completed)),
1467 "pull_rebase with local upstream should complete: {result:?}"
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn test_list_upstream_commit_titles_returns_error_without_upstream() {
1473 let dir = tempdir().expect("failed to create temp dir");
1475 setup_test_git_repo(dir.path());
1476
1477 let result = list_upstream_commit_titles(dir.path().to_path_buf()).await;
1479
1480 assert!(result.is_err());
1482 }
1483
1484 #[tokio::test]
1485 async fn test_list_upstream_commit_titles_returns_new_upstream_commit_titles() {
1486 let dir = tempdir().expect("failed to create temp dir");
1488 let remote_dir = tempdir().expect("failed to create remote temp dir");
1489 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1490 let contributor_clone_path = contributor_dir.path().join("clone");
1491 setup_test_git_repo(dir.path());
1492 run_git_command(remote_dir.path(), &["init", "--bare"]);
1493
1494 let remote_path = remote_dir.path().to_string_lossy().to_string();
1495 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1496 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1497 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1498
1499 run_git_command(
1500 contributor_dir.path(),
1501 &["clone", &remote_path, &contributor_clone_path_text],
1502 );
1503 run_git_command(
1504 &contributor_clone_path,
1505 &["config", "user.name", "Contributor User"],
1506 );
1507 run_git_command(
1508 &contributor_clone_path,
1509 &["config", "user.email", "contributor@example.com"],
1510 );
1511 run_git_command(
1512 &contributor_clone_path,
1513 &["checkout", "-B", "main", "origin/main"],
1514 );
1515 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1516 .expect("failed to write remote change");
1517 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1518 run_git_command(
1519 &contributor_clone_path,
1520 &["commit", "-m", "Remote commit title"],
1521 );
1522 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1523 run_git_command(dir.path(), &["fetch", "origin"]);
1524
1525 let titles = list_upstream_commit_titles(dir.path().to_path_buf())
1527 .await
1528 .expect("failed to list upstream commit titles");
1529
1530 assert_eq!(titles, vec!["Remote commit title".to_string()]);
1532 }
1533
1534 #[tokio::test]
1535 async fn test_list_local_commit_titles_returns_error_without_upstream() {
1536 let dir = tempdir().expect("failed to create temp dir");
1538 setup_test_git_repo(dir.path());
1539
1540 let result = list_local_commit_titles(dir.path().to_path_buf()).await;
1542
1543 assert!(result.is_err());
1545 }
1546
1547 #[tokio::test]
1548 async fn test_list_local_commit_titles_returns_new_local_commit_titles() {
1549 let dir = tempdir().expect("failed to create temp dir");
1551 let remote_dir = tempdir().expect("failed to create remote temp dir");
1552 setup_test_git_repo(dir.path());
1553 run_git_command(remote_dir.path(), &["init", "--bare"]);
1554
1555 let remote_path = remote_dir.path().to_string_lossy().to_string();
1556 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1557 run_git_command(dir.path(), &["push", "-u", "origin", "main"]);
1558
1559 fs::write(dir.path().join("local_1.txt"), "local change 1")
1560 .expect("failed to write local change 1");
1561 run_git_command(dir.path(), &["add", "local_1.txt"]);
1562 run_git_command(dir.path(), &["commit", "-m", "Local commit title one"]);
1563
1564 fs::write(dir.path().join("local_2.txt"), "local change 2")
1565 .expect("failed to write local change 2");
1566 run_git_command(dir.path(), &["add", "local_2.txt"]);
1567 run_git_command(dir.path(), &["commit", "-m", "Local commit title two"]);
1568
1569 let titles = list_local_commit_titles(dir.path().to_path_buf())
1571 .await
1572 .expect("failed to list local commit titles");
1573
1574 assert_eq!(
1576 titles,
1577 vec![
1578 "Local commit title one".to_string(),
1579 "Local commit title two".to_string(),
1580 ]
1581 );
1582 }
1583
1584 #[tokio::test]
1585 async fn test_push_current_branch_returns_error_without_remote() {
1586 let dir = tempdir().expect("failed to create temp dir");
1588 setup_test_git_repo(dir.path());
1589
1590 let result = push_current_branch(dir.path().to_path_buf()).await;
1592
1593 assert!(result.is_err());
1595 }
1596
1597 #[tokio::test]
1598 async fn test_push_current_branch_returns_upstream_reference() {
1599 let dir = tempdir().expect("failed to create temp dir");
1601 let remote_dir = tempdir().expect("failed to create remote temp dir");
1602 setup_test_git_repo(dir.path());
1603 run_git_command(remote_dir.path(), &["init", "--bare"]);
1604 let remote_path = remote_dir.path().to_string_lossy().to_string();
1605 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1606
1607 let upstream_reference = push_current_branch(dir.path().to_path_buf())
1609 .await
1610 .expect("push should set upstream");
1611
1612 assert_eq!(upstream_reference, "origin/main");
1614 }
1615
1616 #[tokio::test]
1617 async fn test_push_current_branch_to_remote_branch_returns_upstream_reference() {
1618 let dir = tempdir().expect("failed to create temp dir");
1620 let remote_dir = tempdir().expect("failed to create remote temp dir");
1621 setup_test_git_repo(dir.path());
1622 run_git_command(remote_dir.path(), &["init", "--bare"]);
1623 let remote_path = remote_dir.path().to_string_lossy().to_string();
1624 run_git_command(dir.path(), &["remote", "add", "origin", &remote_path]);
1625
1626 let upstream_reference = push_current_branch_to_remote_branch(
1628 dir.path().to_path_buf(),
1629 "review/custom-branch".to_string(),
1630 )
1631 .await
1632 .expect("push should set a custom upstream");
1633
1634 assert_eq!(upstream_reference, "origin/review/custom-branch");
1636 }
1637
1638 #[test]
1639 fn test_is_no_upstream_error_detects_upstream_hint() {
1640 let detail = "fatal: The current branch main has no upstream branch.";
1642
1643 let is_no_upstream = sync::is_no_upstream_error(detail);
1645
1646 assert!(is_no_upstream);
1648 }
1649
1650 #[test]
1651 fn test_is_rebase_conflict_detects_conflict_keyword() {
1652 let detail = "CONFLICT (content): Merge conflict in src/main.rs";
1654
1655 assert!(rebase::is_rebase_conflict(detail));
1657 }
1658
1659 #[test]
1660 fn test_is_rebase_conflict_detects_could_not_apply() {
1661 let detail = "error: could not apply abc1234... Update handler";
1663
1664 assert!(rebase::is_rebase_conflict(detail));
1666 }
1667
1668 #[test]
1669 fn test_is_rebase_conflict_detects_mark_as_resolved() {
1670 let detail = "hint: mark them as resolved using git add";
1672
1673 assert!(rebase::is_rebase_conflict(detail));
1675 }
1676
1677 #[test]
1678 fn test_is_rebase_conflict_detects_unresolved_conflict() {
1679 let detail = "fatal: Exiting because of an unresolved conflict.";
1681
1682 assert!(rebase::is_rebase_conflict(detail));
1684 }
1685
1686 #[test]
1687 fn test_is_rebase_conflict_detects_committing_not_possible() {
1688 let detail = "error: Committing is not possible because you have unmerged files.";
1690
1691 assert!(rebase::is_rebase_conflict(detail));
1693 }
1694
1695 #[test]
1696 fn test_is_rebase_conflict_returns_false_for_unrelated_error() {
1697 let detail = "fatal: not a git repository (or any parent up to mount point /)";
1699
1700 assert!(!rebase::is_rebase_conflict(detail));
1702 }
1703
1704 #[test]
1705 fn test_is_rebase_conflict_returns_false_for_index_lock_error() {
1706 let detail = "fatal: Unable to create '.git/index.lock': File exists.";
1708
1709 assert!(!rebase::is_rebase_conflict(detail));
1711 }
1712}