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 = resolve_diff_index_path(&repo_path)?;
331 let index_path = PathBuf::from(index_path.trim());
332 let index_path = if index_path.is_absolute() {
333 index_path
334 } else {
335 repo_path.join(index_path)
336 };
337
338 diff_output_after_index_resolution(&repo_path, &base_branch, name_only, &index_path)
339 })
340 .await?
341}
342
343fn diff_output_after_index_resolution(
345 repo_path: &Path,
346 base_branch: &str,
347 name_only: bool,
348 index_path: &Path,
349) -> Result<String, GitError> {
350 let result = (|| -> Result<String, GitError> {
351 let temporary_index = copy_git_index_to_temp(index_path)?;
352
353 run_git_command_with_index_sync(
354 repo_path,
355 &["add", "-A", "--intent-to-add"],
356 &temporary_index,
357 "Git add --intent-to-add failed",
358 )?;
359
360 let merge_base_output =
361 run_git_command_output_sync(repo_path, &["merge-base", "HEAD", base_branch])?;
362
363 let diff_target = if merge_base_output.status.success() {
364 resolve_diff_target(
365 repo_path,
366 base_branch,
367 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
368 )?
369 } else {
370 base_branch.to_string()
371 };
372
373 let args = if name_only {
374 vec!["diff", "--name-only", diff_target.as_str()]
375 } else {
376 vec!["diff", diff_target.as_str()]
377 };
378
379 run_git_command_with_index_sync(repo_path, &args, &temporary_index, "Git diff failed")
380 })();
381
382 result.map_err(|error| classify_diff_repository_error(repo_path, error))
383}
384
385fn resolve_diff_index_path(repo_path: &Path) -> Result<String, GitError> {
387 run_git_command_sync(
388 repo_path,
389 &["rev-parse", "--git-path", "index"],
390 "Git index path resolution failed",
391 )
392 .map_err(|error| classify_diff_repository_error(repo_path, error))
393}
394
395fn classify_diff_repository_error(repo_path: &Path, error: GitError) -> GitError {
397 if !diff_repository_is_unavailable(repo_path) {
398 return error;
399 }
400
401 GitError::RepositoryUnavailable {
402 detail: error.to_string(),
403 }
404}
405
406fn diff_repository_is_unavailable(repo_path: &Path) -> bool {
408 if !repo_path.is_dir() {
409 return true;
410 }
411
412 diff_repository_probe_is_unavailable(run_git_command_output_sync(
413 repo_path,
414 &["rev-parse", "--git-dir"],
415 ))
416}
417
418fn diff_repository_probe_is_unavailable(probe: Result<Output, GitError>) -> bool {
420 match probe {
421 Ok(output) => !output.status.success(),
422 Err(_) => false,
423 }
424}
425
426pub(crate) async fn read_worktree_file(
435 repo_path: PathBuf,
436 relative_path: String,
437) -> Result<WorktreeFileContent, GitError> {
438 spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
439}
440
441fn read_worktree_file_sync(
443 repo_path: &Path,
444 relative_path: &str,
445) -> Result<WorktreeFileContent, GitError> {
446 let relative_file_path = Path::new(relative_path);
447 if relative_path.is_empty()
448 || relative_file_path
449 .components()
450 .any(|component| !matches!(component, Component::Normal(_)))
451 {
452 return Err(GitError::OutputParse(format!(
453 "Unsafe worktree file path: {relative_path}"
454 )));
455 }
456
457 let canonical_repo_path = std::fs::canonicalize(repo_path)?;
458 let candidate_path = repo_path.join(relative_file_path);
459 let canonical_file_path = match std::fs::canonicalize(candidate_path) {
460 Ok(path) => path,
461 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
462 return Ok(WorktreeFileContent::Missing);
463 }
464 Err(error) => return Err(error.into()),
465 };
466 if !canonical_file_path.starts_with(canonical_repo_path) {
467 return Err(GitError::OutputParse(format!(
468 "Worktree file resolves outside repository: {relative_path}"
469 )));
470 }
471
472 let file = std::fs::File::open(canonical_file_path)?;
473 let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
474 file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
475 .read_to_end(&mut bytes)?;
476
477 Ok(worktree_file_content(bytes))
478}
479
480fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
482 if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
483 return WorktreeFileContent::TooLarge;
484 }
485
486 match String::from_utf8(bytes) {
487 Ok(content) => WorktreeFileContent::Text(content),
488 Err(_) => WorktreeFileContent::Binary,
489 }
490}
491
492fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
495 let index_parent = index_path.parent().ok_or_else(|| {
496 GitError::OutputParse(format!(
497 "Git index path has no parent: {}",
498 index_path.display()
499 ))
500 })?;
501 let temporary_index =
502 tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
503 command: "create temporary git index".to_string(),
504 stderr: error.to_string(),
505 })?;
506 std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
507 command: "copy git index".to_string(),
508 stderr: error.to_string(),
509 })?;
510
511 Ok(temporary_index.into_temp_path())
512}
513
514fn run_git_command_with_index_sync(
517 repo_path: &Path,
518 args: &[&str],
519 index_path: &Path,
520 error_context: &str,
521) -> Result<String, GitError> {
522 let output = run_git_command_output_with_env_sync(
523 repo_path,
524 args,
525 &[("GIT_INDEX_FILE", index_path.as_os_str())],
526 )?;
527 if !output.status.success() {
528 return Err(GitError::CommandFailed {
529 command: format!("git {}", args.join(" ")),
530 stderr: format!(
531 "{error_context}: {}",
532 command_output_detail(&output.stdout, &output.stderr)
533 ),
534 });
535 }
536
537 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
538}
539
540pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
551 let status_output = worktree_status(repo_path).await?;
552
553 Ok(status_output.trim().is_empty())
554}
555
556pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
570 run_git_command(
571 repo_path,
572 vec![
573 "status".to_string(),
574 "--porcelain=v1".to_string(),
575 "--untracked-files=all".to_string(),
576 ],
577 "Git status --porcelain=v1 failed".to_string(),
578 )
579 .await
580}
581
582pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
597 run_git_command(
598 repo_path,
599 vec![
600 "status".to_string(),
601 "--porcelain=v1".to_string(),
602 "--untracked-files=no".to_string(),
603 ],
604 "Git tracked status --porcelain=v1 failed".to_string(),
605 )
606 .await
607}
608
609pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
625 let command_runner = ProcessAsyncGitCommandRunner;
626
627 pull_rebase_with_runner(repo_path, &command_runner, GIT_INDEX_LOCK_RETRY_DELAY).await
628}
629
630async fn pull_rebase_with_runner(
632 repo_path: PathBuf,
633 command_runner: &dyn AsyncGitCommandRunner,
634 retry_delay: Duration,
635) -> Result<PullRebaseResult, GitError> {
636 let pull_arguments = pull_rebase_arguments(&repo_path, command_runner).await?;
637 let command = AsyncGitCommand::new(repo_path, pull_arguments).with_environment(vec![
638 ("GIT_EDITOR".to_string(), ":".to_string()),
639 ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
640 ]);
641 let output =
642 run_async_git_command_with_index_lock_retry(command, command_runner, retry_delay).await?;
643
644 if output.success() {
645 return Ok(PullRebaseResult::Completed);
646 }
647
648 let detail = command_output_detail(&output.stdout, &output.stderr);
649 if is_rebase_conflict(&detail) {
650 return Ok(PullRebaseResult::Conflict { detail });
651 }
652
653 Err(GitError::CommandFailed {
654 command: "git pull --rebase".to_string(),
655 stderr: detail,
656 })
657}
658
659async fn pull_rebase_arguments(
664 repo_path: &Path,
665 command_runner: &dyn AsyncGitCommandRunner,
666) -> Result<Vec<String>, GitError> {
667 let upstream_reference = primary_upstream_reference(repo_path, command_runner).await?;
668
669 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
670 return Ok(vec![
671 "pull".to_string(),
672 "--rebase".to_string(),
673 remote_name.to_string(),
674 branch_name.to_string(),
675 ]);
676 }
677
678 let remote_name = current_branch_remote_name(repo_path, command_runner)
679 .await?
680 .ok_or_else(|| {
681 GitError::OutputParse(
682 "Failed to resolve current branch remote: not configured".to_string(),
683 )
684 })?;
685
686 Ok(vec![
687 "pull".to_string(),
688 "--rebase".to_string(),
689 remote_name,
690 upstream_reference,
691 ])
692}
693
694async fn primary_upstream_reference(
700 repo_path: &Path,
701 command_runner: &dyn AsyncGitCommandRunner,
702) -> Result<String, GitError> {
703 let upstream_reference = upstream_reference_name(repo_path, command_runner).await?;
704 let Some(primary_reference) = upstream_reference
705 .lines()
706 .map(str::trim)
707 .find(|line| !line.is_empty())
708 else {
709 return Err(GitError::OutputParse(
710 "Failed to resolve upstream branch: empty output".to_string(),
711 ));
712 };
713
714 Ok(primary_reference.to_string())
715}
716
717async fn upstream_reference_name(
719 repo_path: &Path,
720 command_runner: &dyn AsyncGitCommandRunner,
721) -> Result<String, GitError> {
722 let upstream_reference = run_git_command_with_runner(
723 AsyncGitCommand::new(
724 repo_path.to_path_buf(),
725 vec![
726 "rev-parse".to_string(),
727 "--abbrev-ref".to_string(),
728 "--symbolic-full-name".to_string(),
729 "@{u}".to_string(),
730 ],
731 ),
732 "Failed to resolve upstream branch",
733 command_runner,
734 )
735 .await?;
736 let upstream_reference = upstream_reference.trim().to_string();
737 if upstream_reference.is_empty() {
738 return Err(GitError::OutputParse(
739 "Failed to resolve upstream branch: empty output".to_string(),
740 ));
741 }
742
743 Ok(upstream_reference)
744}
745
746async fn current_branch_remote_name(
751 repo_path: &Path,
752 command_runner: &dyn AsyncGitCommandRunner,
753) -> Result<Option<String>, GitError> {
754 let current_branch_name = current_branch_name(repo_path, command_runner).await?;
755 let remote_config_key = format!("branch.{current_branch_name}.remote");
756 let output = command_runner
757 .run(AsyncGitCommand::new(
758 repo_path.to_path_buf(),
759 vec![
760 "config".to_string(),
761 "--get".to_string(),
762 remote_config_key.clone(),
763 ],
764 ))
765 .await?;
766
767 parse_current_branch_remote_output(&output, &remote_config_key)
768}
769
770fn parse_current_branch_remote_output(
772 output: &AsyncGitCommandOutput,
773 remote_config_key: &str,
774) -> Result<Option<String>, GitError> {
775 if output.exit_code == Some(1) {
776 return Ok(None);
777 }
778 if !output.success() {
779 let detail = command_output_detail(&output.stdout, &output.stderr);
780
781 return Err(GitError::CommandFailed {
782 command: format!("git config --get {remote_config_key}"),
783 stderr: format!(
784 "Failed to resolve current branch remote `{remote_config_key}`: {detail}"
785 ),
786 });
787 }
788
789 let remote_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
790 if remote_name.is_empty() {
791 return Err(GitError::OutputParse(format!(
792 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
793 )));
794 }
795
796 Ok(Some(remote_name))
797}
798
799async fn current_branch_name(
801 repo_path: &Path,
802 command_runner: &dyn AsyncGitCommandRunner,
803) -> Result<String, GitError> {
804 let branch_name = run_git_command_with_runner(
805 AsyncGitCommand::new(
806 repo_path.to_path_buf(),
807 vec![
808 "rev-parse".to_string(),
809 "--abbrev-ref".to_string(),
810 "HEAD".to_string(),
811 ],
812 ),
813 "Failed to resolve current branch name",
814 command_runner,
815 )
816 .await?;
817 let branch_name = branch_name.trim().to_string();
818 if branch_name.is_empty() {
819 return Err(GitError::OutputParse(
820 "Failed to resolve current branch name: empty output".to_string(),
821 ));
822 }
823
824 if branch_name == "HEAD" {
825 return Err(GitError::OutputParse(
826 "Failed to resolve current branch name: detached HEAD".to_string(),
827 ));
828 }
829
830 Ok(branch_name)
831}
832
833async fn run_async_git_command_with_index_lock_retry(
836 command: AsyncGitCommand,
837 command_runner: &dyn AsyncGitCommandRunner,
838 retry_delay: Duration,
839) -> Result<AsyncGitCommandOutput, GitError> {
840 let mut attempt_count = 0;
841 loop {
842 attempt_count += 1;
843 let output = command_runner.run(command.clone()).await?;
844 if output.success() {
845 return Ok(output);
846 }
847
848 let detail = command_output_detail(&output.stdout, &output.stderr);
849 let is_last_attempt = attempt_count == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
850 if !is_git_index_lock_error(&detail) || is_last_attempt {
851 return Ok(output);
852 }
853
854 time::sleep(retry_delay).await;
855 }
856}
857
858pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
875 let command_runner = ProcessAsyncGitCommandRunner;
876
877 push_current_branch_with_runner(repo_path, &command_runner).await
878}
879
880async fn push_current_branch_with_runner(
883 repo_path: PathBuf,
884 command_runner: &dyn AsyncGitCommandRunner,
885) -> Result<String, GitError> {
886 let push_command = AsyncGitCommand::new(
887 repo_path.clone(),
888 vec!["push".to_string(), "--force-with-lease".to_string()],
889 );
890 let push_output = command_runner.run(push_command).await?;
891
892 if push_output.success() {
893 return primary_upstream_reference(&repo_path, command_runner).await;
894 }
895
896 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
897 if !is_no_upstream_error(&push_detail) {
898 return Err(GitError::CommandFailed {
899 command: "git push --force-with-lease".to_string(),
900 stderr: push_detail,
901 });
902 }
903
904 let remote_name = current_branch_remote_name(&repo_path, command_runner)
905 .await?
906 .unwrap_or_else(|| "origin".to_string());
907 run_git_command_with_runner(
908 AsyncGitCommand::new(
909 repo_path.clone(),
910 vec![
911 "push".to_string(),
912 "--force-with-lease".to_string(),
913 "--set-upstream".to_string(),
914 remote_name,
915 "HEAD".to_string(),
916 ],
917 ),
918 "Git push failed",
919 command_runner,
920 )
921 .await?;
922
923 primary_upstream_reference(&repo_path, command_runner).await
924}
925
926pub(crate) async fn remote_branch_exists(
939 repo_path: PathBuf,
940 remote_branch_name: String,
941) -> Result<bool, GitError> {
942 let command_runner = ProcessAsyncGitCommandRunner;
943
944 remote_branch_exists_with_runner(repo_path, remote_branch_name, &command_runner).await
945}
946
947async fn remote_branch_exists_with_runner(
949 repo_path: PathBuf,
950 remote_branch_name: String,
951 command_runner: &dyn AsyncGitCommandRunner,
952) -> Result<bool, GitError> {
953 let remote_name = current_branch_remote_name(&repo_path, command_runner)
954 .await?
955 .unwrap_or_else(|| "origin".to_string());
956 let arguments = vec![
957 "ls-remote".to_string(),
958 "--heads".to_string(),
959 remote_name,
960 remote_branch_name,
961 ];
962 let stdout = run_git_command_with_runner(
963 AsyncGitCommand::new(repo_path, arguments),
964 "Git ls-remote failed",
965 command_runner,
966 )
967 .await?;
968
969 Ok(!stdout.trim().is_empty())
970}
971
972pub(crate) async fn push_current_branch_to_remote_branch(
989 repo_path: PathBuf,
990 remote_branch_name: String,
991) -> Result<String, GitError> {
992 let command_runner = ProcessAsyncGitCommandRunner;
993
994 push_current_branch_to_remote_branch_with_runner(repo_path, remote_branch_name, &command_runner)
995 .await
996}
997
998async fn push_current_branch_to_remote_branch_with_runner(
1001 repo_path: PathBuf,
1002 remote_branch_name: String,
1003 command_runner: &dyn AsyncGitCommandRunner,
1004) -> Result<String, GitError> {
1005 let remote_name = current_branch_remote_name(&repo_path, command_runner)
1006 .await?
1007 .unwrap_or_else(|| "origin".to_string());
1008 let push_refspec = format!("HEAD:{remote_branch_name}");
1009 let arguments = vec![
1010 "push".to_string(),
1011 "--force-with-lease".to_string(),
1012 "--set-upstream".to_string(),
1013 remote_name.clone(),
1014 push_refspec,
1015 ];
1016 run_git_command_with_runner(
1017 AsyncGitCommand::new(repo_path, arguments),
1018 "Git push failed",
1019 command_runner,
1020 )
1021 .await?;
1022
1023 Ok(format!("{remote_name}/{remote_branch_name}"))
1024}
1025
1026pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
1038 let command_runner = ProcessAsyncGitCommandRunner;
1039
1040 primary_upstream_reference(&repo_path, &command_runner).await
1041}
1042
1043pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
1054 run_git_command(
1055 repo_path,
1056 vec!["fetch".to_string()],
1057 "Git fetch failed".to_string(),
1058 )
1059 .await?;
1060
1061 Ok(())
1062}
1063
1064pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
1076 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
1077}
1078
1079pub(crate) async fn get_ref_ahead_behind(
1089 repo_path: PathBuf,
1090 left_ref: String,
1091 right_ref: String,
1092) -> Result<(u32, u32), GitError> {
1093 let rev_list_output = run_git_command(
1094 repo_path,
1095 vec![
1096 "rev-list".to_string(),
1097 "--left-right".to_string(),
1098 "--count".to_string(),
1099 format!("{left_ref}...{right_ref}"),
1100 ],
1101 "Git rev-list failed".to_string(),
1102 )
1103 .await?;
1104
1105 parse_ahead_behind_counts(&rev_list_output)
1106}
1107
1108fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
1111 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
1112 if parts.len() >= 2 {
1113 let ahead = parts[0].parse().unwrap_or(0);
1114 let behind = parts[1].parse().unwrap_or(0);
1115
1116 return Ok((ahead, behind));
1117 }
1118
1119 Err(GitError::OutputParse(
1120 "Unexpected output format from git rev-list".to_string(),
1121 ))
1122}
1123
1124pub(crate) async fn branch_tracking_statuses(
1133 repo_path: PathBuf,
1134) -> Result<BranchTrackingMap, GitError> {
1135 let git_output = run_git_command(
1136 repo_path,
1137 vec![
1138 "for-each-ref".to_string(),
1139 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
1140 "refs/heads".to_string(),
1141 ],
1142 "Git for-each-ref failed".to_string(),
1143 )
1144 .await?;
1145
1146 Ok(parse_branch_tracking_statuses(&git_output))
1147}
1148
1149pub(crate) async fn list_upstream_commit_titles(
1160 repo_path: PathBuf,
1161) -> Result<Vec<String>, GitError> {
1162 let git_output = run_git_command(
1163 repo_path,
1164 vec![
1165 "log".to_string(),
1166 "--reverse".to_string(),
1167 "--pretty=%s".to_string(),
1168 "HEAD..@{u}".to_string(),
1169 ],
1170 "Git log failed".to_string(),
1171 )
1172 .await?;
1173
1174 Ok(parse_commit_titles(&git_output))
1175}
1176
1177pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
1188 let git_output = run_git_command(
1189 repo_path,
1190 vec![
1191 "log".to_string(),
1192 "--reverse".to_string(),
1193 "--pretty=%s".to_string(),
1194 "@{u}..HEAD".to_string(),
1195 ],
1196 "Git log failed".to_string(),
1197 )
1198 .await?;
1199
1200 Ok(parse_commit_titles(&git_output))
1201}
1202
1203pub(crate) async fn has_commits_since(
1209 repo_path: PathBuf,
1210 base_branch: String,
1211) -> Result<bool, GitError> {
1212 spawn_blocking(move || -> Result<bool, GitError> {
1213 let rev_list_output = run_git_command_sync(
1214 &repo_path,
1215 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
1216 "Failed to count commits since base branch",
1217 )?;
1218 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
1219 GitError::OutputParse(format!(
1220 "Failed to parse commit count since base branch `{base_branch}`: {error}"
1221 ))
1222 })?;
1223
1224 Ok(commit_count > 0)
1225 })
1226 .await?
1227}
1228
1229fn parse_commit_titles(output: &str) -> Vec<String> {
1231 output
1232 .lines()
1233 .map(str::trim)
1234 .filter(|title| !title.is_empty())
1235 .map(ToString::to_string)
1236 .collect()
1237}
1238
1239fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1241 let mut branch_tracking_statuses = HashMap::new();
1242
1243 for line in output
1244 .lines()
1245 .map(str::trim)
1246 .filter(|line| !line.is_empty())
1247 {
1248 let mut parts = line.splitn(3, '\t');
1249 let Some(branch_name) = parts
1250 .next()
1251 .map(str::trim)
1252 .filter(|value| !value.is_empty())
1253 else {
1254 continue;
1255 };
1256 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1257 let track = parts.next().map(str::trim).unwrap_or_default();
1258
1259 let status = if upstream_ref.is_empty() {
1260 None
1261 } else {
1262 parse_branch_tracking_counts(track)
1263 };
1264 branch_tracking_statuses.insert(branch_name.to_string(), status);
1265 }
1266
1267 branch_tracking_statuses
1268}
1269
1270fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1272 let normalized_track = track.trim();
1273 if normalized_track.is_empty() || normalized_track == "gone" {
1274 return None;
1275 }
1276
1277 let mut ahead = 0;
1278 let mut behind = 0;
1279
1280 for part in normalized_track.split(',').map(str::trim) {
1281 if let Some(count) = part.strip_prefix("ahead ") {
1282 ahead = count.parse().ok()?;
1283 } else if let Some(count) = part.strip_prefix("behind ") {
1284 behind = count.parse().ok()?;
1285 }
1286 }
1287
1288 Some((ahead, behind))
1289}
1290
1291fn resolve_diff_target(
1297 repo_path: &Path,
1298 base_branch: &str,
1299 merge_base: &str,
1300) -> Result<String, GitError> {
1301 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1302 if !cherry_output.status.success() {
1303 return Ok(merge_base.to_string());
1304 }
1305
1306 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1307 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1308 return Ok(merge_base.to_string());
1309 };
1310
1311 Ok(last_leading_applied_commit.to_string())
1312}
1313
1314fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1321 let mut last_applied_commit = None;
1322
1323 for line in cherry_output.lines() {
1324 let trimmed_line = line.trim();
1325 if trimmed_line.is_empty() {
1326 continue;
1327 }
1328
1329 let mut parts = trimmed_line.split_whitespace();
1330 let marker = parts.next()?;
1331 let commit_hash = parts.next()?;
1332
1333 if marker == "-" {
1334 last_applied_commit = Some(commit_hash);
1335
1336 continue;
1337 }
1338
1339 if marker == "+" {
1340 break;
1341 }
1342
1343 break;
1344 }
1345
1346 last_applied_commit
1347}
1348
1349async fn commit_all_with_retry(
1357 repo_path: PathBuf,
1358 commit_message: String,
1359 message_strategy: SingleCommitMessageStrategy,
1360 no_verify: bool,
1361 amend_existing_commit: bool,
1362) -> Result<(), GitError> {
1363 spawn_blocking(move || {
1364 stage_all_sync(&repo_path)?;
1365
1366 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1367 let output = run_commit_command(
1368 &repo_path,
1369 &commit_message,
1370 message_strategy,
1371 no_verify,
1372 amend_existing_commit,
1373 )?;
1374
1375 if output.status.success() {
1376 return Ok(());
1377 }
1378
1379 let stderr = String::from_utf8_lossy(&output.stderr);
1380 let stdout = String::from_utf8_lossy(&output.stdout);
1381 if is_nothing_to_commit_output(&stdout, &stderr) {
1382 return Err(nothing_to_commit_error());
1383 }
1384
1385 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1386 reset_empty_amend_sync(&repo_path)?;
1387
1388 return Err(nothing_to_commit_error());
1389 }
1390
1391 if is_hook_modified_error(&stdout, &stderr) {
1392 stage_all_sync(&repo_path)?;
1393
1394 continue;
1395 }
1396
1397 let detail = command_output_detail(&output.stdout, &output.stderr);
1398
1399 return Err(GitError::CommandFailed {
1400 command: "git commit".to_string(),
1401 stderr: detail,
1402 });
1403 }
1404
1405 Err(GitError::CommandFailed {
1406 command: "git commit".to_string(),
1407 stderr: format!(
1408 "Failed to commit: commit hooks kept modifying files after \
1409 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1410 ),
1411 })
1412 })
1413 .await?
1414}
1415
1416fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1419 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1420 .iter()
1421 .find(|config_file| repo_path.join(config_file).is_file())
1422 else {
1423 return Ok(());
1424 };
1425 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1426
1427 if is_executable_hook(&hook_path) {
1428 return Ok(());
1429 }
1430
1431 Err(GitError::PreCommitHookMissing {
1432 config_file: (*config_file).to_string(),
1433 })
1434}
1435
1436fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1438 let hooks_path_output =
1439 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1440 let hooks_path = if hooks_path_output.status.success() {
1441 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1442 } else if hooks_path_output.status.code() == Some(1) {
1443 let default_hook_path = run_git_command_sync(
1444 repo_path,
1445 &["rev-parse", "--git-path", "hooks/pre-commit"],
1446 "Failed to resolve Git pre-commit hook path",
1447 )?;
1448
1449 return Ok(resolve_repo_path(
1450 repo_path,
1451 PathBuf::from(default_hook_path.trim()),
1452 ));
1453 } else {
1454 return Err(GitError::CommandFailed {
1455 command: "git config --path --get core.hooksPath".to_string(),
1456 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1457 });
1458 };
1459
1460 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1461}
1462
1463fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1464 if path.is_absolute() {
1465 return path;
1466 }
1467
1468 repo_path.join(path)
1469}
1470
1471#[cfg(unix)]
1472fn is_executable_hook(hook_path: &Path) -> bool {
1473 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1474}
1475
1476#[cfg(not(unix))]
1477fn is_executable_hook(hook_path: &Path) -> bool {
1478 hook_path.is_file()
1479}
1480
1481fn nothing_to_commit_error() -> GitError {
1483 GitError::CommandFailed {
1484 command: "git commit".to_string(),
1485 stderr: "Nothing to commit: no changes detected".to_string(),
1486 }
1487}
1488
1489fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1492 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1493
1494 combined.contains("nothing to commit")
1495}
1496
1497fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1500 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1501 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1502
1503 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1504}
1505
1506fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1509 run_git_command_sync(
1510 repo_path,
1511 &["reset", "HEAD^"],
1512 "Git reset after empty amend failed",
1513 )?;
1514
1515 Ok(())
1516}
1517
1518fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1522 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1523
1524 if !output.status.success() {
1525 let detail = command_output_detail(&output.stdout, &output.stderr);
1526
1527 return Err(GitError::CommandFailed {
1528 command: "git add -A".to_string(),
1529 stderr: format!("Failed to stage changes: {detail}"),
1530 });
1531 }
1532
1533 Ok(())
1534}
1535
1536fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1538 if !has_head_commit_sync(repo_path)? {
1539 return Ok(None);
1540 }
1541
1542 let output = run_git_command_sync(
1543 repo_path,
1544 &["log", "-1", "--pretty=%B"],
1545 "Failed to read HEAD commit message",
1546 )?;
1547
1548 Ok(Some(output.trim().to_string()))
1549}
1550
1551fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1553 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1554
1555 if output.status.success() {
1556 return Ok(true);
1557 }
1558
1559 let detail = command_output_detail(&output.stdout, &output.stderr);
1560 let normalized_detail = detail.to_ascii_lowercase();
1561 if normalized_detail.contains("needed a single revision")
1562 || normalized_detail.contains("unknown revision")
1563 || normalized_detail.contains("does not have any commits yet")
1564 {
1565 return Ok(false);
1566 }
1567
1568 Err(GitError::CommandFailed {
1569 command: "git rev-parse --verify HEAD".to_string(),
1570 stderr: detail,
1571 })
1572}
1573
1574fn run_commit_command(
1578 repo_path: &Path,
1579 commit_message: &str,
1580 message_strategy: SingleCommitMessageStrategy,
1581 no_verify: bool,
1582 amend_existing_commit: bool,
1583) -> Result<Output, GitError> {
1584 let mut args = vec!["commit"];
1585 if amend_existing_commit {
1586 args.push("--amend");
1587 match message_strategy {
1588 SingleCommitMessageStrategy::Replace => {
1589 args.push("-m");
1590 args.push(commit_message);
1591 }
1592 SingleCommitMessageStrategy::Reuse => {
1593 args.push("--no-edit");
1594 }
1595 }
1596 } else {
1597 args.push("-m");
1598 args.push(commit_message);
1599 }
1600
1601 if no_verify {
1602 args.push("--no-verify");
1603 }
1604
1605 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1606}
1607
1608fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1610 let combined = format!(
1611 "{stdout}
1612{stderr}"
1613 )
1614 .to_ascii_lowercase();
1615
1616 combined.contains("files were modified by this hook")
1617}
1618
1619pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1621 let normalized_detail = detail.to_ascii_lowercase();
1622
1623 normalized_detail.contains("has no upstream branch")
1624 || normalized_detail.contains("no upstream branch")
1625 || normalized_detail.contains("set-upstream")
1626}
1627
1628#[cfg(test)]
1629mod tests {
1630 use std::fs;
1631 #[cfg(unix)]
1632 use std::os::unix::fs::PermissionsExt;
1633 use std::path::Path;
1634 use std::process::{Command, Output};
1635
1636 use mockall::Sequence;
1637 use mockall::predicate::function;
1638 use tempfile::tempdir;
1639
1640 use super::*;
1641 use crate::repo::MockAsyncGitCommandRunner;
1642
1643 fn async_git_output(
1645 exit_code: i32,
1646 stdout: impl Into<Vec<u8>>,
1647 stderr: impl Into<Vec<u8>>,
1648 ) -> AsyncGitCommandOutput {
1649 AsyncGitCommandOutput {
1650 exit_code: Some(exit_code),
1651 stderr: stderr.into(),
1652 stdout: stdout.into(),
1653 }
1654 }
1655
1656 fn run_git_command(repo_path: &Path, args: &[&str]) {
1658 let output = git_command_output(repo_path, args);
1659
1660 assert!(
1661 output.status.success(),
1662 "git command {:?} failed: {}",
1663 args,
1664 String::from_utf8_lossy(&output.stderr)
1665 );
1666 }
1667
1668 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1670 Command::new("git")
1671 .args(args)
1672 .current_dir(repo_path)
1673 .output()
1674 .expect("failed to run git command")
1675 }
1676
1677 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1679 let output = git_command_output(repo_path, args);
1680
1681 assert!(
1682 output.status.success(),
1683 "git command {:?} failed: {}",
1684 args,
1685 String::from_utf8_lossy(&output.stderr)
1686 );
1687
1688 String::from_utf8(output.stdout)
1689 .expect("git stdout should be valid utf-8")
1690 .trim()
1691 .to_string()
1692 }
1693
1694 fn setup_test_git_repo(repo_path: &Path) {
1696 run_git_command(repo_path, &["init", "-b", "main"]);
1697 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1698 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1699 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1700 run_git_command(repo_path, &["add", "README.md"]);
1701 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1702 }
1703
1704 #[tokio::test]
1705 async fn delete_branch_removes_branch_from_isolated_repository() {
1706 let temp_dir = tempdir().expect("failed to create temp dir");
1708 setup_test_git_repo(temp_dir.path());
1709 run_git_command(temp_dir.path(), &["branch", "review/topic"]);
1710
1711 delete_branch(temp_dir.path().to_path_buf(), "review/topic".to_string())
1713 .await
1714 .expect("branch deletion should succeed");
1715
1716 let branch_lookup = git_command_output(
1718 temp_dir.path(),
1719 &["show-ref", "--verify", "--quiet", "refs/heads/review/topic"],
1720 );
1721 assert!(!branch_lookup.status.success());
1722 }
1723
1724 #[tokio::test]
1725 async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1726 let temp_dir = tempdir().expect("failed to create temp dir");
1728 setup_test_git_repo(temp_dir.path());
1729 fs::write(temp_dir.path().join("README.md"), "staged change\n")
1730 .expect("failed to write staged change");
1731 run_git_command(temp_dir.path(), &["add", "README.md"]);
1732 fs::write(
1733 temp_dir.path().join("README.md"),
1734 "staged change\nunstaged change\n",
1735 )
1736 .expect("failed to write unstaged change");
1737 fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1738 .expect("failed to write untracked file");
1739 let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1740 let status_before = git_command_output(
1741 temp_dir.path(),
1742 &["status", "--porcelain=v1", "--untracked-files=all"],
1743 )
1744 .stdout;
1745
1746 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1748 let changed_files =
1749 diff_changed_files(temp_dir.path().to_path_buf(), "main".to_string()).await;
1750
1751 let diff_output = result.expect("diff should succeed");
1753 let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1754 let status_after = git_command_output(
1755 temp_dir.path(),
1756 &["status", "--porcelain=v1", "--untracked-files=all"],
1757 )
1758 .stdout;
1759 assert!(diff_output.contains("staged change"));
1760 assert!(diff_output.contains("unstaged change"));
1761 assert!(diff_output.contains("untracked change"));
1762 assert_eq!(
1763 changed_files.expect("changed files should load"),
1764 vec!["README.md".to_string(), "new.txt".to_string()]
1765 );
1766 assert_eq!(cached_diff_after, cached_diff_before);
1767 assert_eq!(status_after, status_before);
1768 }
1769
1770 #[tokio::test]
1771 async fn diff_reports_repository_unavailable_outside_git_repository() {
1772 let temp_dir = tempdir().expect("failed to create temp dir");
1774
1775 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1777
1778 assert!(matches!(
1780 result,
1781 Err(GitError::RepositoryUnavailable { detail })
1782 if detail.to_ascii_lowercase().contains("not a git repository")
1783 ));
1784 }
1785
1786 #[test]
1787 fn diff_reports_repository_unavailable_when_removed_after_index_resolution() {
1788 let temp_dir = tempdir().expect("failed to create temp dir");
1790 let preserved_index_dir = tempdir().expect("failed to create preserved index dir");
1791 setup_test_git_repo(temp_dir.path());
1792 let index_path =
1793 resolve_diff_index_path(temp_dir.path()).expect("index path should resolve");
1794 let index_path = PathBuf::from(index_path.trim());
1795 let index_path = temp_dir.path().join(index_path);
1796 let preserved_index_path = preserved_index_dir.path().join("index");
1797 fs::copy(index_path, &preserved_index_path).expect("index copy should succeed");
1798 fs::remove_dir_all(temp_dir.path()).expect("worktree removal should succeed");
1799
1800 let result = diff_output_after_index_resolution(
1802 temp_dir.path(),
1803 "main",
1804 false,
1805 &preserved_index_path,
1806 );
1807
1808 assert!(matches!(
1810 result,
1811 Err(GitError::RepositoryUnavailable { detail })
1812 if detail.contains("git add -A --intent-to-add")
1813 ));
1814 }
1815
1816 #[tokio::test]
1817 async fn diff_preserves_invalid_base_reference_error() {
1818 let temp_dir = tempdir().expect("failed to create temp dir");
1820 setup_test_git_repo(temp_dir.path());
1821
1822 let result = diff(
1824 temp_dir.path().to_path_buf(),
1825 "missing-base-reference".to_string(),
1826 )
1827 .await;
1828
1829 assert!(matches!(
1831 result,
1832 Err(GitError::CommandFailed { command, stderr })
1833 if command == "git diff missing-base-reference"
1834 && stderr.contains("Git diff failed")
1835 ));
1836 }
1837
1838 #[test]
1839 fn diff_repository_error_classification_preserves_unrelated_failures() {
1840 let temp_dir = tempdir().expect("failed to create temp dir");
1842 setup_test_git_repo(temp_dir.path());
1843 let error = GitError::CommandFailed {
1844 command: "git rev-parse --git-path index".to_string(),
1845 stderr: "fatal: ambiguous argument".to_string(),
1846 };
1847
1848 let classified = classify_diff_repository_error(temp_dir.path(), error);
1850
1851 assert!(matches!(
1853 classified,
1854 GitError::CommandFailed { command, stderr }
1855 if command == "git rev-parse --git-path index"
1856 && stderr == "fatal: ambiguous argument"
1857 ));
1858 }
1859
1860 #[test]
1861 fn diff_repository_error_classification_ignores_localized_diagnostic() {
1862 let temp_dir = tempdir().expect("failed to create temp dir");
1864 let error = GitError::CommandFailed {
1865 command: "git rev-parse --git-path index".to_string(),
1866 stderr: "fatal: kein Git-Repository".to_string(),
1867 };
1868
1869 let classified = classify_diff_repository_error(temp_dir.path(), error);
1871
1872 assert!(matches!(
1874 classified,
1875 GitError::RepositoryUnavailable { detail }
1876 if detail == "git rev-parse --git-path index: fatal: kein Git-Repository"
1877 ));
1878 }
1879
1880 #[test]
1881 fn diff_repository_probe_preserves_spawn_failure() {
1882 let probe = Err(GitError::CommandFailed {
1884 command: "git rev-parse --git-dir".to_string(),
1885 stderr: "git executable unavailable".to_string(),
1886 });
1887
1888 let unavailable = diff_repository_probe_is_unavailable(probe);
1890
1891 assert!(!unavailable);
1893 }
1894
1895 #[test]
1896 fn diff_repository_error_classification_types_missing_directory() {
1897 let temp_dir = tempdir().expect("failed to create temp dir");
1899 let missing_path = temp_dir.path().join("removed-worktree");
1900 let error = GitError::Io(std::io::Error::new(
1901 std::io::ErrorKind::NotFound,
1902 "worktree removed",
1903 ));
1904
1905 let classified = classify_diff_repository_error(&missing_path, error);
1907
1908 assert!(matches!(
1910 classified,
1911 GitError::RepositoryUnavailable { detail } if detail == "worktree removed"
1912 ));
1913 }
1914
1915 #[tokio::test]
1916 async fn read_worktree_file_returns_text_for_safe_nested_path() {
1917 let temp_dir = tempdir().expect("failed to create temp dir");
1919 let docs_dir = temp_dir.path().join("docs");
1920 fs::create_dir(&docs_dir).expect("failed to create docs directory");
1921 fs::write(docs_dir.join("README.md"), "# Preview\n")
1922 .expect("failed to write markdown file");
1923
1924 let result =
1926 read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1927
1928 assert_eq!(
1930 result.expect("worktree read should succeed"),
1931 WorktreeFileContent::Text("# Preview\n".to_string())
1932 );
1933 }
1934
1935 #[tokio::test]
1936 async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
1937 let temp_dir = tempdir().expect("failed to create temp dir");
1939 fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
1940 .expect("failed to write binary file");
1941 fs::write(
1942 temp_dir.path().join("large.md"),
1943 vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
1944 )
1945 .expect("failed to write oversize file");
1946
1947 let missing =
1949 read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
1950 let binary =
1951 read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
1952 let too_large =
1953 read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
1954
1955 assert_eq!(
1957 missing.expect("missing read should succeed"),
1958 WorktreeFileContent::Missing
1959 );
1960 assert_eq!(
1961 binary.expect("binary read should succeed"),
1962 WorktreeFileContent::Binary
1963 );
1964 assert_eq!(
1965 too_large.expect("oversize read should succeed"),
1966 WorktreeFileContent::TooLarge
1967 );
1968 }
1969
1970 #[tokio::test]
1971 async fn read_worktree_file_rejects_unsafe_relative_paths() {
1972 let temp_dir = tempdir().expect("failed to create temp dir");
1974 let absolute_path = temp_dir.path().join("README.md");
1975
1976 let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
1978 let parent =
1979 read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
1980 let absolute = read_worktree_file(
1981 temp_dir.path().to_path_buf(),
1982 absolute_path.to_string_lossy().into_owned(),
1983 )
1984 .await;
1985
1986 for result in [empty, parent, absolute] {
1988 assert!(
1989 matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
1990 );
1991 }
1992 }
1993
1994 #[cfg(unix)]
1995 #[tokio::test]
1996 async fn read_worktree_file_rejects_symlinks_outside_repository() {
1997 let temp_dir = tempdir().expect("failed to create temp dir");
1999 let outside_dir = tempdir().expect("failed to create outside temp dir");
2000 let outside_file = outside_dir.path().join("outside.md");
2001 fs::write(&outside_file, "outside").expect("failed to write outside file");
2002 std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
2003 .expect("failed to create outside symlink");
2004
2005 let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
2007
2008 assert!(
2010 matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
2011 );
2012 }
2013
2014 #[cfg(unix)]
2015 #[tokio::test]
2016 async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
2017 let temp_dir = tempdir().expect("failed to create temp dir");
2019 std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
2020 .expect("failed to create symlink loop");
2021
2022 let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
2024
2025 assert!(matches!(result, Err(GitError::Io(_))));
2027 }
2028
2029 #[test]
2030 fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
2031 let temp_dir = tempdir().expect("failed to create temp dir");
2033 let path_without_parent = Path::new("/");
2034 let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
2035 let missing_index = temp_dir.path().join("missing-index");
2036
2037 let parent_error = copy_git_index_to_temp(path_without_parent);
2039 let create_error = copy_git_index_to_temp(&missing_parent_index);
2040 let copy_error = copy_git_index_to_temp(&missing_index);
2041
2042 assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
2044 assert!(matches!(
2045 create_error,
2046 Err(GitError::CommandFailed { ref command, .. })
2047 if command == "create temporary git index"
2048 ));
2049 assert!(matches!(
2050 copy_error,
2051 Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
2052 ));
2053 }
2054
2055 #[test]
2056 fn run_git_command_with_index_sync_maps_process_and_command_failures() {
2057 let temp_dir = tempdir().expect("failed to create temp dir");
2059 let index_path = temp_dir.path().join("index");
2060 let missing_repo_path = temp_dir.path().join("missing-repository");
2061 fs::write(&index_path, []).expect("failed to create temporary index");
2062
2063 let process_error = run_git_command_with_index_sync(
2065 &missing_repo_path,
2066 &["status"],
2067 &index_path,
2068 "Expected process failure",
2069 );
2070 let command_error = run_git_command_with_index_sync(
2071 temp_dir.path(),
2072 &["definitely-not-a-git-command"],
2073 &index_path,
2074 "Expected git failure",
2075 );
2076
2077 assert!(matches!(
2079 process_error,
2080 Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
2081 ));
2082 assert!(matches!(
2083 command_error,
2084 Err(GitError::CommandFailed {
2085 ref command,
2086 ref stderr,
2087 }) if command == "git definitely-not-a-git-command"
2088 && stderr.starts_with("Expected git failure:")
2089 ));
2090 }
2091
2092 #[cfg(unix)]
2093 fn write_executable_pre_commit_hook(hook_path: &Path) {
2094 fs::create_dir_all(
2095 hook_path
2096 .parent()
2097 .expect("pre-commit hook should have a parent directory"),
2098 )
2099 .expect("failed to create hooks directory");
2100 fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
2101 let mut permissions = fs::metadata(hook_path)
2102 .expect("failed to read pre-commit hook metadata")
2103 .permissions();
2104 permissions.set_mode(0o755);
2105 fs::set_permissions(hook_path, permissions)
2106 .expect("failed to make pre-commit hook executable");
2107 }
2108
2109 #[test]
2110 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
2111 let temp_dir = tempdir().expect("failed to create temp dir");
2113 setup_test_git_repo(temp_dir.path());
2114
2115 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2117
2118 assert!(result.is_ok());
2120 }
2121
2122 #[test]
2123 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
2124 let temp_dir = tempdir().expect("failed to create temp dir");
2126 setup_test_git_repo(temp_dir.path());
2127 fs::write(
2128 temp_dir.path().join(".pre-commit-config.yaml"),
2129 "repos: []\n",
2130 )
2131 .expect("failed to write pre-commit configuration");
2132
2133 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2135
2136 assert!(matches!(
2138 result,
2139 Err(GitError::PreCommitHookMissing { ref config_file })
2140 if config_file == ".pre-commit-config.yaml"
2141 ));
2142 }
2143
2144 #[cfg(unix)]
2145 #[test]
2146 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
2147 let temp_dir = tempdir().expect("failed to create temp dir");
2149 setup_test_git_repo(temp_dir.path());
2150 fs::write(
2151 temp_dir.path().join(".pre-commit-config.yaml"),
2152 "repos: []\n",
2153 )
2154 .expect("failed to write pre-commit configuration");
2155 let hook_path = temp_dir.path().join(git_command_stdout(
2156 temp_dir.path(),
2157 &["rev-parse", "--git-path", "hooks/pre-commit"],
2158 ));
2159 write_executable_pre_commit_hook(&hook_path);
2160
2161 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2163
2164 assert!(result.is_ok());
2166 }
2167
2168 #[cfg(unix)]
2169 #[test]
2170 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
2171 let temp_dir = tempdir().expect("failed to create temp dir");
2173 setup_test_git_repo(temp_dir.path());
2174 fs::write(
2175 temp_dir.path().join(".pre-commit-config.yaml"),
2176 "repos: []\n",
2177 )
2178 .expect("failed to write pre-commit configuration");
2179 run_git_command(
2180 temp_dir.path(),
2181 &["config", "core.hooksPath", ".custom-hooks"],
2182 );
2183 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
2184
2185 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2187
2188 assert!(result.is_ok());
2190 }
2191
2192 #[cfg(unix)]
2193 #[test]
2194 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
2195 let temp_dir = tempdir().expect("failed to create temp dir");
2197 setup_test_git_repo(temp_dir.path());
2198 fs::write(
2199 temp_dir.path().join(".pre-commit-config.yaml"),
2200 "repos: []\n",
2201 )
2202 .expect("failed to write pre-commit configuration");
2203 let hook_path = temp_dir.path().join(git_command_stdout(
2204 temp_dir.path(),
2205 &["rev-parse", "--git-path", "hooks/pre-commit"],
2206 ));
2207 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
2208 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
2209 .expect("failed to set mismatched execute permissions");
2210
2211 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2213
2214 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
2216 }
2217
2218 #[tokio::test]
2219 async fn commit_all_allows_configured_validation_without_hook() {
2220 let temp_dir = tempdir().expect("failed to create temp dir");
2222 setup_test_git_repo(temp_dir.path());
2223 fs::write(
2224 temp_dir.path().join(".pre-commit-config.yaml"),
2225 "repos: []\n",
2226 )
2227 .expect("failed to write pre-commit configuration");
2228 fs::write(temp_dir.path().join("README.md"), "changed\n")
2229 .expect("failed to write worktree change");
2230
2231 let result = commit_all(
2233 temp_dir.path().to_path_buf(),
2234 "Change README".to_string(),
2235 false,
2236 )
2237 .await;
2238
2239 assert!(result.is_ok());
2241 assert_eq!(
2242 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
2243 "Change README"
2244 );
2245 }
2246
2247 #[tokio::test]
2248 async fn current_branch_name_returns_error_for_detached_head() {
2249 let temp_dir = tempdir().expect("failed to create temp dir");
2251 setup_test_git_repo(temp_dir.path());
2252 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
2253 let command_runner = ProcessAsyncGitCommandRunner;
2254
2255 let result = current_branch_name(temp_dir.path(), &command_runner).await;
2257
2258 let error = result.expect_err("detached HEAD should fail");
2260 assert!(error.to_string().contains("detached HEAD"));
2261 }
2262
2263 #[tokio::test]
2264 async fn current_branch_remote_name_returns_none_when_remote_is_not_configured() {
2265 let temp_dir = tempdir().expect("failed to create temp dir");
2267 setup_test_git_repo(temp_dir.path());
2268 let command_runner = ProcessAsyncGitCommandRunner;
2269
2270 let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2272 .await
2273 .expect("missing branch remote should not be a command failure");
2274
2275 assert_eq!(remote_name, None);
2277 }
2278
2279 #[tokio::test]
2280 async fn current_branch_remote_name_returns_configured_non_origin_remote() {
2281 let temp_dir = tempdir().expect("failed to create temp dir");
2283 setup_test_git_repo(temp_dir.path());
2284 run_git_command(
2285 temp_dir.path(),
2286 &["config", "branch.main.remote", "review-remote"],
2287 );
2288 let command_runner = ProcessAsyncGitCommandRunner;
2289
2290 let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2292 .await
2293 .expect("configured branch remote should resolve");
2294
2295 assert_eq!(remote_name, Some("review-remote".to_string()));
2297 }
2298
2299 #[test]
2300 fn parse_current_branch_remote_output_preserves_fatal_config_error() {
2301 let output = AsyncGitCommandOutput {
2303 exit_code: Some(128),
2304 stderr: b"fatal: bad config line".to_vec(),
2305 stdout: Vec::new(),
2306 };
2307
2308 let error = parse_current_branch_remote_output(&output, "branch.main.remote")
2310 .expect_err("malformed config should remain an error");
2311
2312 assert!(matches!(
2314 error,
2315 GitError::CommandFailed { command, stderr }
2316 if command == "git config --get branch.main.remote"
2317 && stderr.contains("Failed to resolve current branch remote")
2318 ));
2319 }
2320
2321 #[tokio::test]
2322 async fn primary_upstream_reference_uses_first_non_empty_line() {
2323 let temp_dir = tempdir().expect("failed to create temp dir");
2325 let remote_dir = tempdir().expect("failed to create remote temp dir");
2326 setup_test_git_repo(temp_dir.path());
2327 run_git_command(remote_dir.path(), &["init", "--bare"]);
2328 let remote_path = remote_dir.path().to_string_lossy().to_string();
2329 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2330 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2331 run_git_command(
2332 temp_dir.path(),
2333 &[
2334 "config",
2335 "--replace-all",
2336 "branch.main.merge",
2337 "refs/heads/main",
2338 ],
2339 );
2340 run_git_command(
2341 temp_dir.path(),
2342 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
2343 );
2344 let command_runner = ProcessAsyncGitCommandRunner;
2345
2346 let upstream_reference = primary_upstream_reference(temp_dir.path(), &command_runner)
2348 .await
2349 .expect("failed to resolve upstream");
2350
2351 assert_eq!(upstream_reference, "origin/main");
2353 }
2354
2355 #[tokio::test]
2356 async fn pull_rebase_retries_index_lock_through_async_runner() {
2357 let repo_path = PathBuf::from("test-repo");
2359 let mut command_runner = MockAsyncGitCommandRunner::new();
2360 let mut sequence = Sequence::new();
2361 command_runner
2362 .expect_run()
2363 .with(function(|command: &AsyncGitCommand| {
2364 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2365 }))
2366 .times(1)
2367 .in_sequence(&mut sequence)
2368 .return_once(|_| {
2369 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2370 });
2371 for output in [
2372 async_git_output(
2373 128,
2374 Vec::new(),
2375 "fatal: Unable to create '.git/index.lock': File exists.",
2376 ),
2377 async_git_output(0, Vec::new(), Vec::new()),
2378 ] {
2379 command_runner
2380 .expect_run()
2381 .with(function(|command: &AsyncGitCommand| {
2382 command.arguments == ["pull", "--rebase", "origin", "main"]
2383 && command.environment
2384 == [
2385 ("GIT_EDITOR".to_string(), ":".to_string()),
2386 ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
2387 ]
2388 }))
2389 .times(1)
2390 .in_sequence(&mut sequence)
2391 .return_once(move |_| Box::pin(async move { Ok(output) }));
2392 }
2393
2394 let result = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO).await;
2396
2397 assert!(matches!(result, Ok(PullRebaseResult::Completed)));
2399 }
2400
2401 #[tokio::test]
2402 async fn pull_rebase_preserves_non_conflict_command_failure() {
2403 let repo_path = PathBuf::from("test-repo");
2405 let mut command_runner = MockAsyncGitCommandRunner::new();
2406 let mut sequence = Sequence::new();
2407 command_runner
2408 .expect_run()
2409 .with(function(|command: &AsyncGitCommand| {
2410 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2411 }))
2412 .times(1)
2413 .in_sequence(&mut sequence)
2414 .return_once(|_| {
2415 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2416 });
2417 command_runner
2418 .expect_run()
2419 .with(function(|command: &AsyncGitCommand| {
2420 command.arguments == ["pull", "--rebase", "origin", "main"]
2421 }))
2422 .times(1)
2423 .in_sequence(&mut sequence)
2424 .return_once(|_| {
2425 Box::pin(async { Ok(async_git_output(128, Vec::new(), "fatal: transport failed")) })
2426 });
2427
2428 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2430 .await
2431 .expect_err("non-conflict pull failure should remain an error");
2432
2433 assert!(matches!(
2435 error,
2436 GitError::CommandFailed { command, stderr }
2437 if command == "git pull --rebase" && stderr == "fatal: transport failed"
2438 ));
2439 }
2440
2441 #[tokio::test]
2442 async fn pull_rebase_rejects_local_upstream_without_configured_remote() {
2443 let repo_path = PathBuf::from("test-repo");
2445 let mut command_runner = MockAsyncGitCommandRunner::new();
2446 let mut sequence = Sequence::new();
2447 let expectations = [
2448 (
2449 vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2450 async_git_output(0, "main\n", Vec::new()),
2451 ),
2452 (
2453 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2454 async_git_output(0, "main\n", Vec::new()),
2455 ),
2456 (
2457 vec!["config", "--get", "branch.main.remote"],
2458 async_git_output(1, Vec::new(), Vec::new()),
2459 ),
2460 ];
2461 for (arguments, output) in expectations {
2462 let arguments = arguments
2463 .into_iter()
2464 .map(str::to_string)
2465 .collect::<Vec<_>>();
2466 command_runner
2467 .expect_run()
2468 .with(function(move |command: &AsyncGitCommand| {
2469 command.arguments == arguments
2470 }))
2471 .times(1)
2472 .in_sequence(&mut sequence)
2473 .return_once(move |_| Box::pin(async move { Ok(output) }));
2474 }
2475
2476 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2478 .await
2479 .expect_err("local upstream without a remote should fail");
2480
2481 assert!(matches!(
2483 error,
2484 GitError::OutputParse(message)
2485 if message == "Failed to resolve current branch remote: not configured"
2486 ));
2487 }
2488
2489 #[tokio::test]
2490 async fn pull_rebase_returns_last_index_lock_failure_after_retry_exhaustion() {
2491 let repo_path = PathBuf::from("test-repo");
2493 let mut command_runner = MockAsyncGitCommandRunner::new();
2494 let mut sequence = Sequence::new();
2495 command_runner
2496 .expect_run()
2497 .with(function(|command: &AsyncGitCommand| {
2498 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2499 }))
2500 .times(1)
2501 .in_sequence(&mut sequence)
2502 .return_once(|_| {
2503 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2504 });
2505 command_runner
2506 .expect_run()
2507 .with(function(|command: &AsyncGitCommand| {
2508 command.arguments == ["pull", "--rebase", "origin", "main"]
2509 }))
2510 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
2511 .in_sequence(&mut sequence)
2512 .returning(|_| {
2513 Box::pin(async {
2514 Ok(async_git_output(
2515 128,
2516 Vec::new(),
2517 "fatal: Unable to create '.git/index.lock': File exists.",
2518 ))
2519 })
2520 });
2521
2522 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2524 .await
2525 .expect_err("exhausted index-lock retries should return the last failure");
2526
2527 assert!(matches!(
2529 error,
2530 GitError::CommandFailed { command, stderr }
2531 if command == "git pull --rebase" && stderr.contains("index.lock")
2532 ));
2533 }
2534
2535 #[tokio::test]
2536 async fn remote_branch_lookup_uses_origin_fallback_through_async_runner() {
2537 let repo_path = PathBuf::from("test-repo");
2539 let mut command_runner = MockAsyncGitCommandRunner::new();
2540 let mut sequence = Sequence::new();
2541 let expectations = [
2542 (
2543 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2544 async_git_output(0, "main\n", Vec::new()),
2545 ),
2546 (
2547 vec!["config", "--get", "branch.main.remote"],
2548 async_git_output(1, Vec::new(), Vec::new()),
2549 ),
2550 (
2551 vec!["ls-remote", "--heads", "origin", "review/topic"],
2552 async_git_output(0, "abc123\trefs/heads/review/topic\n", Vec::new()),
2553 ),
2554 ];
2555 for (arguments, output) in expectations {
2556 let arguments = arguments
2557 .into_iter()
2558 .map(str::to_string)
2559 .collect::<Vec<_>>();
2560 command_runner
2561 .expect_run()
2562 .with(function(move |command: &AsyncGitCommand| {
2563 command.arguments == arguments
2564 }))
2565 .times(1)
2566 .in_sequence(&mut sequence)
2567 .return_once(move |_| Box::pin(async move { Ok(output) }));
2568 }
2569
2570 let exists = remote_branch_exists_with_runner(
2572 repo_path,
2573 "review/topic".to_string(),
2574 &command_runner,
2575 )
2576 .await
2577 .expect("remote branch lookup should succeed");
2578
2579 assert!(exists);
2581 }
2582
2583 #[tokio::test]
2584 async fn remote_branch_lookup_checks_isolated_local_remote() {
2585 let temp_dir = tempdir().expect("failed to create temp dir");
2587 let remote_dir = tempdir().expect("failed to create remote temp dir");
2588 setup_test_git_repo(temp_dir.path());
2589 run_git_command(remote_dir.path(), &["init", "--bare"]);
2590 let remote_path = remote_dir.path().to_string_lossy().to_string();
2591 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2592 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2593
2594 let exists = remote_branch_exists(temp_dir.path().to_path_buf(), "main".to_string())
2596 .await
2597 .expect("local remote branch lookup should succeed");
2598
2599 assert!(exists);
2601 }
2602
2603 #[tokio::test]
2604 async fn remote_branch_lookup_preserves_remote_config_failure() {
2605 let repo_path = PathBuf::from("test-repo");
2607 let mut command_runner = MockAsyncGitCommandRunner::new();
2608 let mut sequence = Sequence::new();
2609 command_runner
2610 .expect_run()
2611 .with(function(|command: &AsyncGitCommand| {
2612 command.arguments == ["rev-parse", "--abbrev-ref", "HEAD"]
2613 }))
2614 .times(1)
2615 .in_sequence(&mut sequence)
2616 .return_once(|_| Box::pin(async { Ok(async_git_output(0, "main\n", Vec::new())) }));
2617 command_runner
2618 .expect_run()
2619 .with(function(|command: &AsyncGitCommand| {
2620 command.arguments == ["config", "--get", "branch.main.remote"]
2621 }))
2622 .times(1)
2623 .in_sequence(&mut sequence)
2624 .return_once(|_| {
2625 Box::pin(async {
2626 Ok(async_git_output(
2627 128,
2628 Vec::new(),
2629 "fatal: malformed branch config",
2630 ))
2631 })
2632 });
2633
2634 let error = remote_branch_exists_with_runner(
2636 repo_path,
2637 "review/topic".to_string(),
2638 &command_runner,
2639 )
2640 .await
2641 .expect_err("remote config failure should not fall back to origin");
2642
2643 assert!(matches!(
2645 error,
2646 GitError::CommandFailed { command, stderr }
2647 if command == "git config --get branch.main.remote"
2648 && stderr.contains("malformed branch config")
2649 ));
2650 }
2651
2652 #[tokio::test]
2653 async fn push_without_upstream_reuses_configured_remote() {
2654 let repo_path = PathBuf::from("test-repo");
2656 let mut command_runner = MockAsyncGitCommandRunner::new();
2657 let mut sequence = Sequence::new();
2658 let expectations = [
2659 (
2660 vec!["push", "--force-with-lease"],
2661 async_git_output(128, Vec::new(), "fatal: no upstream branch"),
2662 ),
2663 (
2664 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2665 async_git_output(0, "main\n", Vec::new()),
2666 ),
2667 (
2668 vec!["config", "--get", "branch.main.remote"],
2669 async_git_output(0, "review-remote\n", Vec::new()),
2670 ),
2671 (
2672 vec![
2673 "push",
2674 "--force-with-lease",
2675 "--set-upstream",
2676 "review-remote",
2677 "HEAD",
2678 ],
2679 async_git_output(0, Vec::new(), Vec::new()),
2680 ),
2681 (
2682 vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2683 async_git_output(0, "review-remote/main\n", Vec::new()),
2684 ),
2685 ];
2686 for (arguments, output) in expectations {
2687 let arguments = arguments
2688 .into_iter()
2689 .map(str::to_string)
2690 .collect::<Vec<_>>();
2691 command_runner
2692 .expect_run()
2693 .with(function(move |command: &AsyncGitCommand| {
2694 command.arguments == arguments
2695 }))
2696 .times(1)
2697 .in_sequence(&mut sequence)
2698 .return_once(move |_| Box::pin(async move { Ok(output) }));
2699 }
2700
2701 let upstream_reference = push_current_branch_with_runner(repo_path, &command_runner)
2703 .await
2704 .expect("configured remote push should succeed");
2705
2706 assert_eq!(upstream_reference, "review-remote/main");
2708 }
2709
2710 #[test]
2711 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
2712 let output = "\
2714main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
2715 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
2716
2717 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
2719
2720 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
2722 assert_eq!(
2723 branch_tracking_statuses.get("wt/1234abcd"),
2724 Some(&Some((3, 1)))
2725 );
2726 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
2727 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
2728 }
2729
2730 #[tokio::test]
2731 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
2732 let temp_dir = tempdir().expect("failed to create temp dir");
2734 let remote_dir = tempdir().expect("failed to create remote temp dir");
2735 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2736 let contributor_clone_path = contributor_dir.path().join("clone");
2737 setup_test_git_repo(temp_dir.path());
2738 run_git_command(remote_dir.path(), &["init", "--bare"]);
2739 let remote_path = remote_dir.path().to_string_lossy().to_string();
2740 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2741 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2742 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2743 fs::write(temp_dir.path().join("README.md"), "local change\n")
2744 .expect("failed to write local change");
2745 run_git_command(temp_dir.path(), &["add", "README.md"]);
2746 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2747 run_git_command(
2748 contributor_dir.path(),
2749 &["clone", &remote_path, &contributor_clone_path_text],
2750 );
2751 run_git_command(
2752 &contributor_clone_path,
2753 &["config", "user.name", "Contributor User"],
2754 );
2755 run_git_command(
2756 &contributor_clone_path,
2757 &["config", "user.email", "contributor@example.com"],
2758 );
2759 run_git_command(
2760 &contributor_clone_path,
2761 &["checkout", "-B", "main", "origin/main"],
2762 );
2763 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
2764 .expect("failed to write remote change");
2765 run_git_command(&contributor_clone_path, &["add", "README.md"]);
2766 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2767 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2768
2769 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
2771
2772 assert!(matches!(
2774 result,
2775 Ok(PullRebaseResult::Conflict { ref detail })
2776 if {
2777 let normalized_detail = detail.to_ascii_lowercase();
2778
2779 (normalized_detail.contains("conflict")
2780 || normalized_detail.contains("could not apply"))
2781 && !detail.is_empty()
2782 }
2783 ));
2784 }
2785
2786 #[tokio::test]
2787 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
2788 let temp_dir = tempdir().expect("failed to create temp dir");
2790 let remote_dir = tempdir().expect("failed to create remote temp dir");
2791 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2792 let contributor_clone_path = contributor_dir.path().join("clone");
2793 setup_test_git_repo(temp_dir.path());
2794 run_git_command(remote_dir.path(), &["init", "--bare"]);
2795 let remote_path = remote_dir.path().to_string_lossy().to_string();
2796 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2797 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2798 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2799 run_git_command(
2800 contributor_dir.path(),
2801 &["clone", &remote_path, &contributor_clone_path_text],
2802 );
2803 run_git_command(
2804 &contributor_clone_path,
2805 &["config", "user.name", "Contributor User"],
2806 );
2807 run_git_command(
2808 &contributor_clone_path,
2809 &["config", "user.email", "contributor@example.com"],
2810 );
2811 run_git_command(
2812 &contributor_clone_path,
2813 &["checkout", "-B", "main", "origin/main"],
2814 );
2815 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2816 .expect("failed to write remote file");
2817 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2818 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2819 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2820 fs::write(temp_dir.path().join("local.txt"), "local change")
2821 .expect("failed to write local file");
2822 run_git_command(temp_dir.path(), &["add", "local.txt"]);
2823 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2824
2825 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2827
2828 let error = result
2830 .expect_err("non-fast-forward push should fail")
2831 .to_string();
2832 assert!(error.contains("git push"));
2833 assert!(
2834 error.contains("stale info")
2835 || error.contains("rejected")
2836 || error.contains("fetch first")
2837 );
2838 }
2839
2840 #[tokio::test]
2841 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
2842 let temp_dir = tempdir().expect("failed to create temp dir");
2844 let remote_dir = tempdir().expect("failed to create remote temp dir");
2845 setup_test_git_repo(temp_dir.path());
2846 run_git_command(remote_dir.path(), &["init", "--bare"]);
2847 let remote_path = remote_dir.path().to_string_lossy().to_string();
2848 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2849 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2850 fs::write(
2851 temp_dir.path().join("README.md"),
2852 "first published version\n",
2853 )
2854 .expect("failed to write first version");
2855 run_git_command(temp_dir.path(), &["add", "README.md"]);
2856 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
2857 push_current_branch(temp_dir.path().to_path_buf())
2858 .await
2859 .expect("initial push should succeed");
2860 fs::write(
2861 temp_dir.path().join("README.md"),
2862 "rewritten published version\n",
2863 )
2864 .expect("failed to rewrite published version");
2865 run_git_command(temp_dir.path(), &["add", "README.md"]);
2866 run_git_command(
2867 temp_dir.path(),
2868 &["commit", "--amend", "-m", "Rewrite published branch change"],
2869 );
2870
2871 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
2873 .await
2874 .expect("force-with-lease push should update rewritten history");
2875 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
2876 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
2877
2878 assert_eq!(upstream_reference, "origin/main");
2880 assert_eq!(local_head, remote_head);
2881 }
2882
2883 #[tokio::test]
2884 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
2885 let temp_dir = tempdir().expect("failed to create temp dir");
2887 let remote_dir = tempdir().expect("failed to create remote temp dir");
2888 setup_test_git_repo(temp_dir.path());
2889 run_git_command(remote_dir.path(), &["init", "--bare"]);
2890 let remote_path = remote_dir.path().to_string_lossy().to_string();
2891 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2892
2893 let upstream_reference = push_current_branch_to_remote_branch(
2895 temp_dir.path().to_path_buf(),
2896 "review/custom-branch".to_string(),
2897 )
2898 .await
2899 .expect("failed to push current branch to custom remote branch");
2900
2901 assert_eq!(upstream_reference, "origin/review/custom-branch");
2903 }
2904
2905 #[tokio::test]
2906 async fn current_upstream_reference_returns_origin_main() {
2907 let temp_dir = tempdir().expect("failed to create temp dir");
2909 let remote_dir = tempdir().expect("failed to create remote temp dir");
2910 setup_test_git_repo(temp_dir.path());
2911 run_git_command(remote_dir.path(), &["init", "--bare"]);
2912 let remote_path = remote_dir.path().to_string_lossy().to_string();
2913 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2914 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2915
2916 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
2918 .await
2919 .expect("failed to resolve upstream reference");
2920
2921 assert_eq!(upstream_reference, "origin/main");
2923 }
2924
2925 #[tokio::test]
2926 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
2927 let temp_dir = tempdir().expect("failed to create temp dir");
2929 setup_test_git_repo(temp_dir.path());
2930 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2931 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2932 .expect("failed to write session file");
2933 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2934 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2935 run_git_command(temp_dir.path(), &["checkout", "main"]);
2936 fs::write(temp_dir.path().join("main.txt"), "main change\n")
2937 .expect("failed to write main file");
2938 run_git_command(temp_dir.path(), &["add", "main.txt"]);
2939 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
2940
2941 let status = get_ref_ahead_behind(
2943 temp_dir.path().to_path_buf(),
2944 "wt/1234abcd".to_string(),
2945 "main".to_string(),
2946 )
2947 .await
2948 .expect("failed to compare branch refs");
2949
2950 assert_eq!(status, (1, 1));
2952 }
2953
2954 #[tokio::test]
2955 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
2956 let temp_dir = tempdir().expect("failed to create temp dir");
2958 let remote_dir = tempdir().expect("failed to create remote temp dir");
2959 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2960 let contributor_clone_path = contributor_dir.path().join("clone");
2961 setup_test_git_repo(temp_dir.path());
2962 run_git_command(remote_dir.path(), &["init", "--bare"]);
2963 let remote_path = remote_dir.path().to_string_lossy().to_string();
2964 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2965 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2966 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2967 run_git_command(
2968 contributor_dir.path(),
2969 &["clone", &remote_path, &contributor_clone_path_text],
2970 );
2971 run_git_command(
2972 &contributor_clone_path,
2973 &["config", "user.name", "Contributor User"],
2974 );
2975 run_git_command(
2976 &contributor_clone_path,
2977 &["config", "user.email", "contributor@example.com"],
2978 );
2979 run_git_command(
2980 &contributor_clone_path,
2981 &["checkout", "-B", "main", "origin/main"],
2982 );
2983 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2984 .expect("failed to write remote file");
2985 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2986 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2987 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2988 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2989 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2990 .expect("failed to write session file");
2991 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2992 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2993 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
2994 fs::write(
2995 temp_dir.path().join("session.txt"),
2996 "session change\nmore local\n",
2997 )
2998 .expect("failed to extend session file");
2999 run_git_command(temp_dir.path(), &["add", "session.txt"]);
3000 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
3001 run_git_command(temp_dir.path(), &["fetch"]);
3002
3003 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
3005 .await
3006 .expect("failed to read branch tracking statuses");
3007
3008 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
3010 assert_eq!(
3011 branch_tracking_statuses.get("wt/1234abcd"),
3012 Some(&Some((1, 0)))
3013 );
3014 }
3015
3016 #[tokio::test]
3017 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
3022 let temp_dir = tempdir().expect("failed to create temp dir");
3024 setup_test_git_repo(temp_dir.path());
3025 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
3026 fs::write(temp_dir.path().join("session.txt"), "session work\n")
3027 .expect("failed to write session file");
3028 run_git_command(temp_dir.path(), &["add", "session.txt"]);
3029 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
3030 fs::remove_file(temp_dir.path().join("session.txt"))
3031 .expect("failed to remove session file");
3032
3033 let result = commit_all_preserving_single_commit(
3037 temp_dir.path().to_path_buf(),
3038 "main".to_string(),
3039 "Session commit".to_string(),
3040 SingleCommitMessageStrategy::Replace,
3041 true,
3042 )
3043 .await;
3044
3045 let error = result.expect_err("amend-would-be-empty should fail");
3047 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
3048 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
3049 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
3050
3051 assert!(
3052 error.to_string().contains("Nothing to commit"),
3053 "expected 'Nothing to commit' sentinel but got: {error}"
3054 );
3055 assert_eq!(commit_count, "1");
3056 assert_eq!(head_message, "Initial commit");
3057 assert_eq!(status, "");
3058 }
3059}