1use std::collections::HashMap;
2use std::io::Read;
3use std::path::{Component, Path, PathBuf};
4use std::process::Output;
5use std::time::Duration;
6
7#[cfg(unix)]
8use rustix::fs::{self as rustix_fs, Access};
9use tokio::task::spawn_blocking;
10use tokio::time;
11
12use super::error::GitError;
13use super::rebase::{
14 GIT_INDEX_LOCK_RETRY_ATTEMPTS, GIT_INDEX_LOCK_RETRY_DELAY, is_git_index_lock_error,
15 is_rebase_conflict, run_git_command_with_index_lock_retry,
16};
17use super::repo::{
18 AsyncGitCommand, AsyncGitCommandOutput, AsyncGitCommandRunner, ProcessAsyncGitCommandRunner,
19 command_output_detail, run_git_command, run_git_command_output_sync,
20 run_git_command_output_with_env_sync, run_git_command_sync, run_git_command_with_runner,
21};
22
23pub type BranchTrackingMap = HashMap<String, Option<(u32, u32)>>;
26
27const COMMIT_ALL_HOOK_RETRY_ATTEMPTS: usize = 5;
28const MAX_WORKTREE_FILE_BYTE_COUNT: usize = 1024 * 1024;
29const PRE_COMMIT_CONFIG_FILES: [&str; 2] = [".pre-commit-config.yaml", ".pre-commit-config.yml"];
30
31#[derive(Clone, Debug, Eq, PartialEq)]
33pub enum WorktreeFileContent {
34 Text(String),
36 Missing,
38 Binary,
40 TooLarge,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SingleCommitMessageStrategy {
48 Replace,
50 Reuse,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum PullRebaseResult {
57 Completed,
59 Conflict {
61 detail: String,
63 },
64}
65
66pub(crate) async fn commit_all(
80 repo_path: PathBuf,
81 commit_message: String,
82 no_verify: bool,
83) -> Result<(), GitError> {
84 commit_all_with_retry(
85 repo_path,
86 commit_message,
87 SingleCommitMessageStrategy::Replace,
88 no_verify,
89 false,
90 )
91 .await
92}
93
94pub(crate) async fn commit_all_preserving_single_commit(
116 repo_path: PathBuf,
117 base_branch: String,
118 commit_message: String,
119 message_strategy: SingleCommitMessageStrategy,
120 no_verify: bool,
121) -> Result<(), GitError> {
122 let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
123
124 commit_all_with_retry(
125 repo_path,
126 commit_message,
127 message_strategy,
128 no_verify,
129 amend_existing_commit,
130 )
131 .await
132}
133
134pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
145 spawn_blocking(move || stage_all_sync(&repo_path)).await?
146}
147
148pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
155 spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
156}
157
158pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
169 let hash = run_git_command(
170 repo_path,
171 vec![
172 "rev-parse".to_string(),
173 "--short".to_string(),
174 "HEAD".to_string(),
175 ],
176 "Failed to resolve HEAD hash".to_string(),
177 )
178 .await?;
179 let hash = hash.trim().to_string();
180 if hash.is_empty() {
181 return Err(GitError::OutputParse(
182 "Failed to resolve HEAD hash: empty output".to_string(),
183 ));
184 }
185
186 Ok(hash)
187}
188
189pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
200 let hash = run_git_command(
201 repo_path,
202 vec!["rev-parse".to_string(), "HEAD".to_string()],
203 "Failed to resolve HEAD hash".to_string(),
204 )
205 .await?;
206 let hash = hash.trim().to_string();
207 if hash.is_empty() {
208 return Err(GitError::OutputParse(
209 "Failed to resolve HEAD hash: empty output".to_string(),
210 ));
211 }
212
213 Ok(hash)
214}
215
216pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
228 let hash = run_git_command(
229 repo_path,
230 vec![
231 "rev-parse".to_string(),
232 "--verify".to_string(),
233 format!("{reference}^{{commit}}"),
234 ],
235 format!("Failed to resolve `{reference}` hash"),
236 )
237 .await?;
238 let hash = hash.trim().to_string();
239 if hash.is_empty() {
240 return Err(GitError::OutputParse(format!(
241 "Failed to resolve `{reference}` hash: empty output"
242 )));
243 }
244
245 Ok(hash)
246}
247
248pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
253 spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
254}
255
256pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
271 run_git_command(
272 repo_path,
273 vec!["branch".to_string(), "-D".to_string(), branch_name],
274 "Git branch deletion failed".to_string(),
275 )
276 .await?;
277
278 Ok(())
279}
280
281pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
303 diff_output(repo_path, base_branch, false).await
304}
305
306pub(crate) async fn diff_changed_files(
309 repo_path: PathBuf,
310 base_branch: String,
311) -> Result<Vec<String>, GitError> {
312 let output = diff_output(repo_path, base_branch, true).await?;
313
314 Ok(output
315 .lines()
316 .map(str::trim)
317 .filter(|path| !path.is_empty())
318 .map(str::to_string)
319 .collect())
320}
321
322async fn diff_output(
325 repo_path: PathBuf,
326 base_branch: String,
327 name_only: bool,
328) -> Result<String, GitError> {
329 spawn_blocking(move || -> Result<String, GitError> {
330 let index_path = run_git_command_sync(
331 &repo_path,
332 &["rev-parse", "--git-path", "index"],
333 "Git index path resolution failed",
334 )?;
335 let index_path = PathBuf::from(index_path.trim());
336 let index_path = if index_path.is_absolute() {
337 index_path
338 } else {
339 repo_path.join(index_path)
340 };
341 let temporary_index = copy_git_index_to_temp(&index_path)?;
342
343 run_git_command_with_index_sync(
344 &repo_path,
345 &["add", "-A", "--intent-to-add"],
346 &temporary_index,
347 "Git add --intent-to-add failed",
348 )?;
349
350 let merge_base_output =
351 run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
352
353 let diff_target = if merge_base_output.status.success() {
354 resolve_diff_target(
355 &repo_path,
356 &base_branch,
357 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
358 )?
359 } else {
360 base_branch
361 };
362
363 let args = if name_only {
364 vec!["diff", "--name-only", diff_target.as_str()]
365 } else {
366 vec!["diff", diff_target.as_str()]
367 };
368
369 run_git_command_with_index_sync(&repo_path, &args, &temporary_index, "Git diff failed")
370 })
371 .await?
372}
373
374pub(crate) async fn read_worktree_file(
383 repo_path: PathBuf,
384 relative_path: String,
385) -> Result<WorktreeFileContent, GitError> {
386 spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
387}
388
389fn read_worktree_file_sync(
391 repo_path: &Path,
392 relative_path: &str,
393) -> Result<WorktreeFileContent, GitError> {
394 let relative_file_path = Path::new(relative_path);
395 if relative_path.is_empty()
396 || relative_file_path
397 .components()
398 .any(|component| !matches!(component, Component::Normal(_)))
399 {
400 return Err(GitError::OutputParse(format!(
401 "Unsafe worktree file path: {relative_path}"
402 )));
403 }
404
405 let canonical_repo_path = std::fs::canonicalize(repo_path)?;
406 let candidate_path = repo_path.join(relative_file_path);
407 let canonical_file_path = match std::fs::canonicalize(candidate_path) {
408 Ok(path) => path,
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
410 return Ok(WorktreeFileContent::Missing);
411 }
412 Err(error) => return Err(error.into()),
413 };
414 if !canonical_file_path.starts_with(canonical_repo_path) {
415 return Err(GitError::OutputParse(format!(
416 "Worktree file resolves outside repository: {relative_path}"
417 )));
418 }
419
420 let file = std::fs::File::open(canonical_file_path)?;
421 let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
422 file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
423 .read_to_end(&mut bytes)?;
424
425 Ok(worktree_file_content(bytes))
426}
427
428fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
430 if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
431 return WorktreeFileContent::TooLarge;
432 }
433
434 match String::from_utf8(bytes) {
435 Ok(content) => WorktreeFileContent::Text(content),
436 Err(_) => WorktreeFileContent::Binary,
437 }
438}
439
440fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
443 let index_parent = index_path.parent().ok_or_else(|| {
444 GitError::OutputParse(format!(
445 "Git index path has no parent: {}",
446 index_path.display()
447 ))
448 })?;
449 let temporary_index =
450 tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
451 command: "create temporary git index".to_string(),
452 stderr: error.to_string(),
453 })?;
454 std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
455 command: "copy git index".to_string(),
456 stderr: error.to_string(),
457 })?;
458
459 Ok(temporary_index.into_temp_path())
460}
461
462fn run_git_command_with_index_sync(
465 repo_path: &Path,
466 args: &[&str],
467 index_path: &Path,
468 error_context: &str,
469) -> Result<String, GitError> {
470 let output = run_git_command_output_with_env_sync(
471 repo_path,
472 args,
473 &[("GIT_INDEX_FILE", index_path.as_os_str())],
474 )?;
475 if !output.status.success() {
476 return Err(GitError::CommandFailed {
477 command: format!("git {}", args.join(" ")),
478 stderr: format!(
479 "{error_context}: {}",
480 command_output_detail(&output.stdout, &output.stderr)
481 ),
482 });
483 }
484
485 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
486}
487
488pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
499 let status_output = worktree_status(repo_path).await?;
500
501 Ok(status_output.trim().is_empty())
502}
503
504pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
518 run_git_command(
519 repo_path,
520 vec![
521 "status".to_string(),
522 "--porcelain=v1".to_string(),
523 "--untracked-files=all".to_string(),
524 ],
525 "Git status --porcelain=v1 failed".to_string(),
526 )
527 .await
528}
529
530pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
545 run_git_command(
546 repo_path,
547 vec![
548 "status".to_string(),
549 "--porcelain=v1".to_string(),
550 "--untracked-files=no".to_string(),
551 ],
552 "Git tracked status --porcelain=v1 failed".to_string(),
553 )
554 .await
555}
556
557pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
573 let command_runner = ProcessAsyncGitCommandRunner;
574
575 pull_rebase_with_runner(repo_path, &command_runner, GIT_INDEX_LOCK_RETRY_DELAY).await
576}
577
578async fn pull_rebase_with_runner(
580 repo_path: PathBuf,
581 command_runner: &dyn AsyncGitCommandRunner,
582 retry_delay: Duration,
583) -> Result<PullRebaseResult, GitError> {
584 let pull_arguments = pull_rebase_arguments(&repo_path, command_runner).await?;
585 let command = AsyncGitCommand::new(repo_path, pull_arguments).with_environment(vec![
586 ("GIT_EDITOR".to_string(), ":".to_string()),
587 ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
588 ]);
589 let output =
590 run_async_git_command_with_index_lock_retry(command, command_runner, retry_delay).await?;
591
592 if output.success() {
593 return Ok(PullRebaseResult::Completed);
594 }
595
596 let detail = command_output_detail(&output.stdout, &output.stderr);
597 if is_rebase_conflict(&detail) {
598 return Ok(PullRebaseResult::Conflict { detail });
599 }
600
601 Err(GitError::CommandFailed {
602 command: "git pull --rebase".to_string(),
603 stderr: detail,
604 })
605}
606
607async fn pull_rebase_arguments(
612 repo_path: &Path,
613 command_runner: &dyn AsyncGitCommandRunner,
614) -> Result<Vec<String>, GitError> {
615 let upstream_reference = primary_upstream_reference(repo_path, command_runner).await?;
616
617 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
618 return Ok(vec![
619 "pull".to_string(),
620 "--rebase".to_string(),
621 remote_name.to_string(),
622 branch_name.to_string(),
623 ]);
624 }
625
626 let remote_name = current_branch_remote_name(repo_path, command_runner)
627 .await?
628 .ok_or_else(|| {
629 GitError::OutputParse(
630 "Failed to resolve current branch remote: not configured".to_string(),
631 )
632 })?;
633
634 Ok(vec![
635 "pull".to_string(),
636 "--rebase".to_string(),
637 remote_name,
638 upstream_reference,
639 ])
640}
641
642async fn primary_upstream_reference(
648 repo_path: &Path,
649 command_runner: &dyn AsyncGitCommandRunner,
650) -> Result<String, GitError> {
651 let upstream_reference = upstream_reference_name(repo_path, command_runner).await?;
652 let Some(primary_reference) = upstream_reference
653 .lines()
654 .map(str::trim)
655 .find(|line| !line.is_empty())
656 else {
657 return Err(GitError::OutputParse(
658 "Failed to resolve upstream branch: empty output".to_string(),
659 ));
660 };
661
662 Ok(primary_reference.to_string())
663}
664
665async fn upstream_reference_name(
667 repo_path: &Path,
668 command_runner: &dyn AsyncGitCommandRunner,
669) -> Result<String, GitError> {
670 let upstream_reference = run_git_command_with_runner(
671 AsyncGitCommand::new(
672 repo_path.to_path_buf(),
673 vec![
674 "rev-parse".to_string(),
675 "--abbrev-ref".to_string(),
676 "--symbolic-full-name".to_string(),
677 "@{u}".to_string(),
678 ],
679 ),
680 "Failed to resolve upstream branch",
681 command_runner,
682 )
683 .await?;
684 let upstream_reference = upstream_reference.trim().to_string();
685 if upstream_reference.is_empty() {
686 return Err(GitError::OutputParse(
687 "Failed to resolve upstream branch: empty output".to_string(),
688 ));
689 }
690
691 Ok(upstream_reference)
692}
693
694async fn current_branch_remote_name(
699 repo_path: &Path,
700 command_runner: &dyn AsyncGitCommandRunner,
701) -> Result<Option<String>, GitError> {
702 let current_branch_name = current_branch_name(repo_path, command_runner).await?;
703 let remote_config_key = format!("branch.{current_branch_name}.remote");
704 let output = command_runner
705 .run(AsyncGitCommand::new(
706 repo_path.to_path_buf(),
707 vec![
708 "config".to_string(),
709 "--get".to_string(),
710 remote_config_key.clone(),
711 ],
712 ))
713 .await?;
714
715 parse_current_branch_remote_output(&output, &remote_config_key)
716}
717
718fn parse_current_branch_remote_output(
720 output: &AsyncGitCommandOutput,
721 remote_config_key: &str,
722) -> Result<Option<String>, GitError> {
723 if output.exit_code == Some(1) {
724 return Ok(None);
725 }
726 if !output.success() {
727 let detail = command_output_detail(&output.stdout, &output.stderr);
728
729 return Err(GitError::CommandFailed {
730 command: format!("git config --get {remote_config_key}"),
731 stderr: format!(
732 "Failed to resolve current branch remote `{remote_config_key}`: {detail}"
733 ),
734 });
735 }
736
737 let remote_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
738 if remote_name.is_empty() {
739 return Err(GitError::OutputParse(format!(
740 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
741 )));
742 }
743
744 Ok(Some(remote_name))
745}
746
747async fn current_branch_name(
749 repo_path: &Path,
750 command_runner: &dyn AsyncGitCommandRunner,
751) -> Result<String, GitError> {
752 let branch_name = run_git_command_with_runner(
753 AsyncGitCommand::new(
754 repo_path.to_path_buf(),
755 vec![
756 "rev-parse".to_string(),
757 "--abbrev-ref".to_string(),
758 "HEAD".to_string(),
759 ],
760 ),
761 "Failed to resolve current branch name",
762 command_runner,
763 )
764 .await?;
765 let branch_name = branch_name.trim().to_string();
766 if branch_name.is_empty() {
767 return Err(GitError::OutputParse(
768 "Failed to resolve current branch name: empty output".to_string(),
769 ));
770 }
771
772 if branch_name == "HEAD" {
773 return Err(GitError::OutputParse(
774 "Failed to resolve current branch name: detached HEAD".to_string(),
775 ));
776 }
777
778 Ok(branch_name)
779}
780
781async fn run_async_git_command_with_index_lock_retry(
784 command: AsyncGitCommand,
785 command_runner: &dyn AsyncGitCommandRunner,
786 retry_delay: Duration,
787) -> Result<AsyncGitCommandOutput, GitError> {
788 let mut attempt_count = 0;
789 loop {
790 attempt_count += 1;
791 let output = command_runner.run(command.clone()).await?;
792 if output.success() {
793 return Ok(output);
794 }
795
796 let detail = command_output_detail(&output.stdout, &output.stderr);
797 let is_last_attempt = attempt_count == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
798 if !is_git_index_lock_error(&detail) || is_last_attempt {
799 return Ok(output);
800 }
801
802 time::sleep(retry_delay).await;
803 }
804}
805
806pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
823 let command_runner = ProcessAsyncGitCommandRunner;
824
825 push_current_branch_with_runner(repo_path, &command_runner).await
826}
827
828async fn push_current_branch_with_runner(
831 repo_path: PathBuf,
832 command_runner: &dyn AsyncGitCommandRunner,
833) -> Result<String, GitError> {
834 let push_command = AsyncGitCommand::new(
835 repo_path.clone(),
836 vec!["push".to_string(), "--force-with-lease".to_string()],
837 );
838 let push_output = command_runner.run(push_command).await?;
839
840 if push_output.success() {
841 return primary_upstream_reference(&repo_path, command_runner).await;
842 }
843
844 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
845 if !is_no_upstream_error(&push_detail) {
846 return Err(GitError::CommandFailed {
847 command: "git push --force-with-lease".to_string(),
848 stderr: push_detail,
849 });
850 }
851
852 let remote_name = current_branch_remote_name(&repo_path, command_runner)
853 .await?
854 .unwrap_or_else(|| "origin".to_string());
855 run_git_command_with_runner(
856 AsyncGitCommand::new(
857 repo_path.clone(),
858 vec![
859 "push".to_string(),
860 "--force-with-lease".to_string(),
861 "--set-upstream".to_string(),
862 remote_name,
863 "HEAD".to_string(),
864 ],
865 ),
866 "Git push failed",
867 command_runner,
868 )
869 .await?;
870
871 primary_upstream_reference(&repo_path, command_runner).await
872}
873
874pub(crate) async fn remote_branch_exists(
887 repo_path: PathBuf,
888 remote_branch_name: String,
889) -> Result<bool, GitError> {
890 let command_runner = ProcessAsyncGitCommandRunner;
891
892 remote_branch_exists_with_runner(repo_path, remote_branch_name, &command_runner).await
893}
894
895async fn remote_branch_exists_with_runner(
897 repo_path: PathBuf,
898 remote_branch_name: String,
899 command_runner: &dyn AsyncGitCommandRunner,
900) -> Result<bool, GitError> {
901 let remote_name = current_branch_remote_name(&repo_path, command_runner)
902 .await?
903 .unwrap_or_else(|| "origin".to_string());
904 let arguments = vec![
905 "ls-remote".to_string(),
906 "--heads".to_string(),
907 remote_name,
908 remote_branch_name,
909 ];
910 let stdout = run_git_command_with_runner(
911 AsyncGitCommand::new(repo_path, arguments),
912 "Git ls-remote failed",
913 command_runner,
914 )
915 .await?;
916
917 Ok(!stdout.trim().is_empty())
918}
919
920pub(crate) async fn push_current_branch_to_remote_branch(
937 repo_path: PathBuf,
938 remote_branch_name: String,
939) -> Result<String, GitError> {
940 let command_runner = ProcessAsyncGitCommandRunner;
941
942 push_current_branch_to_remote_branch_with_runner(repo_path, remote_branch_name, &command_runner)
943 .await
944}
945
946async fn push_current_branch_to_remote_branch_with_runner(
949 repo_path: PathBuf,
950 remote_branch_name: String,
951 command_runner: &dyn AsyncGitCommandRunner,
952) -> Result<String, GitError> {
953 let remote_name = current_branch_remote_name(&repo_path, command_runner)
954 .await?
955 .unwrap_or_else(|| "origin".to_string());
956 let push_refspec = format!("HEAD:{remote_branch_name}");
957 let arguments = vec![
958 "push".to_string(),
959 "--force-with-lease".to_string(),
960 "--set-upstream".to_string(),
961 remote_name.clone(),
962 push_refspec,
963 ];
964 run_git_command_with_runner(
965 AsyncGitCommand::new(repo_path, arguments),
966 "Git push failed",
967 command_runner,
968 )
969 .await?;
970
971 Ok(format!("{remote_name}/{remote_branch_name}"))
972}
973
974pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
986 let command_runner = ProcessAsyncGitCommandRunner;
987
988 primary_upstream_reference(&repo_path, &command_runner).await
989}
990
991pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
1002 run_git_command(
1003 repo_path,
1004 vec!["fetch".to_string()],
1005 "Git fetch failed".to_string(),
1006 )
1007 .await?;
1008
1009 Ok(())
1010}
1011
1012pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
1024 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
1025}
1026
1027pub(crate) async fn get_ref_ahead_behind(
1037 repo_path: PathBuf,
1038 left_ref: String,
1039 right_ref: String,
1040) -> Result<(u32, u32), GitError> {
1041 let rev_list_output = run_git_command(
1042 repo_path,
1043 vec![
1044 "rev-list".to_string(),
1045 "--left-right".to_string(),
1046 "--count".to_string(),
1047 format!("{left_ref}...{right_ref}"),
1048 ],
1049 "Git rev-list failed".to_string(),
1050 )
1051 .await?;
1052
1053 parse_ahead_behind_counts(&rev_list_output)
1054}
1055
1056fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
1059 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
1060 if parts.len() >= 2 {
1061 let ahead = parts[0].parse().unwrap_or(0);
1062 let behind = parts[1].parse().unwrap_or(0);
1063
1064 return Ok((ahead, behind));
1065 }
1066
1067 Err(GitError::OutputParse(
1068 "Unexpected output format from git rev-list".to_string(),
1069 ))
1070}
1071
1072pub(crate) async fn branch_tracking_statuses(
1081 repo_path: PathBuf,
1082) -> Result<BranchTrackingMap, GitError> {
1083 let git_output = run_git_command(
1084 repo_path,
1085 vec![
1086 "for-each-ref".to_string(),
1087 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
1088 "refs/heads".to_string(),
1089 ],
1090 "Git for-each-ref failed".to_string(),
1091 )
1092 .await?;
1093
1094 Ok(parse_branch_tracking_statuses(&git_output))
1095}
1096
1097pub(crate) async fn list_upstream_commit_titles(
1108 repo_path: PathBuf,
1109) -> Result<Vec<String>, GitError> {
1110 let git_output = run_git_command(
1111 repo_path,
1112 vec![
1113 "log".to_string(),
1114 "--reverse".to_string(),
1115 "--pretty=%s".to_string(),
1116 "HEAD..@{u}".to_string(),
1117 ],
1118 "Git log failed".to_string(),
1119 )
1120 .await?;
1121
1122 Ok(parse_commit_titles(&git_output))
1123}
1124
1125pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
1136 let git_output = run_git_command(
1137 repo_path,
1138 vec![
1139 "log".to_string(),
1140 "--reverse".to_string(),
1141 "--pretty=%s".to_string(),
1142 "@{u}..HEAD".to_string(),
1143 ],
1144 "Git log failed".to_string(),
1145 )
1146 .await?;
1147
1148 Ok(parse_commit_titles(&git_output))
1149}
1150
1151pub(crate) async fn has_commits_since(
1157 repo_path: PathBuf,
1158 base_branch: String,
1159) -> Result<bool, GitError> {
1160 spawn_blocking(move || -> Result<bool, GitError> {
1161 let rev_list_output = run_git_command_sync(
1162 &repo_path,
1163 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
1164 "Failed to count commits since base branch",
1165 )?;
1166 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
1167 GitError::OutputParse(format!(
1168 "Failed to parse commit count since base branch `{base_branch}`: {error}"
1169 ))
1170 })?;
1171
1172 Ok(commit_count > 0)
1173 })
1174 .await?
1175}
1176
1177fn parse_commit_titles(output: &str) -> Vec<String> {
1179 output
1180 .lines()
1181 .map(str::trim)
1182 .filter(|title| !title.is_empty())
1183 .map(ToString::to_string)
1184 .collect()
1185}
1186
1187fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1189 let mut branch_tracking_statuses = HashMap::new();
1190
1191 for line in output
1192 .lines()
1193 .map(str::trim)
1194 .filter(|line| !line.is_empty())
1195 {
1196 let mut parts = line.splitn(3, '\t');
1197 let Some(branch_name) = parts
1198 .next()
1199 .map(str::trim)
1200 .filter(|value| !value.is_empty())
1201 else {
1202 continue;
1203 };
1204 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1205 let track = parts.next().map(str::trim).unwrap_or_default();
1206
1207 let status = if upstream_ref.is_empty() {
1208 None
1209 } else {
1210 parse_branch_tracking_counts(track)
1211 };
1212 branch_tracking_statuses.insert(branch_name.to_string(), status);
1213 }
1214
1215 branch_tracking_statuses
1216}
1217
1218fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1220 let normalized_track = track.trim();
1221 if normalized_track.is_empty() || normalized_track == "gone" {
1222 return None;
1223 }
1224
1225 let mut ahead = 0;
1226 let mut behind = 0;
1227
1228 for part in normalized_track.split(',').map(str::trim) {
1229 if let Some(count) = part.strip_prefix("ahead ") {
1230 ahead = count.parse().ok()?;
1231 } else if let Some(count) = part.strip_prefix("behind ") {
1232 behind = count.parse().ok()?;
1233 }
1234 }
1235
1236 Some((ahead, behind))
1237}
1238
1239fn resolve_diff_target(
1245 repo_path: &Path,
1246 base_branch: &str,
1247 merge_base: &str,
1248) -> Result<String, GitError> {
1249 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1250 if !cherry_output.status.success() {
1251 return Ok(merge_base.to_string());
1252 }
1253
1254 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1255 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1256 return Ok(merge_base.to_string());
1257 };
1258
1259 Ok(last_leading_applied_commit.to_string())
1260}
1261
1262fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1269 let mut last_applied_commit = None;
1270
1271 for line in cherry_output.lines() {
1272 let trimmed_line = line.trim();
1273 if trimmed_line.is_empty() {
1274 continue;
1275 }
1276
1277 let mut parts = trimmed_line.split_whitespace();
1278 let marker = parts.next()?;
1279 let commit_hash = parts.next()?;
1280
1281 if marker == "-" {
1282 last_applied_commit = Some(commit_hash);
1283
1284 continue;
1285 }
1286
1287 if marker == "+" {
1288 break;
1289 }
1290
1291 break;
1292 }
1293
1294 last_applied_commit
1295}
1296
1297async fn commit_all_with_retry(
1305 repo_path: PathBuf,
1306 commit_message: String,
1307 message_strategy: SingleCommitMessageStrategy,
1308 no_verify: bool,
1309 amend_existing_commit: bool,
1310) -> Result<(), GitError> {
1311 spawn_blocking(move || {
1312 stage_all_sync(&repo_path)?;
1313
1314 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1315 let output = run_commit_command(
1316 &repo_path,
1317 &commit_message,
1318 message_strategy,
1319 no_verify,
1320 amend_existing_commit,
1321 )?;
1322
1323 if output.status.success() {
1324 return Ok(());
1325 }
1326
1327 let stderr = String::from_utf8_lossy(&output.stderr);
1328 let stdout = String::from_utf8_lossy(&output.stdout);
1329 if is_nothing_to_commit_output(&stdout, &stderr) {
1330 return Err(nothing_to_commit_error());
1331 }
1332
1333 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1334 reset_empty_amend_sync(&repo_path)?;
1335
1336 return Err(nothing_to_commit_error());
1337 }
1338
1339 if is_hook_modified_error(&stdout, &stderr) {
1340 stage_all_sync(&repo_path)?;
1341
1342 continue;
1343 }
1344
1345 let detail = command_output_detail(&output.stdout, &output.stderr);
1346
1347 return Err(GitError::CommandFailed {
1348 command: "git commit".to_string(),
1349 stderr: detail,
1350 });
1351 }
1352
1353 Err(GitError::CommandFailed {
1354 command: "git commit".to_string(),
1355 stderr: format!(
1356 "Failed to commit: commit hooks kept modifying files after \
1357 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1358 ),
1359 })
1360 })
1361 .await?
1362}
1363
1364fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1367 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1368 .iter()
1369 .find(|config_file| repo_path.join(config_file).is_file())
1370 else {
1371 return Ok(());
1372 };
1373 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1374
1375 if is_executable_hook(&hook_path) {
1376 return Ok(());
1377 }
1378
1379 Err(GitError::PreCommitHookMissing {
1380 config_file: (*config_file).to_string(),
1381 })
1382}
1383
1384fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1386 let hooks_path_output =
1387 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1388 let hooks_path = if hooks_path_output.status.success() {
1389 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1390 } else if hooks_path_output.status.code() == Some(1) {
1391 let default_hook_path = run_git_command_sync(
1392 repo_path,
1393 &["rev-parse", "--git-path", "hooks/pre-commit"],
1394 "Failed to resolve Git pre-commit hook path",
1395 )?;
1396
1397 return Ok(resolve_repo_path(
1398 repo_path,
1399 PathBuf::from(default_hook_path.trim()),
1400 ));
1401 } else {
1402 return Err(GitError::CommandFailed {
1403 command: "git config --path --get core.hooksPath".to_string(),
1404 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1405 });
1406 };
1407
1408 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1409}
1410
1411fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1412 if path.is_absolute() {
1413 return path;
1414 }
1415
1416 repo_path.join(path)
1417}
1418
1419#[cfg(unix)]
1420fn is_executable_hook(hook_path: &Path) -> bool {
1421 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1422}
1423
1424#[cfg(not(unix))]
1425fn is_executable_hook(hook_path: &Path) -> bool {
1426 hook_path.is_file()
1427}
1428
1429fn nothing_to_commit_error() -> GitError {
1431 GitError::CommandFailed {
1432 command: "git commit".to_string(),
1433 stderr: "Nothing to commit: no changes detected".to_string(),
1434 }
1435}
1436
1437fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1440 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1441
1442 combined.contains("nothing to commit")
1443}
1444
1445fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1448 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1449 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1450
1451 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1452}
1453
1454fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1457 run_git_command_sync(
1458 repo_path,
1459 &["reset", "HEAD^"],
1460 "Git reset after empty amend failed",
1461 )?;
1462
1463 Ok(())
1464}
1465
1466fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1470 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1471
1472 if !output.status.success() {
1473 let detail = command_output_detail(&output.stdout, &output.stderr);
1474
1475 return Err(GitError::CommandFailed {
1476 command: "git add -A".to_string(),
1477 stderr: format!("Failed to stage changes: {detail}"),
1478 });
1479 }
1480
1481 Ok(())
1482}
1483
1484fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1486 if !has_head_commit_sync(repo_path)? {
1487 return Ok(None);
1488 }
1489
1490 let output = run_git_command_sync(
1491 repo_path,
1492 &["log", "-1", "--pretty=%B"],
1493 "Failed to read HEAD commit message",
1494 )?;
1495
1496 Ok(Some(output.trim().to_string()))
1497}
1498
1499fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1501 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1502
1503 if output.status.success() {
1504 return Ok(true);
1505 }
1506
1507 let detail = command_output_detail(&output.stdout, &output.stderr);
1508 let normalized_detail = detail.to_ascii_lowercase();
1509 if normalized_detail.contains("needed a single revision")
1510 || normalized_detail.contains("unknown revision")
1511 || normalized_detail.contains("does not have any commits yet")
1512 {
1513 return Ok(false);
1514 }
1515
1516 Err(GitError::CommandFailed {
1517 command: "git rev-parse --verify HEAD".to_string(),
1518 stderr: detail,
1519 })
1520}
1521
1522fn run_commit_command(
1526 repo_path: &Path,
1527 commit_message: &str,
1528 message_strategy: SingleCommitMessageStrategy,
1529 no_verify: bool,
1530 amend_existing_commit: bool,
1531) -> Result<Output, GitError> {
1532 let mut args = vec!["commit"];
1533 if amend_existing_commit {
1534 args.push("--amend");
1535 match message_strategy {
1536 SingleCommitMessageStrategy::Replace => {
1537 args.push("-m");
1538 args.push(commit_message);
1539 }
1540 SingleCommitMessageStrategy::Reuse => {
1541 args.push("--no-edit");
1542 }
1543 }
1544 } else {
1545 args.push("-m");
1546 args.push(commit_message);
1547 }
1548
1549 if no_verify {
1550 args.push("--no-verify");
1551 }
1552
1553 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1554}
1555
1556fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1558 let combined = format!(
1559 "{stdout}
1560{stderr}"
1561 )
1562 .to_ascii_lowercase();
1563
1564 combined.contains("files were modified by this hook")
1565}
1566
1567pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1569 let normalized_detail = detail.to_ascii_lowercase();
1570
1571 normalized_detail.contains("has no upstream branch")
1572 || normalized_detail.contains("no upstream branch")
1573 || normalized_detail.contains("set-upstream")
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use std::fs;
1579 #[cfg(unix)]
1580 use std::os::unix::fs::PermissionsExt;
1581 use std::path::Path;
1582 use std::process::{Command, Output};
1583
1584 use mockall::Sequence;
1585 use mockall::predicate::function;
1586 use tempfile::tempdir;
1587
1588 use super::*;
1589 use crate::repo::MockAsyncGitCommandRunner;
1590
1591 fn async_git_output(
1593 exit_code: i32,
1594 stdout: impl Into<Vec<u8>>,
1595 stderr: impl Into<Vec<u8>>,
1596 ) -> AsyncGitCommandOutput {
1597 AsyncGitCommandOutput {
1598 exit_code: Some(exit_code),
1599 stderr: stderr.into(),
1600 stdout: stdout.into(),
1601 }
1602 }
1603
1604 fn run_git_command(repo_path: &Path, args: &[&str]) {
1606 let output = git_command_output(repo_path, args);
1607
1608 assert!(
1609 output.status.success(),
1610 "git command {:?} failed: {}",
1611 args,
1612 String::from_utf8_lossy(&output.stderr)
1613 );
1614 }
1615
1616 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1618 Command::new("git")
1619 .args(args)
1620 .current_dir(repo_path)
1621 .output()
1622 .expect("failed to run git command")
1623 }
1624
1625 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1627 let output = git_command_output(repo_path, args);
1628
1629 assert!(
1630 output.status.success(),
1631 "git command {:?} failed: {}",
1632 args,
1633 String::from_utf8_lossy(&output.stderr)
1634 );
1635
1636 String::from_utf8(output.stdout)
1637 .expect("git stdout should be valid utf-8")
1638 .trim()
1639 .to_string()
1640 }
1641
1642 fn setup_test_git_repo(repo_path: &Path) {
1644 run_git_command(repo_path, &["init", "-b", "main"]);
1645 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1646 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1647 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1648 run_git_command(repo_path, &["add", "README.md"]);
1649 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1650 }
1651
1652 #[tokio::test]
1653 async fn delete_branch_removes_branch_from_isolated_repository() {
1654 let temp_dir = tempdir().expect("failed to create temp dir");
1656 setup_test_git_repo(temp_dir.path());
1657 run_git_command(temp_dir.path(), &["branch", "review/topic"]);
1658
1659 delete_branch(temp_dir.path().to_path_buf(), "review/topic".to_string())
1661 .await
1662 .expect("branch deletion should succeed");
1663
1664 let branch_lookup = git_command_output(
1666 temp_dir.path(),
1667 &["show-ref", "--verify", "--quiet", "refs/heads/review/topic"],
1668 );
1669 assert!(!branch_lookup.status.success());
1670 }
1671
1672 #[tokio::test]
1673 async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1674 let temp_dir = tempdir().expect("failed to create temp dir");
1676 setup_test_git_repo(temp_dir.path());
1677 fs::write(temp_dir.path().join("README.md"), "staged change\n")
1678 .expect("failed to write staged change");
1679 run_git_command(temp_dir.path(), &["add", "README.md"]);
1680 fs::write(
1681 temp_dir.path().join("README.md"),
1682 "staged change\nunstaged change\n",
1683 )
1684 .expect("failed to write unstaged change");
1685 fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1686 .expect("failed to write untracked file");
1687 let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1688 let status_before = git_command_output(
1689 temp_dir.path(),
1690 &["status", "--porcelain=v1", "--untracked-files=all"],
1691 )
1692 .stdout;
1693
1694 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1696 let changed_files =
1697 diff_changed_files(temp_dir.path().to_path_buf(), "main".to_string()).await;
1698
1699 let diff_output = result.expect("diff should succeed");
1701 let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1702 let status_after = git_command_output(
1703 temp_dir.path(),
1704 &["status", "--porcelain=v1", "--untracked-files=all"],
1705 )
1706 .stdout;
1707 assert!(diff_output.contains("staged change"));
1708 assert!(diff_output.contains("unstaged change"));
1709 assert!(diff_output.contains("untracked change"));
1710 assert_eq!(
1711 changed_files.expect("changed files should load"),
1712 vec!["README.md".to_string(), "new.txt".to_string()]
1713 );
1714 assert_eq!(cached_diff_after, cached_diff_before);
1715 assert_eq!(status_after, status_before);
1716 }
1717
1718 #[tokio::test]
1719 async fn read_worktree_file_returns_text_for_safe_nested_path() {
1720 let temp_dir = tempdir().expect("failed to create temp dir");
1722 let docs_dir = temp_dir.path().join("docs");
1723 fs::create_dir(&docs_dir).expect("failed to create docs directory");
1724 fs::write(docs_dir.join("README.md"), "# Preview\n")
1725 .expect("failed to write markdown file");
1726
1727 let result =
1729 read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1730
1731 assert_eq!(
1733 result.expect("worktree read should succeed"),
1734 WorktreeFileContent::Text("# Preview\n".to_string())
1735 );
1736 }
1737
1738 #[tokio::test]
1739 async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
1740 let temp_dir = tempdir().expect("failed to create temp dir");
1742 fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
1743 .expect("failed to write binary file");
1744 fs::write(
1745 temp_dir.path().join("large.md"),
1746 vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
1747 )
1748 .expect("failed to write oversize file");
1749
1750 let missing =
1752 read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
1753 let binary =
1754 read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
1755 let too_large =
1756 read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
1757
1758 assert_eq!(
1760 missing.expect("missing read should succeed"),
1761 WorktreeFileContent::Missing
1762 );
1763 assert_eq!(
1764 binary.expect("binary read should succeed"),
1765 WorktreeFileContent::Binary
1766 );
1767 assert_eq!(
1768 too_large.expect("oversize read should succeed"),
1769 WorktreeFileContent::TooLarge
1770 );
1771 }
1772
1773 #[tokio::test]
1774 async fn read_worktree_file_rejects_unsafe_relative_paths() {
1775 let temp_dir = tempdir().expect("failed to create temp dir");
1777 let absolute_path = temp_dir.path().join("README.md");
1778
1779 let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
1781 let parent =
1782 read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
1783 let absolute = read_worktree_file(
1784 temp_dir.path().to_path_buf(),
1785 absolute_path.to_string_lossy().into_owned(),
1786 )
1787 .await;
1788
1789 for result in [empty, parent, absolute] {
1791 assert!(
1792 matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
1793 );
1794 }
1795 }
1796
1797 #[cfg(unix)]
1798 #[tokio::test]
1799 async fn read_worktree_file_rejects_symlinks_outside_repository() {
1800 let temp_dir = tempdir().expect("failed to create temp dir");
1802 let outside_dir = tempdir().expect("failed to create outside temp dir");
1803 let outside_file = outside_dir.path().join("outside.md");
1804 fs::write(&outside_file, "outside").expect("failed to write outside file");
1805 std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
1806 .expect("failed to create outside symlink");
1807
1808 let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
1810
1811 assert!(
1813 matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
1814 );
1815 }
1816
1817 #[cfg(unix)]
1818 #[tokio::test]
1819 async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
1820 let temp_dir = tempdir().expect("failed to create temp dir");
1822 std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
1823 .expect("failed to create symlink loop");
1824
1825 let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
1827
1828 assert!(matches!(result, Err(GitError::Io(_))));
1830 }
1831
1832 #[test]
1833 fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
1834 let temp_dir = tempdir().expect("failed to create temp dir");
1836 let path_without_parent = Path::new("/");
1837 let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
1838 let missing_index = temp_dir.path().join("missing-index");
1839
1840 let parent_error = copy_git_index_to_temp(path_without_parent);
1842 let create_error = copy_git_index_to_temp(&missing_parent_index);
1843 let copy_error = copy_git_index_to_temp(&missing_index);
1844
1845 assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
1847 assert!(matches!(
1848 create_error,
1849 Err(GitError::CommandFailed { ref command, .. })
1850 if command == "create temporary git index"
1851 ));
1852 assert!(matches!(
1853 copy_error,
1854 Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
1855 ));
1856 }
1857
1858 #[test]
1859 fn run_git_command_with_index_sync_maps_process_and_command_failures() {
1860 let temp_dir = tempdir().expect("failed to create temp dir");
1862 let index_path = temp_dir.path().join("index");
1863 let missing_repo_path = temp_dir.path().join("missing-repository");
1864 fs::write(&index_path, []).expect("failed to create temporary index");
1865
1866 let process_error = run_git_command_with_index_sync(
1868 &missing_repo_path,
1869 &["status"],
1870 &index_path,
1871 "Expected process failure",
1872 );
1873 let command_error = run_git_command_with_index_sync(
1874 temp_dir.path(),
1875 &["definitely-not-a-git-command"],
1876 &index_path,
1877 "Expected git failure",
1878 );
1879
1880 assert!(matches!(
1882 process_error,
1883 Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
1884 ));
1885 assert!(matches!(
1886 command_error,
1887 Err(GitError::CommandFailed {
1888 ref command,
1889 ref stderr,
1890 }) if command == "git definitely-not-a-git-command"
1891 && stderr.starts_with("Expected git failure:")
1892 ));
1893 }
1894
1895 #[cfg(unix)]
1896 fn write_executable_pre_commit_hook(hook_path: &Path) {
1897 fs::create_dir_all(
1898 hook_path
1899 .parent()
1900 .expect("pre-commit hook should have a parent directory"),
1901 )
1902 .expect("failed to create hooks directory");
1903 fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1904 let mut permissions = fs::metadata(hook_path)
1905 .expect("failed to read pre-commit hook metadata")
1906 .permissions();
1907 permissions.set_mode(0o755);
1908 fs::set_permissions(hook_path, permissions)
1909 .expect("failed to make pre-commit hook executable");
1910 }
1911
1912 #[test]
1913 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
1914 let temp_dir = tempdir().expect("failed to create temp dir");
1916 setup_test_git_repo(temp_dir.path());
1917
1918 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1920
1921 assert!(result.is_ok());
1923 }
1924
1925 #[test]
1926 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
1927 let temp_dir = tempdir().expect("failed to create temp dir");
1929 setup_test_git_repo(temp_dir.path());
1930 fs::write(
1931 temp_dir.path().join(".pre-commit-config.yaml"),
1932 "repos: []\n",
1933 )
1934 .expect("failed to write pre-commit configuration");
1935
1936 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1938
1939 assert!(matches!(
1941 result,
1942 Err(GitError::PreCommitHookMissing { ref config_file })
1943 if config_file == ".pre-commit-config.yaml"
1944 ));
1945 }
1946
1947 #[cfg(unix)]
1948 #[test]
1949 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
1950 let temp_dir = tempdir().expect("failed to create temp dir");
1952 setup_test_git_repo(temp_dir.path());
1953 fs::write(
1954 temp_dir.path().join(".pre-commit-config.yaml"),
1955 "repos: []\n",
1956 )
1957 .expect("failed to write pre-commit configuration");
1958 let hook_path = temp_dir.path().join(git_command_stdout(
1959 temp_dir.path(),
1960 &["rev-parse", "--git-path", "hooks/pre-commit"],
1961 ));
1962 write_executable_pre_commit_hook(&hook_path);
1963
1964 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1966
1967 assert!(result.is_ok());
1969 }
1970
1971 #[cfg(unix)]
1972 #[test]
1973 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
1974 let temp_dir = tempdir().expect("failed to create temp dir");
1976 setup_test_git_repo(temp_dir.path());
1977 fs::write(
1978 temp_dir.path().join(".pre-commit-config.yaml"),
1979 "repos: []\n",
1980 )
1981 .expect("failed to write pre-commit configuration");
1982 run_git_command(
1983 temp_dir.path(),
1984 &["config", "core.hooksPath", ".custom-hooks"],
1985 );
1986 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
1987
1988 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1990
1991 assert!(result.is_ok());
1993 }
1994
1995 #[cfg(unix)]
1996 #[test]
1997 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
1998 let temp_dir = tempdir().expect("failed to create temp dir");
2000 setup_test_git_repo(temp_dir.path());
2001 fs::write(
2002 temp_dir.path().join(".pre-commit-config.yaml"),
2003 "repos: []\n",
2004 )
2005 .expect("failed to write pre-commit configuration");
2006 let hook_path = temp_dir.path().join(git_command_stdout(
2007 temp_dir.path(),
2008 &["rev-parse", "--git-path", "hooks/pre-commit"],
2009 ));
2010 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
2011 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
2012 .expect("failed to set mismatched execute permissions");
2013
2014 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2016
2017 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
2019 }
2020
2021 #[tokio::test]
2022 async fn commit_all_allows_configured_validation_without_hook() {
2023 let temp_dir = tempdir().expect("failed to create temp dir");
2025 setup_test_git_repo(temp_dir.path());
2026 fs::write(
2027 temp_dir.path().join(".pre-commit-config.yaml"),
2028 "repos: []\n",
2029 )
2030 .expect("failed to write pre-commit configuration");
2031 fs::write(temp_dir.path().join("README.md"), "changed\n")
2032 .expect("failed to write worktree change");
2033
2034 let result = commit_all(
2036 temp_dir.path().to_path_buf(),
2037 "Change README".to_string(),
2038 false,
2039 )
2040 .await;
2041
2042 assert!(result.is_ok());
2044 assert_eq!(
2045 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
2046 "Change README"
2047 );
2048 }
2049
2050 #[tokio::test]
2051 async fn current_branch_name_returns_error_for_detached_head() {
2052 let temp_dir = tempdir().expect("failed to create temp dir");
2054 setup_test_git_repo(temp_dir.path());
2055 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
2056 let command_runner = ProcessAsyncGitCommandRunner;
2057
2058 let result = current_branch_name(temp_dir.path(), &command_runner).await;
2060
2061 let error = result.expect_err("detached HEAD should fail");
2063 assert!(error.to_string().contains("detached HEAD"));
2064 }
2065
2066 #[tokio::test]
2067 async fn current_branch_remote_name_returns_none_when_remote_is_not_configured() {
2068 let temp_dir = tempdir().expect("failed to create temp dir");
2070 setup_test_git_repo(temp_dir.path());
2071 let command_runner = ProcessAsyncGitCommandRunner;
2072
2073 let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2075 .await
2076 .expect("missing branch remote should not be a command failure");
2077
2078 assert_eq!(remote_name, None);
2080 }
2081
2082 #[tokio::test]
2083 async fn current_branch_remote_name_returns_configured_non_origin_remote() {
2084 let temp_dir = tempdir().expect("failed to create temp dir");
2086 setup_test_git_repo(temp_dir.path());
2087 run_git_command(
2088 temp_dir.path(),
2089 &["config", "branch.main.remote", "review-remote"],
2090 );
2091 let command_runner = ProcessAsyncGitCommandRunner;
2092
2093 let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2095 .await
2096 .expect("configured branch remote should resolve");
2097
2098 assert_eq!(remote_name, Some("review-remote".to_string()));
2100 }
2101
2102 #[test]
2103 fn parse_current_branch_remote_output_preserves_fatal_config_error() {
2104 let output = AsyncGitCommandOutput {
2106 exit_code: Some(128),
2107 stderr: b"fatal: bad config line".to_vec(),
2108 stdout: Vec::new(),
2109 };
2110
2111 let error = parse_current_branch_remote_output(&output, "branch.main.remote")
2113 .expect_err("malformed config should remain an error");
2114
2115 assert!(matches!(
2117 error,
2118 GitError::CommandFailed { command, stderr }
2119 if command == "git config --get branch.main.remote"
2120 && stderr.contains("Failed to resolve current branch remote")
2121 ));
2122 }
2123
2124 #[tokio::test]
2125 async fn primary_upstream_reference_uses_first_non_empty_line() {
2126 let temp_dir = tempdir().expect("failed to create temp dir");
2128 let remote_dir = tempdir().expect("failed to create remote temp dir");
2129 setup_test_git_repo(temp_dir.path());
2130 run_git_command(remote_dir.path(), &["init", "--bare"]);
2131 let remote_path = remote_dir.path().to_string_lossy().to_string();
2132 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2133 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2134 run_git_command(
2135 temp_dir.path(),
2136 &[
2137 "config",
2138 "--replace-all",
2139 "branch.main.merge",
2140 "refs/heads/main",
2141 ],
2142 );
2143 run_git_command(
2144 temp_dir.path(),
2145 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
2146 );
2147 let command_runner = ProcessAsyncGitCommandRunner;
2148
2149 let upstream_reference = primary_upstream_reference(temp_dir.path(), &command_runner)
2151 .await
2152 .expect("failed to resolve upstream");
2153
2154 assert_eq!(upstream_reference, "origin/main");
2156 }
2157
2158 #[tokio::test]
2159 async fn pull_rebase_retries_index_lock_through_async_runner() {
2160 let repo_path = PathBuf::from("test-repo");
2162 let mut command_runner = MockAsyncGitCommandRunner::new();
2163 let mut sequence = Sequence::new();
2164 command_runner
2165 .expect_run()
2166 .with(function(|command: &AsyncGitCommand| {
2167 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2168 }))
2169 .times(1)
2170 .in_sequence(&mut sequence)
2171 .return_once(|_| {
2172 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2173 });
2174 for output in [
2175 async_git_output(
2176 128,
2177 Vec::new(),
2178 "fatal: Unable to create '.git/index.lock': File exists.",
2179 ),
2180 async_git_output(0, Vec::new(), Vec::new()),
2181 ] {
2182 command_runner
2183 .expect_run()
2184 .with(function(|command: &AsyncGitCommand| {
2185 command.arguments == ["pull", "--rebase", "origin", "main"]
2186 && command.environment
2187 == [
2188 ("GIT_EDITOR".to_string(), ":".to_string()),
2189 ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
2190 ]
2191 }))
2192 .times(1)
2193 .in_sequence(&mut sequence)
2194 .return_once(move |_| Box::pin(async move { Ok(output) }));
2195 }
2196
2197 let result = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO).await;
2199
2200 assert!(matches!(result, Ok(PullRebaseResult::Completed)));
2202 }
2203
2204 #[tokio::test]
2205 async fn pull_rebase_preserves_non_conflict_command_failure() {
2206 let repo_path = PathBuf::from("test-repo");
2208 let mut command_runner = MockAsyncGitCommandRunner::new();
2209 let mut sequence = Sequence::new();
2210 command_runner
2211 .expect_run()
2212 .with(function(|command: &AsyncGitCommand| {
2213 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2214 }))
2215 .times(1)
2216 .in_sequence(&mut sequence)
2217 .return_once(|_| {
2218 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2219 });
2220 command_runner
2221 .expect_run()
2222 .with(function(|command: &AsyncGitCommand| {
2223 command.arguments == ["pull", "--rebase", "origin", "main"]
2224 }))
2225 .times(1)
2226 .in_sequence(&mut sequence)
2227 .return_once(|_| {
2228 Box::pin(async { Ok(async_git_output(128, Vec::new(), "fatal: transport failed")) })
2229 });
2230
2231 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2233 .await
2234 .expect_err("non-conflict pull failure should remain an error");
2235
2236 assert!(matches!(
2238 error,
2239 GitError::CommandFailed { command, stderr }
2240 if command == "git pull --rebase" && stderr == "fatal: transport failed"
2241 ));
2242 }
2243
2244 #[tokio::test]
2245 async fn pull_rebase_rejects_local_upstream_without_configured_remote() {
2246 let repo_path = PathBuf::from("test-repo");
2248 let mut command_runner = MockAsyncGitCommandRunner::new();
2249 let mut sequence = Sequence::new();
2250 let expectations = [
2251 (
2252 vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2253 async_git_output(0, "main\n", Vec::new()),
2254 ),
2255 (
2256 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2257 async_git_output(0, "main\n", Vec::new()),
2258 ),
2259 (
2260 vec!["config", "--get", "branch.main.remote"],
2261 async_git_output(1, Vec::new(), Vec::new()),
2262 ),
2263 ];
2264 for (arguments, output) in expectations {
2265 let arguments = arguments
2266 .into_iter()
2267 .map(str::to_string)
2268 .collect::<Vec<_>>();
2269 command_runner
2270 .expect_run()
2271 .with(function(move |command: &AsyncGitCommand| {
2272 command.arguments == arguments
2273 }))
2274 .times(1)
2275 .in_sequence(&mut sequence)
2276 .return_once(move |_| Box::pin(async move { Ok(output) }));
2277 }
2278
2279 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2281 .await
2282 .expect_err("local upstream without a remote should fail");
2283
2284 assert!(matches!(
2286 error,
2287 GitError::OutputParse(message)
2288 if message == "Failed to resolve current branch remote: not configured"
2289 ));
2290 }
2291
2292 #[tokio::test]
2293 async fn pull_rebase_returns_last_index_lock_failure_after_retry_exhaustion() {
2294 let repo_path = PathBuf::from("test-repo");
2296 let mut command_runner = MockAsyncGitCommandRunner::new();
2297 let mut sequence = Sequence::new();
2298 command_runner
2299 .expect_run()
2300 .with(function(|command: &AsyncGitCommand| {
2301 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2302 }))
2303 .times(1)
2304 .in_sequence(&mut sequence)
2305 .return_once(|_| {
2306 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2307 });
2308 command_runner
2309 .expect_run()
2310 .with(function(|command: &AsyncGitCommand| {
2311 command.arguments == ["pull", "--rebase", "origin", "main"]
2312 }))
2313 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
2314 .in_sequence(&mut sequence)
2315 .returning(|_| {
2316 Box::pin(async {
2317 Ok(async_git_output(
2318 128,
2319 Vec::new(),
2320 "fatal: Unable to create '.git/index.lock': File exists.",
2321 ))
2322 })
2323 });
2324
2325 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2327 .await
2328 .expect_err("exhausted index-lock retries should return the last failure");
2329
2330 assert!(matches!(
2332 error,
2333 GitError::CommandFailed { command, stderr }
2334 if command == "git pull --rebase" && stderr.contains("index.lock")
2335 ));
2336 }
2337
2338 #[tokio::test]
2339 async fn remote_branch_lookup_uses_origin_fallback_through_async_runner() {
2340 let repo_path = PathBuf::from("test-repo");
2342 let mut command_runner = MockAsyncGitCommandRunner::new();
2343 let mut sequence = Sequence::new();
2344 let expectations = [
2345 (
2346 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2347 async_git_output(0, "main\n", Vec::new()),
2348 ),
2349 (
2350 vec!["config", "--get", "branch.main.remote"],
2351 async_git_output(1, Vec::new(), Vec::new()),
2352 ),
2353 (
2354 vec!["ls-remote", "--heads", "origin", "review/topic"],
2355 async_git_output(0, "abc123\trefs/heads/review/topic\n", Vec::new()),
2356 ),
2357 ];
2358 for (arguments, output) in expectations {
2359 let arguments = arguments
2360 .into_iter()
2361 .map(str::to_string)
2362 .collect::<Vec<_>>();
2363 command_runner
2364 .expect_run()
2365 .with(function(move |command: &AsyncGitCommand| {
2366 command.arguments == arguments
2367 }))
2368 .times(1)
2369 .in_sequence(&mut sequence)
2370 .return_once(move |_| Box::pin(async move { Ok(output) }));
2371 }
2372
2373 let exists = remote_branch_exists_with_runner(
2375 repo_path,
2376 "review/topic".to_string(),
2377 &command_runner,
2378 )
2379 .await
2380 .expect("remote branch lookup should succeed");
2381
2382 assert!(exists);
2384 }
2385
2386 #[tokio::test]
2387 async fn remote_branch_lookup_checks_isolated_local_remote() {
2388 let temp_dir = tempdir().expect("failed to create temp dir");
2390 let remote_dir = tempdir().expect("failed to create remote temp dir");
2391 setup_test_git_repo(temp_dir.path());
2392 run_git_command(remote_dir.path(), &["init", "--bare"]);
2393 let remote_path = remote_dir.path().to_string_lossy().to_string();
2394 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2395 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2396
2397 let exists = remote_branch_exists(temp_dir.path().to_path_buf(), "main".to_string())
2399 .await
2400 .expect("local remote branch lookup should succeed");
2401
2402 assert!(exists);
2404 }
2405
2406 #[tokio::test]
2407 async fn remote_branch_lookup_preserves_remote_config_failure() {
2408 let repo_path = PathBuf::from("test-repo");
2410 let mut command_runner = MockAsyncGitCommandRunner::new();
2411 let mut sequence = Sequence::new();
2412 command_runner
2413 .expect_run()
2414 .with(function(|command: &AsyncGitCommand| {
2415 command.arguments == ["rev-parse", "--abbrev-ref", "HEAD"]
2416 }))
2417 .times(1)
2418 .in_sequence(&mut sequence)
2419 .return_once(|_| Box::pin(async { Ok(async_git_output(0, "main\n", Vec::new())) }));
2420 command_runner
2421 .expect_run()
2422 .with(function(|command: &AsyncGitCommand| {
2423 command.arguments == ["config", "--get", "branch.main.remote"]
2424 }))
2425 .times(1)
2426 .in_sequence(&mut sequence)
2427 .return_once(|_| {
2428 Box::pin(async {
2429 Ok(async_git_output(
2430 128,
2431 Vec::new(),
2432 "fatal: malformed branch config",
2433 ))
2434 })
2435 });
2436
2437 let error = remote_branch_exists_with_runner(
2439 repo_path,
2440 "review/topic".to_string(),
2441 &command_runner,
2442 )
2443 .await
2444 .expect_err("remote config failure should not fall back to origin");
2445
2446 assert!(matches!(
2448 error,
2449 GitError::CommandFailed { command, stderr }
2450 if command == "git config --get branch.main.remote"
2451 && stderr.contains("malformed branch config")
2452 ));
2453 }
2454
2455 #[tokio::test]
2456 async fn push_without_upstream_reuses_configured_remote() {
2457 let repo_path = PathBuf::from("test-repo");
2459 let mut command_runner = MockAsyncGitCommandRunner::new();
2460 let mut sequence = Sequence::new();
2461 let expectations = [
2462 (
2463 vec!["push", "--force-with-lease"],
2464 async_git_output(128, Vec::new(), "fatal: no upstream branch"),
2465 ),
2466 (
2467 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2468 async_git_output(0, "main\n", Vec::new()),
2469 ),
2470 (
2471 vec!["config", "--get", "branch.main.remote"],
2472 async_git_output(0, "review-remote\n", Vec::new()),
2473 ),
2474 (
2475 vec![
2476 "push",
2477 "--force-with-lease",
2478 "--set-upstream",
2479 "review-remote",
2480 "HEAD",
2481 ],
2482 async_git_output(0, Vec::new(), Vec::new()),
2483 ),
2484 (
2485 vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2486 async_git_output(0, "review-remote/main\n", Vec::new()),
2487 ),
2488 ];
2489 for (arguments, output) in expectations {
2490 let arguments = arguments
2491 .into_iter()
2492 .map(str::to_string)
2493 .collect::<Vec<_>>();
2494 command_runner
2495 .expect_run()
2496 .with(function(move |command: &AsyncGitCommand| {
2497 command.arguments == arguments
2498 }))
2499 .times(1)
2500 .in_sequence(&mut sequence)
2501 .return_once(move |_| Box::pin(async move { Ok(output) }));
2502 }
2503
2504 let upstream_reference = push_current_branch_with_runner(repo_path, &command_runner)
2506 .await
2507 .expect("configured remote push should succeed");
2508
2509 assert_eq!(upstream_reference, "review-remote/main");
2511 }
2512
2513 #[test]
2514 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
2515 let output = "\
2517main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
2518 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
2519
2520 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
2522
2523 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
2525 assert_eq!(
2526 branch_tracking_statuses.get("wt/1234abcd"),
2527 Some(&Some((3, 1)))
2528 );
2529 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
2530 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
2531 }
2532
2533 #[tokio::test]
2534 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
2535 let temp_dir = tempdir().expect("failed to create temp dir");
2537 let remote_dir = tempdir().expect("failed to create remote temp dir");
2538 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2539 let contributor_clone_path = contributor_dir.path().join("clone");
2540 setup_test_git_repo(temp_dir.path());
2541 run_git_command(remote_dir.path(), &["init", "--bare"]);
2542 let remote_path = remote_dir.path().to_string_lossy().to_string();
2543 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2544 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2545 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2546 fs::write(temp_dir.path().join("README.md"), "local change\n")
2547 .expect("failed to write local change");
2548 run_git_command(temp_dir.path(), &["add", "README.md"]);
2549 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2550 run_git_command(
2551 contributor_dir.path(),
2552 &["clone", &remote_path, &contributor_clone_path_text],
2553 );
2554 run_git_command(
2555 &contributor_clone_path,
2556 &["config", "user.name", "Contributor User"],
2557 );
2558 run_git_command(
2559 &contributor_clone_path,
2560 &["config", "user.email", "contributor@example.com"],
2561 );
2562 run_git_command(
2563 &contributor_clone_path,
2564 &["checkout", "-B", "main", "origin/main"],
2565 );
2566 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
2567 .expect("failed to write remote change");
2568 run_git_command(&contributor_clone_path, &["add", "README.md"]);
2569 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2570 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2571
2572 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
2574
2575 assert!(matches!(
2577 result,
2578 Ok(PullRebaseResult::Conflict { ref detail })
2579 if {
2580 let normalized_detail = detail.to_ascii_lowercase();
2581
2582 (normalized_detail.contains("conflict")
2583 || normalized_detail.contains("could not apply"))
2584 && !detail.is_empty()
2585 }
2586 ));
2587 }
2588
2589 #[tokio::test]
2590 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
2591 let temp_dir = tempdir().expect("failed to create temp dir");
2593 let remote_dir = tempdir().expect("failed to create remote temp dir");
2594 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2595 let contributor_clone_path = contributor_dir.path().join("clone");
2596 setup_test_git_repo(temp_dir.path());
2597 run_git_command(remote_dir.path(), &["init", "--bare"]);
2598 let remote_path = remote_dir.path().to_string_lossy().to_string();
2599 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2600 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2601 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2602 run_git_command(
2603 contributor_dir.path(),
2604 &["clone", &remote_path, &contributor_clone_path_text],
2605 );
2606 run_git_command(
2607 &contributor_clone_path,
2608 &["config", "user.name", "Contributor User"],
2609 );
2610 run_git_command(
2611 &contributor_clone_path,
2612 &["config", "user.email", "contributor@example.com"],
2613 );
2614 run_git_command(
2615 &contributor_clone_path,
2616 &["checkout", "-B", "main", "origin/main"],
2617 );
2618 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2619 .expect("failed to write remote file");
2620 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2621 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2622 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2623 fs::write(temp_dir.path().join("local.txt"), "local change")
2624 .expect("failed to write local file");
2625 run_git_command(temp_dir.path(), &["add", "local.txt"]);
2626 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2627
2628 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2630
2631 let error = result
2633 .expect_err("non-fast-forward push should fail")
2634 .to_string();
2635 assert!(error.contains("git push"));
2636 assert!(
2637 error.contains("stale info")
2638 || error.contains("rejected")
2639 || error.contains("fetch first")
2640 );
2641 }
2642
2643 #[tokio::test]
2644 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
2645 let temp_dir = tempdir().expect("failed to create temp dir");
2647 let remote_dir = tempdir().expect("failed to create remote temp dir");
2648 setup_test_git_repo(temp_dir.path());
2649 run_git_command(remote_dir.path(), &["init", "--bare"]);
2650 let remote_path = remote_dir.path().to_string_lossy().to_string();
2651 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2652 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2653 fs::write(
2654 temp_dir.path().join("README.md"),
2655 "first published version\n",
2656 )
2657 .expect("failed to write first version");
2658 run_git_command(temp_dir.path(), &["add", "README.md"]);
2659 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
2660 push_current_branch(temp_dir.path().to_path_buf())
2661 .await
2662 .expect("initial push should succeed");
2663 fs::write(
2664 temp_dir.path().join("README.md"),
2665 "rewritten published version\n",
2666 )
2667 .expect("failed to rewrite published version");
2668 run_git_command(temp_dir.path(), &["add", "README.md"]);
2669 run_git_command(
2670 temp_dir.path(),
2671 &["commit", "--amend", "-m", "Rewrite published branch change"],
2672 );
2673
2674 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
2676 .await
2677 .expect("force-with-lease push should update rewritten history");
2678 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
2679 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
2680
2681 assert_eq!(upstream_reference, "origin/main");
2683 assert_eq!(local_head, remote_head);
2684 }
2685
2686 #[tokio::test]
2687 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
2688 let temp_dir = tempdir().expect("failed to create temp dir");
2690 let remote_dir = tempdir().expect("failed to create remote temp dir");
2691 setup_test_git_repo(temp_dir.path());
2692 run_git_command(remote_dir.path(), &["init", "--bare"]);
2693 let remote_path = remote_dir.path().to_string_lossy().to_string();
2694 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2695
2696 let upstream_reference = push_current_branch_to_remote_branch(
2698 temp_dir.path().to_path_buf(),
2699 "review/custom-branch".to_string(),
2700 )
2701 .await
2702 .expect("failed to push current branch to custom remote branch");
2703
2704 assert_eq!(upstream_reference, "origin/review/custom-branch");
2706 }
2707
2708 #[tokio::test]
2709 async fn current_upstream_reference_returns_origin_main() {
2710 let temp_dir = tempdir().expect("failed to create temp dir");
2712 let remote_dir = tempdir().expect("failed to create remote temp dir");
2713 setup_test_git_repo(temp_dir.path());
2714 run_git_command(remote_dir.path(), &["init", "--bare"]);
2715 let remote_path = remote_dir.path().to_string_lossy().to_string();
2716 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2717 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2718
2719 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
2721 .await
2722 .expect("failed to resolve upstream reference");
2723
2724 assert_eq!(upstream_reference, "origin/main");
2726 }
2727
2728 #[tokio::test]
2729 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
2730 let temp_dir = tempdir().expect("failed to create temp dir");
2732 setup_test_git_repo(temp_dir.path());
2733 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2734 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2735 .expect("failed to write session file");
2736 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2737 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2738 run_git_command(temp_dir.path(), &["checkout", "main"]);
2739 fs::write(temp_dir.path().join("main.txt"), "main change\n")
2740 .expect("failed to write main file");
2741 run_git_command(temp_dir.path(), &["add", "main.txt"]);
2742 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
2743
2744 let status = get_ref_ahead_behind(
2746 temp_dir.path().to_path_buf(),
2747 "wt/1234abcd".to_string(),
2748 "main".to_string(),
2749 )
2750 .await
2751 .expect("failed to compare branch refs");
2752
2753 assert_eq!(status, (1, 1));
2755 }
2756
2757 #[tokio::test]
2758 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
2759 let temp_dir = tempdir().expect("failed to create temp dir");
2761 let remote_dir = tempdir().expect("failed to create remote temp dir");
2762 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2763 let contributor_clone_path = contributor_dir.path().join("clone");
2764 setup_test_git_repo(temp_dir.path());
2765 run_git_command(remote_dir.path(), &["init", "--bare"]);
2766 let remote_path = remote_dir.path().to_string_lossy().to_string();
2767 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2768 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2769 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2770 run_git_command(
2771 contributor_dir.path(),
2772 &["clone", &remote_path, &contributor_clone_path_text],
2773 );
2774 run_git_command(
2775 &contributor_clone_path,
2776 &["config", "user.name", "Contributor User"],
2777 );
2778 run_git_command(
2779 &contributor_clone_path,
2780 &["config", "user.email", "contributor@example.com"],
2781 );
2782 run_git_command(
2783 &contributor_clone_path,
2784 &["checkout", "-B", "main", "origin/main"],
2785 );
2786 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2787 .expect("failed to write remote file");
2788 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2789 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2790 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2791 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2792 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2793 .expect("failed to write session file");
2794 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2795 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2796 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
2797 fs::write(
2798 temp_dir.path().join("session.txt"),
2799 "session change\nmore local\n",
2800 )
2801 .expect("failed to extend session file");
2802 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2803 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
2804 run_git_command(temp_dir.path(), &["fetch"]);
2805
2806 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
2808 .await
2809 .expect("failed to read branch tracking statuses");
2810
2811 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
2813 assert_eq!(
2814 branch_tracking_statuses.get("wt/1234abcd"),
2815 Some(&Some((1, 0)))
2816 );
2817 }
2818
2819 #[tokio::test]
2820 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
2825 let temp_dir = tempdir().expect("failed to create temp dir");
2827 setup_test_git_repo(temp_dir.path());
2828 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
2829 fs::write(temp_dir.path().join("session.txt"), "session work\n")
2830 .expect("failed to write session file");
2831 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2832 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
2833 fs::remove_file(temp_dir.path().join("session.txt"))
2834 .expect("failed to remove session file");
2835
2836 let result = commit_all_preserving_single_commit(
2840 temp_dir.path().to_path_buf(),
2841 "main".to_string(),
2842 "Session commit".to_string(),
2843 SingleCommitMessageStrategy::Replace,
2844 true,
2845 )
2846 .await;
2847
2848 let error = result.expect_err("amend-would-be-empty should fail");
2850 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
2851 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
2852 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
2853
2854 assert!(
2855 error.to_string().contains("Nothing to commit"),
2856 "expected 'Nothing to commit' sentinel but got: {error}"
2857 );
2858 assert_eq!(commit_count, "1");
2859 assert_eq!(head_message, "Initial commit");
2860 assert_eq!(status, "");
2861 }
2862}