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(repo_path: PathBuf, commit_message: String) -> Result<(), GitError> {
77 commit_all_with_retry(
78 repo_path,
79 commit_message,
80 SingleCommitMessageStrategy::Replace,
81 false,
82 )
83 .await
84}
85
86pub(crate) async fn commit_all_preserving_single_commit(
105 repo_path: PathBuf,
106 base_branch: String,
107 commit_message: String,
108 message_strategy: SingleCommitMessageStrategy,
109) -> Result<(), GitError> {
110 let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
111
112 commit_all_with_retry(
113 repo_path,
114 commit_message,
115 message_strategy,
116 amend_existing_commit,
117 )
118 .await
119}
120
121pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
132 spawn_blocking(move || stage_all_sync(&repo_path)).await?
133}
134
135pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
142 spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
143}
144
145pub(crate) async fn run_pre_commit_hook(repo_path: PathBuf) -> Result<(), GitError> {
155 spawn_blocking(move || {
156 pre_commit_hook_result(run_git_command_output_sync(
157 &repo_path,
158 &["hook", "run", "--ignore-missing", "pre-commit"],
159 ))
160 })
161 .await?
162}
163
164fn pre_commit_hook_result(output: Result<Output, GitError>) -> Result<(), GitError> {
166 let output = output?;
167 if output.status.success() {
168 return Ok(());
169 }
170
171 Err(GitError::CommandFailed {
172 command: "git hook run pre-commit".to_string(),
173 stderr: command_output_detail(&output.stdout, &output.stderr),
174 })
175}
176
177pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
188 let hash = run_git_command(
189 repo_path,
190 vec![
191 "rev-parse".to_string(),
192 "--short".to_string(),
193 "HEAD".to_string(),
194 ],
195 "Failed to resolve HEAD hash".to_string(),
196 )
197 .await?;
198 let hash = hash.trim().to_string();
199 if hash.is_empty() {
200 return Err(GitError::OutputParse(
201 "Failed to resolve HEAD hash: empty output".to_string(),
202 ));
203 }
204
205 Ok(hash)
206}
207
208pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
219 let hash = run_git_command(
220 repo_path,
221 vec!["rev-parse".to_string(), "HEAD".to_string()],
222 "Failed to resolve HEAD hash".to_string(),
223 )
224 .await?;
225 let hash = hash.trim().to_string();
226 if hash.is_empty() {
227 return Err(GitError::OutputParse(
228 "Failed to resolve HEAD hash: empty output".to_string(),
229 ));
230 }
231
232 Ok(hash)
233}
234
235pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
247 let hash = run_git_command(
248 repo_path,
249 vec![
250 "rev-parse".to_string(),
251 "--verify".to_string(),
252 format!("{reference}^{{commit}}"),
253 ],
254 format!("Failed to resolve `{reference}` hash"),
255 )
256 .await?;
257 let hash = hash.trim().to_string();
258 if hash.is_empty() {
259 return Err(GitError::OutputParse(format!(
260 "Failed to resolve `{reference}` hash: empty output"
261 )));
262 }
263
264 Ok(hash)
265}
266
267pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
272 spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
273}
274
275pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
290 run_git_command(
291 repo_path,
292 vec!["branch".to_string(), "-D".to_string(), branch_name],
293 "Git branch deletion failed".to_string(),
294 )
295 .await?;
296
297 Ok(())
298}
299
300pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
322 diff_output(repo_path, base_branch, false).await
323}
324
325pub(crate) async fn diff_changed_files(
328 repo_path: PathBuf,
329 base_branch: String,
330) -> Result<Vec<String>, GitError> {
331 let output = diff_output(repo_path, base_branch, true).await?;
332
333 Ok(output
334 .lines()
335 .map(str::trim)
336 .filter(|path| !path.is_empty())
337 .map(str::to_string)
338 .collect())
339}
340
341async fn diff_output(
344 repo_path: PathBuf,
345 base_branch: String,
346 name_only: bool,
347) -> Result<String, GitError> {
348 spawn_blocking(move || -> Result<String, GitError> {
349 let index_path = resolve_diff_index_path(&repo_path)?;
350 let index_path = PathBuf::from(index_path.trim());
351 let index_path = if index_path.is_absolute() {
352 index_path
353 } else {
354 repo_path.join(index_path)
355 };
356
357 diff_output_after_index_resolution(&repo_path, &base_branch, name_only, &index_path)
358 })
359 .await?
360}
361
362fn diff_output_after_index_resolution(
364 repo_path: &Path,
365 base_branch: &str,
366 name_only: bool,
367 index_path: &Path,
368) -> Result<String, GitError> {
369 let result = (|| -> Result<String, GitError> {
370 let temporary_index = copy_git_index_to_temp(index_path)?;
371
372 run_git_command_with_index_sync(
373 repo_path,
374 &["add", "-A", "--intent-to-add"],
375 &temporary_index,
376 "Git add --intent-to-add failed",
377 )?;
378
379 let merge_base_output =
380 run_git_command_output_sync(repo_path, &["merge-base", "HEAD", base_branch])?;
381
382 let diff_target = if merge_base_output.status.success() {
383 resolve_diff_target(
384 repo_path,
385 base_branch,
386 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
387 )?
388 } else {
389 base_branch.to_string()
390 };
391
392 let args = if name_only {
393 vec!["diff", "--name-only", diff_target.as_str()]
394 } else {
395 vec!["diff", diff_target.as_str()]
396 };
397
398 run_git_command_with_index_sync(repo_path, &args, &temporary_index, "Git diff failed")
399 })();
400
401 result.map_err(|error| classify_diff_repository_error(repo_path, error))
402}
403
404fn resolve_diff_index_path(repo_path: &Path) -> Result<String, GitError> {
406 run_git_command_sync(
407 repo_path,
408 &["rev-parse", "--git-path", "index"],
409 "Git index path resolution failed",
410 )
411 .map_err(|error| classify_diff_repository_error(repo_path, error))
412}
413
414fn classify_diff_repository_error(repo_path: &Path, error: GitError) -> GitError {
416 if !diff_repository_is_unavailable(repo_path) {
417 return error;
418 }
419
420 GitError::RepositoryUnavailable {
421 detail: error.to_string(),
422 }
423}
424
425fn diff_repository_is_unavailable(repo_path: &Path) -> bool {
427 if !repo_path.is_dir() {
428 return true;
429 }
430
431 diff_repository_probe_is_unavailable(run_git_command_output_sync(
432 repo_path,
433 &["rev-parse", "--git-dir"],
434 ))
435}
436
437fn diff_repository_probe_is_unavailable(probe: Result<Output, GitError>) -> bool {
439 match probe {
440 Ok(output) => !output.status.success(),
441 Err(_) => false,
442 }
443}
444
445pub(crate) async fn read_worktree_file(
454 repo_path: PathBuf,
455 relative_path: String,
456) -> Result<WorktreeFileContent, GitError> {
457 spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
458}
459
460fn read_worktree_file_sync(
462 repo_path: &Path,
463 relative_path: &str,
464) -> Result<WorktreeFileContent, GitError> {
465 let relative_file_path = Path::new(relative_path);
466 if relative_path.is_empty()
467 || relative_file_path
468 .components()
469 .any(|component| !matches!(component, Component::Normal(_)))
470 {
471 return Err(GitError::OutputParse(format!(
472 "Unsafe worktree file path: {relative_path}"
473 )));
474 }
475
476 let canonical_repo_path = std::fs::canonicalize(repo_path)?;
477 let candidate_path = repo_path.join(relative_file_path);
478 let canonical_file_path = match std::fs::canonicalize(candidate_path) {
479 Ok(path) => path,
480 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
481 return Ok(WorktreeFileContent::Missing);
482 }
483 Err(error) => return Err(error.into()),
484 };
485 if !canonical_file_path.starts_with(canonical_repo_path) {
486 return Err(GitError::OutputParse(format!(
487 "Worktree file resolves outside repository: {relative_path}"
488 )));
489 }
490
491 let file = std::fs::File::open(canonical_file_path)?;
492 let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
493 file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
494 .read_to_end(&mut bytes)?;
495
496 Ok(worktree_file_content(bytes))
497}
498
499fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
501 if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
502 return WorktreeFileContent::TooLarge;
503 }
504
505 match String::from_utf8(bytes) {
506 Ok(content) => WorktreeFileContent::Text(content),
507 Err(_) => WorktreeFileContent::Binary,
508 }
509}
510
511fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
514 let index_parent = index_path.parent().ok_or_else(|| {
515 GitError::OutputParse(format!(
516 "Git index path has no parent: {}",
517 index_path.display()
518 ))
519 })?;
520 let temporary_index =
521 tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
522 command: "create temporary git index".to_string(),
523 stderr: error.to_string(),
524 })?;
525 std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
526 command: "copy git index".to_string(),
527 stderr: error.to_string(),
528 })?;
529
530 Ok(temporary_index.into_temp_path())
531}
532
533fn run_git_command_with_index_sync(
536 repo_path: &Path,
537 args: &[&str],
538 index_path: &Path,
539 error_context: &str,
540) -> Result<String, GitError> {
541 let output = run_git_command_output_with_env_sync(
542 repo_path,
543 args,
544 &[("GIT_INDEX_FILE", index_path.as_os_str())],
545 )?;
546 if !output.status.success() {
547 return Err(GitError::CommandFailed {
548 command: format!("git {}", args.join(" ")),
549 stderr: format!(
550 "{error_context}: {}",
551 command_output_detail(&output.stdout, &output.stderr)
552 ),
553 });
554 }
555
556 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
557}
558
559pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
570 let status_output = worktree_status(repo_path).await?;
571
572 Ok(status_output.trim().is_empty())
573}
574
575pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
589 run_git_command(
590 repo_path,
591 vec![
592 "status".to_string(),
593 "--porcelain=v1".to_string(),
594 "--untracked-files=all".to_string(),
595 ],
596 "Git status --porcelain=v1 failed".to_string(),
597 )
598 .await
599}
600
601pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
616 run_git_command(
617 repo_path,
618 vec![
619 "status".to_string(),
620 "--porcelain=v1".to_string(),
621 "--untracked-files=no".to_string(),
622 ],
623 "Git tracked status --porcelain=v1 failed".to_string(),
624 )
625 .await
626}
627
628pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
644 let command_runner = ProcessAsyncGitCommandRunner;
645
646 pull_rebase_with_runner(repo_path, &command_runner, GIT_INDEX_LOCK_RETRY_DELAY).await
647}
648
649async fn pull_rebase_with_runner(
651 repo_path: PathBuf,
652 command_runner: &dyn AsyncGitCommandRunner,
653 retry_delay: Duration,
654) -> Result<PullRebaseResult, GitError> {
655 let pull_arguments = pull_rebase_arguments(&repo_path, command_runner).await?;
656 let command = AsyncGitCommand::new(repo_path, pull_arguments).with_environment(vec![
657 ("GIT_EDITOR".to_string(), ":".to_string()),
658 ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
659 ]);
660 let output =
661 run_async_git_command_with_index_lock_retry(command, command_runner, retry_delay).await?;
662
663 if output.success() {
664 return Ok(PullRebaseResult::Completed);
665 }
666
667 let detail = command_output_detail(&output.stdout, &output.stderr);
668 if is_rebase_conflict(&detail) {
669 return Ok(PullRebaseResult::Conflict { detail });
670 }
671
672 Err(GitError::CommandFailed {
673 command: "git pull --rebase".to_string(),
674 stderr: detail,
675 })
676}
677
678async fn pull_rebase_arguments(
683 repo_path: &Path,
684 command_runner: &dyn AsyncGitCommandRunner,
685) -> Result<Vec<String>, GitError> {
686 let upstream_reference = primary_upstream_reference(repo_path, command_runner).await?;
687
688 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
689 return Ok(vec![
690 "pull".to_string(),
691 "--rebase".to_string(),
692 remote_name.to_string(),
693 branch_name.to_string(),
694 ]);
695 }
696
697 let remote_name = current_branch_remote_name(repo_path, command_runner)
698 .await?
699 .ok_or_else(|| {
700 GitError::OutputParse(
701 "Failed to resolve current branch remote: not configured".to_string(),
702 )
703 })?;
704
705 Ok(vec![
706 "pull".to_string(),
707 "--rebase".to_string(),
708 remote_name,
709 upstream_reference,
710 ])
711}
712
713async fn primary_upstream_reference(
719 repo_path: &Path,
720 command_runner: &dyn AsyncGitCommandRunner,
721) -> Result<String, GitError> {
722 let upstream_reference = upstream_reference_name(repo_path, command_runner).await?;
723 let Some(primary_reference) = upstream_reference
724 .lines()
725 .map(str::trim)
726 .find(|line| !line.is_empty())
727 else {
728 return Err(GitError::OutputParse(
729 "Failed to resolve upstream branch: empty output".to_string(),
730 ));
731 };
732
733 Ok(primary_reference.to_string())
734}
735
736async fn upstream_reference_name(
738 repo_path: &Path,
739 command_runner: &dyn AsyncGitCommandRunner,
740) -> Result<String, GitError> {
741 let upstream_reference = run_git_command_with_runner(
742 AsyncGitCommand::new(
743 repo_path.to_path_buf(),
744 vec![
745 "rev-parse".to_string(),
746 "--abbrev-ref".to_string(),
747 "--symbolic-full-name".to_string(),
748 "@{u}".to_string(),
749 ],
750 ),
751 "Failed to resolve upstream branch",
752 command_runner,
753 )
754 .await?;
755 let upstream_reference = upstream_reference.trim().to_string();
756 if upstream_reference.is_empty() {
757 return Err(GitError::OutputParse(
758 "Failed to resolve upstream branch: empty output".to_string(),
759 ));
760 }
761
762 Ok(upstream_reference)
763}
764
765async fn current_branch_remote_name(
770 repo_path: &Path,
771 command_runner: &dyn AsyncGitCommandRunner,
772) -> Result<Option<String>, GitError> {
773 let current_branch_name = current_branch_name(repo_path, command_runner).await?;
774 let remote_config_key = format!("branch.{current_branch_name}.remote");
775 let output = command_runner
776 .run(AsyncGitCommand::new(
777 repo_path.to_path_buf(),
778 vec![
779 "config".to_string(),
780 "--get".to_string(),
781 remote_config_key.clone(),
782 ],
783 ))
784 .await?;
785
786 parse_current_branch_remote_output(&output, &remote_config_key)
787}
788
789fn parse_current_branch_remote_output(
791 output: &AsyncGitCommandOutput,
792 remote_config_key: &str,
793) -> Result<Option<String>, GitError> {
794 if output.exit_code == Some(1) {
795 return Ok(None);
796 }
797 if !output.success() {
798 let detail = command_output_detail(&output.stdout, &output.stderr);
799
800 return Err(GitError::CommandFailed {
801 command: format!("git config --get {remote_config_key}"),
802 stderr: format!(
803 "Failed to resolve current branch remote `{remote_config_key}`: {detail}"
804 ),
805 });
806 }
807
808 let remote_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
809 if remote_name.is_empty() {
810 return Err(GitError::OutputParse(format!(
811 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
812 )));
813 }
814
815 Ok(Some(remote_name))
816}
817
818async fn current_branch_name(
820 repo_path: &Path,
821 command_runner: &dyn AsyncGitCommandRunner,
822) -> Result<String, GitError> {
823 let branch_name = run_git_command_with_runner(
824 AsyncGitCommand::new(
825 repo_path.to_path_buf(),
826 vec![
827 "rev-parse".to_string(),
828 "--abbrev-ref".to_string(),
829 "HEAD".to_string(),
830 ],
831 ),
832 "Failed to resolve current branch name",
833 command_runner,
834 )
835 .await?;
836 let branch_name = branch_name.trim().to_string();
837 if branch_name.is_empty() {
838 return Err(GitError::OutputParse(
839 "Failed to resolve current branch name: empty output".to_string(),
840 ));
841 }
842
843 if branch_name == "HEAD" {
844 return Err(GitError::OutputParse(
845 "Failed to resolve current branch name: detached HEAD".to_string(),
846 ));
847 }
848
849 Ok(branch_name)
850}
851
852async fn run_async_git_command_with_index_lock_retry(
855 command: AsyncGitCommand,
856 command_runner: &dyn AsyncGitCommandRunner,
857 retry_delay: Duration,
858) -> Result<AsyncGitCommandOutput, GitError> {
859 let mut attempt_count = 0;
860 loop {
861 attempt_count += 1;
862 let output = command_runner.run(command.clone()).await?;
863 if output.success() {
864 return Ok(output);
865 }
866
867 let detail = command_output_detail(&output.stdout, &output.stderr);
868 let is_last_attempt = attempt_count == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
869 if !is_git_index_lock_error(&detail) || is_last_attempt {
870 return Ok(output);
871 }
872
873 time::sleep(retry_delay).await;
874 }
875}
876
877pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
894 let command_runner = ProcessAsyncGitCommandRunner;
895
896 push_current_branch_with_runner(repo_path, &command_runner).await
897}
898
899async fn push_current_branch_with_runner(
902 repo_path: PathBuf,
903 command_runner: &dyn AsyncGitCommandRunner,
904) -> Result<String, GitError> {
905 let push_command = AsyncGitCommand::new(
906 repo_path.clone(),
907 vec!["push".to_string(), "--force-with-lease".to_string()],
908 );
909 let push_output = command_runner.run(push_command).await?;
910
911 if push_output.success() {
912 return primary_upstream_reference(&repo_path, command_runner).await;
913 }
914
915 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
916 if !is_no_upstream_error(&push_detail) {
917 return Err(GitError::CommandFailed {
918 command: "git push --force-with-lease".to_string(),
919 stderr: push_detail,
920 });
921 }
922
923 let remote_name = current_branch_remote_name(&repo_path, command_runner)
924 .await?
925 .unwrap_or_else(|| "origin".to_string());
926 run_git_command_with_runner(
927 AsyncGitCommand::new(
928 repo_path.clone(),
929 vec![
930 "push".to_string(),
931 "--force-with-lease".to_string(),
932 "--set-upstream".to_string(),
933 remote_name,
934 "HEAD".to_string(),
935 ],
936 ),
937 "Git push failed",
938 command_runner,
939 )
940 .await?;
941
942 primary_upstream_reference(&repo_path, command_runner).await
943}
944
945pub(crate) async fn push_current_branch_to_remote_branch(
962 repo_path: PathBuf,
963 remote_branch_name: String,
964) -> Result<String, GitError> {
965 let command_runner = ProcessAsyncGitCommandRunner;
966
967 push_current_branch_to_remote_branch_with_runner(repo_path, remote_branch_name, &command_runner)
968 .await
969}
970
971pub(crate) async fn push_current_branch_to_new_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_new_remote_branch_with_runner(
995 repo_path,
996 remote_branch_name,
997 &command_runner,
998 )
999 .await
1000}
1001
1002pub(crate) async fn remote_branch_exists(
1015 repo_path: PathBuf,
1016 remote_branch_name: String,
1017) -> Result<bool, GitError> {
1018 let command_runner = ProcessAsyncGitCommandRunner;
1019
1020 remote_branch_exists_with_runner(repo_path, remote_branch_name, &command_runner).await
1021}
1022
1023async fn push_current_branch_to_remote_branch_with_runner(
1026 repo_path: PathBuf,
1027 remote_branch_name: String,
1028 command_runner: &dyn AsyncGitCommandRunner,
1029) -> Result<String, GitError> {
1030 let remote_name = current_branch_remote_name(&repo_path, command_runner)
1031 .await?
1032 .unwrap_or_else(|| "origin".to_string());
1033 let push_refspec = format!("HEAD:{remote_branch_name}");
1034 let arguments = vec![
1035 "push".to_string(),
1036 "--force-with-lease".to_string(),
1037 "--set-upstream".to_string(),
1038 remote_name.clone(),
1039 push_refspec,
1040 ];
1041 run_git_command_with_runner(
1042 AsyncGitCommand::new(repo_path, arguments),
1043 "Git push failed",
1044 command_runner,
1045 )
1046 .await?;
1047
1048 Ok(format!("{remote_name}/{remote_branch_name}"))
1049}
1050
1051async fn push_current_branch_to_new_remote_branch_with_runner(
1053 repo_path: PathBuf,
1054 remote_branch_name: String,
1055 command_runner: &dyn AsyncGitCommandRunner,
1056) -> Result<String, GitError> {
1057 let remote_name = current_branch_remote_name(&repo_path, command_runner)
1058 .await?
1059 .unwrap_or_else(|| "origin".to_string());
1060 let remote_ref = format!("refs/heads/{remote_branch_name}");
1061 let lease_argument = format!("--force-with-lease={remote_ref}:");
1062 let push_refspec = format!("HEAD:{remote_branch_name}");
1063 let arguments = vec![
1064 "push".to_string(),
1065 lease_argument,
1066 "--set-upstream".to_string(),
1067 remote_name.clone(),
1068 push_refspec,
1069 ];
1070 run_git_command_with_runner(
1071 AsyncGitCommand::new(repo_path, arguments),
1072 "Git push failed",
1073 command_runner,
1074 )
1075 .await?;
1076
1077 Ok(format!("{remote_name}/{remote_branch_name}"))
1078}
1079
1080async fn remote_branch_exists_with_runner(
1082 repo_path: PathBuf,
1083 remote_branch_name: String,
1084 command_runner: &dyn AsyncGitCommandRunner,
1085) -> Result<bool, GitError> {
1086 let remote_name = current_branch_remote_name(&repo_path, command_runner)
1087 .await?
1088 .unwrap_or_else(|| "origin".to_string());
1089 let arguments = vec![
1090 "ls-remote".to_string(),
1091 "--heads".to_string(),
1092 remote_name,
1093 remote_branch_name,
1094 ];
1095 let stdout = run_git_command_with_runner(
1096 AsyncGitCommand::new(repo_path, arguments),
1097 "Git ls-remote failed",
1098 command_runner,
1099 )
1100 .await?;
1101
1102 Ok(!stdout.trim().is_empty())
1103}
1104
1105pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
1117 let command_runner = ProcessAsyncGitCommandRunner;
1118
1119 primary_upstream_reference(&repo_path, &command_runner).await
1120}
1121
1122pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
1133 run_git_command(
1134 repo_path,
1135 vec!["fetch".to_string()],
1136 "Git fetch failed".to_string(),
1137 )
1138 .await?;
1139
1140 Ok(())
1141}
1142
1143pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
1155 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
1156}
1157
1158pub(crate) async fn get_ref_ahead_behind(
1168 repo_path: PathBuf,
1169 left_ref: String,
1170 right_ref: String,
1171) -> Result<(u32, u32), GitError> {
1172 let rev_list_output = run_git_command(
1173 repo_path,
1174 vec![
1175 "rev-list".to_string(),
1176 "--left-right".to_string(),
1177 "--count".to_string(),
1178 format!("{left_ref}...{right_ref}"),
1179 ],
1180 "Git rev-list failed".to_string(),
1181 )
1182 .await?;
1183
1184 parse_ahead_behind_counts(&rev_list_output)
1185}
1186
1187fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
1190 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
1191 if parts.len() >= 2 {
1192 let ahead = parts[0].parse().unwrap_or(0);
1193 let behind = parts[1].parse().unwrap_or(0);
1194
1195 return Ok((ahead, behind));
1196 }
1197
1198 Err(GitError::OutputParse(
1199 "Unexpected output format from git rev-list".to_string(),
1200 ))
1201}
1202
1203pub(crate) async fn branch_tracking_statuses(
1212 repo_path: PathBuf,
1213) -> Result<BranchTrackingMap, GitError> {
1214 let git_output = run_git_command(
1215 repo_path,
1216 vec![
1217 "for-each-ref".to_string(),
1218 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
1219 "refs/heads".to_string(),
1220 ],
1221 "Git for-each-ref failed".to_string(),
1222 )
1223 .await?;
1224
1225 Ok(parse_branch_tracking_statuses(&git_output))
1226}
1227
1228pub(crate) async fn list_upstream_commit_titles(
1239 repo_path: PathBuf,
1240) -> Result<Vec<String>, GitError> {
1241 let git_output = run_git_command(
1242 repo_path,
1243 vec![
1244 "log".to_string(),
1245 "--reverse".to_string(),
1246 "--pretty=%s".to_string(),
1247 "HEAD..@{u}".to_string(),
1248 ],
1249 "Git log failed".to_string(),
1250 )
1251 .await?;
1252
1253 Ok(parse_commit_titles(&git_output))
1254}
1255
1256pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
1267 let git_output = run_git_command(
1268 repo_path,
1269 vec![
1270 "log".to_string(),
1271 "--reverse".to_string(),
1272 "--pretty=%s".to_string(),
1273 "@{u}..HEAD".to_string(),
1274 ],
1275 "Git log failed".to_string(),
1276 )
1277 .await?;
1278
1279 Ok(parse_commit_titles(&git_output))
1280}
1281
1282pub(crate) async fn has_commits_since(
1288 repo_path: PathBuf,
1289 base_branch: String,
1290) -> Result<bool, GitError> {
1291 spawn_blocking(move || -> Result<bool, GitError> {
1292 let rev_list_output = run_git_command_sync(
1293 &repo_path,
1294 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
1295 "Failed to count commits since base branch",
1296 )?;
1297 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
1298 GitError::OutputParse(format!(
1299 "Failed to parse commit count since base branch `{base_branch}`: {error}"
1300 ))
1301 })?;
1302
1303 Ok(commit_count > 0)
1304 })
1305 .await?
1306}
1307
1308fn parse_commit_titles(output: &str) -> Vec<String> {
1310 output
1311 .lines()
1312 .map(str::trim)
1313 .filter(|title| !title.is_empty())
1314 .map(ToString::to_string)
1315 .collect()
1316}
1317
1318fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1320 let mut branch_tracking_statuses = HashMap::new();
1321
1322 for line in output
1323 .lines()
1324 .map(str::trim)
1325 .filter(|line| !line.is_empty())
1326 {
1327 let mut parts = line.splitn(3, '\t');
1328 let Some(branch_name) = parts
1329 .next()
1330 .map(str::trim)
1331 .filter(|value| !value.is_empty())
1332 else {
1333 continue;
1334 };
1335 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1336 let track = parts.next().map(str::trim).unwrap_or_default();
1337
1338 let status = if upstream_ref.is_empty() {
1339 None
1340 } else {
1341 parse_branch_tracking_counts(track)
1342 };
1343 branch_tracking_statuses.insert(branch_name.to_string(), status);
1344 }
1345
1346 branch_tracking_statuses
1347}
1348
1349fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1351 let normalized_track = track.trim();
1352 if normalized_track.is_empty() || normalized_track == "gone" {
1353 return None;
1354 }
1355
1356 let mut ahead = 0;
1357 let mut behind = 0;
1358
1359 for part in normalized_track.split(',').map(str::trim) {
1360 if let Some(count) = part.strip_prefix("ahead ") {
1361 ahead = count.parse().ok()?;
1362 } else if let Some(count) = part.strip_prefix("behind ") {
1363 behind = count.parse().ok()?;
1364 }
1365 }
1366
1367 Some((ahead, behind))
1368}
1369
1370fn resolve_diff_target(
1376 repo_path: &Path,
1377 base_branch: &str,
1378 merge_base: &str,
1379) -> Result<String, GitError> {
1380 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1381 if !cherry_output.status.success() {
1382 return Ok(merge_base.to_string());
1383 }
1384
1385 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1386 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1387 return Ok(merge_base.to_string());
1388 };
1389
1390 Ok(last_leading_applied_commit.to_string())
1391}
1392
1393fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1400 let mut last_applied_commit = None;
1401
1402 for line in cherry_output.lines() {
1403 let trimmed_line = line.trim();
1404 if trimmed_line.is_empty() {
1405 continue;
1406 }
1407
1408 let mut parts = trimmed_line.split_whitespace();
1409 let marker = parts.next()?;
1410 let commit_hash = parts.next()?;
1411
1412 if marker == "-" {
1413 last_applied_commit = Some(commit_hash);
1414
1415 continue;
1416 }
1417
1418 if marker == "+" {
1419 break;
1420 }
1421
1422 break;
1423 }
1424
1425 last_applied_commit
1426}
1427
1428async fn commit_all_with_retry(
1436 repo_path: PathBuf,
1437 commit_message: String,
1438 message_strategy: SingleCommitMessageStrategy,
1439 amend_existing_commit: bool,
1440) -> Result<(), GitError> {
1441 spawn_blocking(move || {
1442 stage_all_sync(&repo_path)?;
1443
1444 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1445 let output = run_commit_command(
1446 &repo_path,
1447 &commit_message,
1448 message_strategy,
1449 amend_existing_commit,
1450 )?;
1451
1452 if output.status.success() {
1453 return Ok(());
1454 }
1455
1456 let stderr = String::from_utf8_lossy(&output.stderr);
1457 let stdout = String::from_utf8_lossy(&output.stdout);
1458 if is_nothing_to_commit_output(&stdout, &stderr) {
1459 return Err(nothing_to_commit_error());
1460 }
1461
1462 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1463 reset_empty_amend_sync(&repo_path)?;
1464
1465 return Err(nothing_to_commit_error());
1466 }
1467
1468 if is_hook_modified_error(&stdout, &stderr) {
1469 stage_all_sync(&repo_path)?;
1470
1471 continue;
1472 }
1473
1474 let detail = command_output_detail(&output.stdout, &output.stderr);
1475
1476 return Err(GitError::CommandFailed {
1477 command: "git commit".to_string(),
1478 stderr: detail,
1479 });
1480 }
1481
1482 Err(GitError::CommandFailed {
1483 command: "git commit".to_string(),
1484 stderr: format!(
1485 "Failed to commit: commit hooks kept modifying files after \
1486 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1487 ),
1488 })
1489 })
1490 .await?
1491}
1492
1493fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1496 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1497 .iter()
1498 .find(|config_file| repo_path.join(config_file).is_file())
1499 else {
1500 return Ok(());
1501 };
1502 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1503
1504 if is_executable_hook(&hook_path) {
1505 return Ok(());
1506 }
1507
1508 Err(GitError::PreCommitHookMissing {
1509 config_file: (*config_file).to_string(),
1510 })
1511}
1512
1513fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1515 let hooks_path_output =
1516 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1517 let hooks_path = if hooks_path_output.status.success() {
1518 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1519 } else if hooks_path_output.status.code() == Some(1) {
1520 let default_hook_path = run_git_command_sync(
1521 repo_path,
1522 &["rev-parse", "--git-path", "hooks/pre-commit"],
1523 "Failed to resolve Git pre-commit hook path",
1524 )?;
1525
1526 return Ok(resolve_repo_path(
1527 repo_path,
1528 PathBuf::from(default_hook_path.trim()),
1529 ));
1530 } else {
1531 return Err(GitError::CommandFailed {
1532 command: "git config --path --get core.hooksPath".to_string(),
1533 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1534 });
1535 };
1536
1537 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1538}
1539
1540fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1541 if path.is_absolute() {
1542 return path;
1543 }
1544
1545 repo_path.join(path)
1546}
1547
1548#[cfg(unix)]
1549fn is_executable_hook(hook_path: &Path) -> bool {
1550 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1551}
1552
1553#[cfg(not(unix))]
1554fn is_executable_hook(hook_path: &Path) -> bool {
1555 hook_path.is_file()
1556}
1557
1558fn nothing_to_commit_error() -> GitError {
1560 GitError::CommandFailed {
1561 command: "git commit".to_string(),
1562 stderr: "Nothing to commit: no changes detected".to_string(),
1563 }
1564}
1565
1566fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1569 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1570
1571 combined.contains("nothing to commit")
1572}
1573
1574fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1577 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1578 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1579
1580 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1581}
1582
1583fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1586 run_git_command_sync(
1587 repo_path,
1588 &["reset", "HEAD^"],
1589 "Git reset after empty amend failed",
1590 )?;
1591
1592 Ok(())
1593}
1594
1595fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1599 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1600
1601 if !output.status.success() {
1602 let detail = command_output_detail(&output.stdout, &output.stderr);
1603
1604 return Err(GitError::CommandFailed {
1605 command: "git add -A".to_string(),
1606 stderr: format!("Failed to stage changes: {detail}"),
1607 });
1608 }
1609
1610 Ok(())
1611}
1612
1613fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1615 if !has_head_commit_sync(repo_path)? {
1616 return Ok(None);
1617 }
1618
1619 let output = run_git_command_sync(
1620 repo_path,
1621 &["log", "-1", "--pretty=%B"],
1622 "Failed to read HEAD commit message",
1623 )?;
1624
1625 Ok(Some(output.trim().to_string()))
1626}
1627
1628fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1630 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1631
1632 if output.status.success() {
1633 return Ok(true);
1634 }
1635
1636 let detail = command_output_detail(&output.stdout, &output.stderr);
1637 let normalized_detail = detail.to_ascii_lowercase();
1638 if normalized_detail.contains("needed a single revision")
1639 || normalized_detail.contains("unknown revision")
1640 || normalized_detail.contains("does not have any commits yet")
1641 {
1642 return Ok(false);
1643 }
1644
1645 Err(GitError::CommandFailed {
1646 command: "git rev-parse --verify HEAD".to_string(),
1647 stderr: detail,
1648 })
1649}
1650
1651fn run_commit_command(
1655 repo_path: &Path,
1656 commit_message: &str,
1657 message_strategy: SingleCommitMessageStrategy,
1658 amend_existing_commit: bool,
1659) -> Result<Output, GitError> {
1660 let mut args = vec!["commit"];
1661 if amend_existing_commit {
1662 args.push("--amend");
1663 match message_strategy {
1664 SingleCommitMessageStrategy::Replace => {
1665 args.push("-m");
1666 args.push(commit_message);
1667 }
1668 SingleCommitMessageStrategy::Reuse => {
1669 args.push("--no-edit");
1670 }
1671 }
1672 } else {
1673 args.push("-m");
1674 args.push(commit_message);
1675 }
1676
1677 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1678}
1679
1680fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1682 let combined = format!(
1683 "{stdout}
1684{stderr}"
1685 )
1686 .to_ascii_lowercase();
1687
1688 combined.contains("files were modified by this hook")
1689}
1690
1691pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1693 let normalized_detail = detail.to_ascii_lowercase();
1694
1695 normalized_detail.contains("has no upstream branch")
1696 || normalized_detail.contains("no upstream branch")
1697 || normalized_detail.contains("set-upstream")
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702 use std::fs;
1703 #[cfg(unix)]
1704 use std::os::unix::fs::PermissionsExt;
1705 use std::path::Path;
1706 use std::process::{Command, Output};
1707
1708 use mockall::Sequence;
1709 use mockall::predicate::function;
1710 use tempfile::tempdir;
1711
1712 use super::*;
1713 use crate::repo::MockAsyncGitCommandRunner;
1714
1715 fn async_git_output(
1717 exit_code: i32,
1718 stdout: impl Into<Vec<u8>>,
1719 stderr: impl Into<Vec<u8>>,
1720 ) -> AsyncGitCommandOutput {
1721 AsyncGitCommandOutput {
1722 exit_code: Some(exit_code),
1723 stderr: stderr.into(),
1724 stdout: stdout.into(),
1725 }
1726 }
1727
1728 fn run_git_command(repo_path: &Path, args: &[&str]) {
1730 let output = git_command_output(repo_path, args);
1731
1732 assert!(
1733 output.status.success(),
1734 "git command {:?} failed: {}",
1735 args,
1736 String::from_utf8_lossy(&output.stderr)
1737 );
1738 }
1739
1740 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1742 Command::new("git")
1743 .args(args)
1744 .current_dir(repo_path)
1745 .output()
1746 .expect("failed to run git command")
1747 }
1748
1749 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1751 let output = git_command_output(repo_path, args);
1752
1753 assert!(
1754 output.status.success(),
1755 "git command {:?} failed: {}",
1756 args,
1757 String::from_utf8_lossy(&output.stderr)
1758 );
1759
1760 String::from_utf8(output.stdout)
1761 .expect("git stdout should be valid utf-8")
1762 .trim()
1763 .to_string()
1764 }
1765
1766 fn setup_test_git_repo(repo_path: &Path) {
1768 run_git_command(repo_path, &["init", "-b", "main"]);
1769 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1770 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1771 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1772 run_git_command(repo_path, &["add", "README.md"]);
1773 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1774 }
1775
1776 #[tokio::test]
1777 async fn delete_branch_removes_branch_from_isolated_repository() {
1778 let temp_dir = tempdir().expect("failed to create temp dir");
1780 setup_test_git_repo(temp_dir.path());
1781 run_git_command(temp_dir.path(), &["branch", "review/topic"]);
1782
1783 delete_branch(temp_dir.path().to_path_buf(), "review/topic".to_string())
1785 .await
1786 .expect("branch deletion should succeed");
1787
1788 let branch_lookup = git_command_output(
1790 temp_dir.path(),
1791 &["show-ref", "--verify", "--quiet", "refs/heads/review/topic"],
1792 );
1793 assert!(!branch_lookup.status.success());
1794 }
1795
1796 #[tokio::test]
1797 async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1798 let temp_dir = tempdir().expect("failed to create temp dir");
1800 setup_test_git_repo(temp_dir.path());
1801 fs::write(temp_dir.path().join("README.md"), "staged change\n")
1802 .expect("failed to write staged change");
1803 run_git_command(temp_dir.path(), &["add", "README.md"]);
1804 fs::write(
1805 temp_dir.path().join("README.md"),
1806 "staged change\nunstaged change\n",
1807 )
1808 .expect("failed to write unstaged change");
1809 fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1810 .expect("failed to write untracked file");
1811 let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1812 let status_before = git_command_output(
1813 temp_dir.path(),
1814 &["status", "--porcelain=v1", "--untracked-files=all"],
1815 )
1816 .stdout;
1817
1818 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1820 let changed_files =
1821 diff_changed_files(temp_dir.path().to_path_buf(), "main".to_string()).await;
1822
1823 let diff_output = result.expect("diff should succeed");
1825 let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1826 let status_after = git_command_output(
1827 temp_dir.path(),
1828 &["status", "--porcelain=v1", "--untracked-files=all"],
1829 )
1830 .stdout;
1831 assert!(diff_output.contains("staged change"));
1832 assert!(diff_output.contains("unstaged change"));
1833 assert!(diff_output.contains("untracked change"));
1834 assert_eq!(
1835 changed_files.expect("changed files should load"),
1836 vec!["README.md".to_string(), "new.txt".to_string()]
1837 );
1838 assert_eq!(cached_diff_after, cached_diff_before);
1839 assert_eq!(status_after, status_before);
1840 }
1841
1842 #[tokio::test]
1843 async fn diff_reports_repository_unavailable_outside_git_repository() {
1844 let temp_dir = tempdir().expect("failed to create temp dir");
1846
1847 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1849
1850 assert!(matches!(
1852 result,
1853 Err(GitError::RepositoryUnavailable { detail })
1854 if detail.to_ascii_lowercase().contains("not a git repository")
1855 ));
1856 }
1857
1858 #[test]
1859 fn diff_reports_repository_unavailable_when_removed_after_index_resolution() {
1860 let temp_dir = tempdir().expect("failed to create temp dir");
1862 let preserved_index_dir = tempdir().expect("failed to create preserved index dir");
1863 setup_test_git_repo(temp_dir.path());
1864 let index_path =
1865 resolve_diff_index_path(temp_dir.path()).expect("index path should resolve");
1866 let index_path = PathBuf::from(index_path.trim());
1867 let index_path = temp_dir.path().join(index_path);
1868 let preserved_index_path = preserved_index_dir.path().join("index");
1869 fs::copy(index_path, &preserved_index_path).expect("index copy should succeed");
1870 fs::remove_dir_all(temp_dir.path()).expect("worktree removal should succeed");
1871
1872 let result = diff_output_after_index_resolution(
1874 temp_dir.path(),
1875 "main",
1876 false,
1877 &preserved_index_path,
1878 );
1879
1880 assert!(matches!(
1882 result,
1883 Err(GitError::RepositoryUnavailable { detail })
1884 if detail.contains("git add -A --intent-to-add")
1885 ));
1886 }
1887
1888 #[tokio::test]
1889 async fn diff_preserves_invalid_base_reference_error() {
1890 let temp_dir = tempdir().expect("failed to create temp dir");
1892 setup_test_git_repo(temp_dir.path());
1893
1894 let result = diff(
1896 temp_dir.path().to_path_buf(),
1897 "missing-base-reference".to_string(),
1898 )
1899 .await;
1900
1901 assert!(matches!(
1903 result,
1904 Err(GitError::CommandFailed { command, stderr })
1905 if command == "git diff missing-base-reference"
1906 && stderr.contains("Git diff failed")
1907 ));
1908 }
1909
1910 #[test]
1911 fn diff_repository_error_classification_preserves_unrelated_failures() {
1912 let temp_dir = tempdir().expect("failed to create temp dir");
1914 setup_test_git_repo(temp_dir.path());
1915 let error = GitError::CommandFailed {
1916 command: "git rev-parse --git-path index".to_string(),
1917 stderr: "fatal: ambiguous argument".to_string(),
1918 };
1919
1920 let classified = classify_diff_repository_error(temp_dir.path(), error);
1922
1923 assert!(matches!(
1925 classified,
1926 GitError::CommandFailed { command, stderr }
1927 if command == "git rev-parse --git-path index"
1928 && stderr == "fatal: ambiguous argument"
1929 ));
1930 }
1931
1932 #[test]
1933 fn diff_repository_error_classification_ignores_localized_diagnostic() {
1934 let temp_dir = tempdir().expect("failed to create temp dir");
1936 let error = GitError::CommandFailed {
1937 command: "git rev-parse --git-path index".to_string(),
1938 stderr: "fatal: kein Git-Repository".to_string(),
1939 };
1940
1941 let classified = classify_diff_repository_error(temp_dir.path(), error);
1943
1944 assert!(matches!(
1946 classified,
1947 GitError::RepositoryUnavailable { detail }
1948 if detail == "git rev-parse --git-path index: fatal: kein Git-Repository"
1949 ));
1950 }
1951
1952 #[test]
1953 fn diff_repository_probe_preserves_spawn_failure() {
1954 let probe = Err(GitError::CommandFailed {
1956 command: "git rev-parse --git-dir".to_string(),
1957 stderr: "git executable unavailable".to_string(),
1958 });
1959
1960 let unavailable = diff_repository_probe_is_unavailable(probe);
1962
1963 assert!(!unavailable);
1965 }
1966
1967 #[test]
1968 fn diff_repository_error_classification_types_missing_directory() {
1969 let temp_dir = tempdir().expect("failed to create temp dir");
1971 let missing_path = temp_dir.path().join("removed-worktree");
1972 let error = GitError::Io(std::io::Error::new(
1973 std::io::ErrorKind::NotFound,
1974 "worktree removed",
1975 ));
1976
1977 let classified = classify_diff_repository_error(&missing_path, error);
1979
1980 assert!(matches!(
1982 classified,
1983 GitError::RepositoryUnavailable { detail } if detail == "worktree removed"
1984 ));
1985 }
1986
1987 #[tokio::test]
1988 async fn read_worktree_file_returns_text_for_safe_nested_path() {
1989 let temp_dir = tempdir().expect("failed to create temp dir");
1991 let docs_dir = temp_dir.path().join("docs");
1992 fs::create_dir(&docs_dir).expect("failed to create docs directory");
1993 fs::write(docs_dir.join("README.md"), "# Preview\n")
1994 .expect("failed to write markdown file");
1995
1996 let result =
1998 read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1999
2000 assert_eq!(
2002 result.expect("worktree read should succeed"),
2003 WorktreeFileContent::Text("# Preview\n".to_string())
2004 );
2005 }
2006
2007 #[tokio::test]
2008 async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
2009 let temp_dir = tempdir().expect("failed to create temp dir");
2011 fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
2012 .expect("failed to write binary file");
2013 fs::write(
2014 temp_dir.path().join("large.md"),
2015 vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
2016 )
2017 .expect("failed to write oversize file");
2018
2019 let missing =
2021 read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
2022 let binary =
2023 read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
2024 let too_large =
2025 read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
2026
2027 assert_eq!(
2029 missing.expect("missing read should succeed"),
2030 WorktreeFileContent::Missing
2031 );
2032 assert_eq!(
2033 binary.expect("binary read should succeed"),
2034 WorktreeFileContent::Binary
2035 );
2036 assert_eq!(
2037 too_large.expect("oversize read should succeed"),
2038 WorktreeFileContent::TooLarge
2039 );
2040 }
2041
2042 #[tokio::test]
2043 async fn read_worktree_file_rejects_unsafe_relative_paths() {
2044 let temp_dir = tempdir().expect("failed to create temp dir");
2046 let absolute_path = temp_dir.path().join("README.md");
2047
2048 let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
2050 let parent =
2051 read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
2052 let absolute = read_worktree_file(
2053 temp_dir.path().to_path_buf(),
2054 absolute_path.to_string_lossy().into_owned(),
2055 )
2056 .await;
2057
2058 for result in [empty, parent, absolute] {
2060 assert!(
2061 matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
2062 );
2063 }
2064 }
2065
2066 #[cfg(unix)]
2067 #[tokio::test]
2068 async fn read_worktree_file_rejects_symlinks_outside_repository() {
2069 let temp_dir = tempdir().expect("failed to create temp dir");
2071 let outside_dir = tempdir().expect("failed to create outside temp dir");
2072 let outside_file = outside_dir.path().join("outside.md");
2073 fs::write(&outside_file, "outside").expect("failed to write outside file");
2074 std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
2075 .expect("failed to create outside symlink");
2076
2077 let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
2079
2080 assert!(
2082 matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
2083 );
2084 }
2085
2086 #[cfg(unix)]
2087 #[tokio::test]
2088 async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
2089 let temp_dir = tempdir().expect("failed to create temp dir");
2091 std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
2092 .expect("failed to create symlink loop");
2093
2094 let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
2096
2097 assert!(matches!(result, Err(GitError::Io(_))));
2099 }
2100
2101 #[test]
2102 fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
2103 let temp_dir = tempdir().expect("failed to create temp dir");
2105 let path_without_parent = Path::new("/");
2106 let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
2107 let missing_index = temp_dir.path().join("missing-index");
2108
2109 let parent_error = copy_git_index_to_temp(path_without_parent);
2111 let create_error = copy_git_index_to_temp(&missing_parent_index);
2112 let copy_error = copy_git_index_to_temp(&missing_index);
2113
2114 assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
2116 assert!(matches!(
2117 create_error,
2118 Err(GitError::CommandFailed { ref command, .. })
2119 if command == "create temporary git index"
2120 ));
2121 assert!(matches!(
2122 copy_error,
2123 Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
2124 ));
2125 }
2126
2127 #[test]
2128 fn run_git_command_with_index_sync_maps_process_and_command_failures() {
2129 let temp_dir = tempdir().expect("failed to create temp dir");
2131 let index_path = temp_dir.path().join("index");
2132 let missing_repo_path = temp_dir.path().join("missing-repository");
2133 fs::write(&index_path, []).expect("failed to create temporary index");
2134
2135 let process_error = run_git_command_with_index_sync(
2137 &missing_repo_path,
2138 &["status"],
2139 &index_path,
2140 "Expected process failure",
2141 );
2142 let command_error = run_git_command_with_index_sync(
2143 temp_dir.path(),
2144 &["definitely-not-a-git-command"],
2145 &index_path,
2146 "Expected git failure",
2147 );
2148
2149 assert!(matches!(
2151 process_error,
2152 Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
2153 ));
2154 assert!(matches!(
2155 command_error,
2156 Err(GitError::CommandFailed {
2157 ref command,
2158 ref stderr,
2159 }) if command == "git definitely-not-a-git-command"
2160 && stderr.starts_with("Expected git failure:")
2161 ));
2162 }
2163
2164 #[cfg(unix)]
2165 fn write_executable_pre_commit_hook(hook_path: &Path) {
2166 write_executable_hook(hook_path, "#!/bin/sh\nexit 0\n");
2167 }
2168
2169 #[cfg(unix)]
2170 fn write_executable_hook(hook_path: &Path, contents: &str) {
2171 fs::create_dir_all(
2172 hook_path
2173 .parent()
2174 .expect("pre-commit hook should have a parent directory"),
2175 )
2176 .expect("failed to create hooks directory");
2177 fs::write(hook_path, contents).expect("failed to write Git hook");
2178 let mut permissions = fs::metadata(hook_path)
2179 .expect("failed to read Git hook metadata")
2180 .permissions();
2181 permissions.set_mode(0o750);
2182 fs::set_permissions(hook_path, permissions).expect("failed to make Git hook executable");
2183 }
2184
2185 #[test]
2186 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
2187 let temp_dir = tempdir().expect("failed to create temp dir");
2189 setup_test_git_repo(temp_dir.path());
2190
2191 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2193
2194 assert!(result.is_ok());
2196 }
2197
2198 #[test]
2199 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
2200 let temp_dir = tempdir().expect("failed to create temp dir");
2202 setup_test_git_repo(temp_dir.path());
2203 fs::write(
2204 temp_dir.path().join(".pre-commit-config.yaml"),
2205 "repos: []\n",
2206 )
2207 .expect("failed to write pre-commit configuration");
2208
2209 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2211
2212 assert!(matches!(
2214 result,
2215 Err(GitError::PreCommitHookMissing { ref config_file })
2216 if config_file == ".pre-commit-config.yaml"
2217 ));
2218 }
2219
2220 #[cfg(unix)]
2221 #[test]
2222 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
2223 let temp_dir = tempdir().expect("failed to create temp dir");
2225 setup_test_git_repo(temp_dir.path());
2226 fs::write(
2227 temp_dir.path().join(".pre-commit-config.yaml"),
2228 "repos: []\n",
2229 )
2230 .expect("failed to write pre-commit configuration");
2231 let hook_path = temp_dir.path().join(git_command_stdout(
2232 temp_dir.path(),
2233 &["rev-parse", "--git-path", "hooks/pre-commit"],
2234 ));
2235 write_executable_pre_commit_hook(&hook_path);
2236
2237 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2239
2240 assert!(result.is_ok());
2242 }
2243
2244 #[cfg(unix)]
2245 #[test]
2246 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
2247 let temp_dir = tempdir().expect("failed to create temp dir");
2249 setup_test_git_repo(temp_dir.path());
2250 fs::write(
2251 temp_dir.path().join(".pre-commit-config.yaml"),
2252 "repos: []\n",
2253 )
2254 .expect("failed to write pre-commit configuration");
2255 run_git_command(
2256 temp_dir.path(),
2257 &["config", "core.hooksPath", ".custom-hooks"],
2258 );
2259 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
2260
2261 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2263
2264 assert!(result.is_ok());
2266 }
2267
2268 #[cfg(unix)]
2269 #[test]
2270 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
2271 let temp_dir = tempdir().expect("failed to create temp dir");
2273 setup_test_git_repo(temp_dir.path());
2274 fs::write(
2275 temp_dir.path().join(".pre-commit-config.yaml"),
2276 "repos: []\n",
2277 )
2278 .expect("failed to write pre-commit configuration");
2279 let hook_path = temp_dir.path().join(git_command_stdout(
2280 temp_dir.path(),
2281 &["rev-parse", "--git-path", "hooks/pre-commit"],
2282 ));
2283 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
2284 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o010))
2285 .expect("failed to set mismatched execute permissions");
2286
2287 let result = ensure_pre_commit_hook_ready(temp_dir.path());
2289
2290 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
2292 }
2293
2294 #[tokio::test]
2295 async fn run_pre_commit_hook_accepts_missing_hook() {
2296 let temp_dir = tempdir().expect("failed to create temp dir");
2298 setup_test_git_repo(temp_dir.path());
2299
2300 let result = run_pre_commit_hook(temp_dir.path().to_path_buf()).await;
2302
2303 assert!(result.is_ok());
2305 }
2306
2307 #[cfg(unix)]
2308 #[tokio::test]
2309 async fn run_pre_commit_hook_uses_effective_custom_hook() {
2310 let temp_dir = tempdir().expect("failed to create temp dir");
2312 setup_test_git_repo(temp_dir.path());
2313 run_git_command(
2314 temp_dir.path(),
2315 &["config", "core.hooksPath", ".custom-hooks"],
2316 );
2317 write_executable_hook(
2318 &temp_dir.path().join(".custom-hooks").join("pre-commit"),
2319 "#!/bin/sh\nprintf 'ran\\n' > pre-commit-ran\n",
2320 );
2321
2322 let result = run_pre_commit_hook(temp_dir.path().to_path_buf()).await;
2324
2325 assert!(result.is_ok());
2327 assert_eq!(
2328 fs::read_to_string(temp_dir.path().join("pre-commit-ran"))
2329 .expect("pre-commit marker should exist"),
2330 "ran\n"
2331 );
2332 }
2333
2334 #[cfg(unix)]
2335 #[tokio::test]
2336 async fn run_pre_commit_hook_returns_hook_failure_output() {
2337 let temp_dir = tempdir().expect("failed to create temp dir");
2339 setup_test_git_repo(temp_dir.path());
2340 let hook_path = temp_dir.path().join(git_command_stdout(
2341 temp_dir.path(),
2342 &["rev-parse", "--git-path", "hooks/pre-commit"],
2343 ));
2344 write_executable_hook(
2345 &hook_path,
2346 "#!/bin/sh\nprintf 'resolved conflict rejected\\n' >&2\nexit 1\n",
2347 );
2348
2349 let result = run_pre_commit_hook(temp_dir.path().to_path_buf()).await;
2351
2352 assert!(matches!(
2354 result,
2355 Err(GitError::CommandFailed {
2356 ref command,
2357 ref stderr,
2358 }) if command == "git hook run pre-commit"
2359 && stderr.contains("resolved conflict rejected")
2360 ));
2361 }
2362
2363 #[test]
2364 fn pre_commit_hook_result_preserves_command_launch_error() {
2365 let command_error = GitError::CommandFailed {
2367 command: "git hook run pre-commit".to_string(),
2368 stderr: "git executable unavailable".to_string(),
2369 };
2370
2371 let result = pre_commit_hook_result(Err(command_error));
2373
2374 assert!(matches!(
2376 result,
2377 Err(GitError::CommandFailed { ref stderr, .. })
2378 if stderr == "git executable unavailable"
2379 ));
2380 }
2381
2382 #[tokio::test]
2383 async fn commit_all_allows_configured_validation_without_hook() {
2384 let temp_dir = tempdir().expect("failed to create temp dir");
2386 setup_test_git_repo(temp_dir.path());
2387 fs::write(
2388 temp_dir.path().join(".pre-commit-config.yaml"),
2389 "repos: []\n",
2390 )
2391 .expect("failed to write pre-commit configuration");
2392 fs::write(temp_dir.path().join("README.md"), "changed\n")
2393 .expect("failed to write worktree change");
2394
2395 let result = commit_all(temp_dir.path().to_path_buf(), "Change README".to_string()).await;
2397
2398 assert!(result.is_ok());
2400 assert_eq!(
2401 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
2402 "Change README"
2403 );
2404 }
2405
2406 #[tokio::test]
2407 async fn current_branch_name_returns_error_for_detached_head() {
2408 let temp_dir = tempdir().expect("failed to create temp dir");
2410 setup_test_git_repo(temp_dir.path());
2411 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
2412 let command_runner = ProcessAsyncGitCommandRunner;
2413
2414 let result = current_branch_name(temp_dir.path(), &command_runner).await;
2416
2417 let error = result.expect_err("detached HEAD should fail");
2419 assert!(error.to_string().contains("detached HEAD"));
2420 }
2421
2422 #[tokio::test]
2423 async fn current_branch_remote_name_returns_none_when_remote_is_not_configured() {
2424 let temp_dir = tempdir().expect("failed to create temp dir");
2426 setup_test_git_repo(temp_dir.path());
2427 let command_runner = ProcessAsyncGitCommandRunner;
2428
2429 let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2431 .await
2432 .expect("missing branch remote should not be a command failure");
2433
2434 assert_eq!(remote_name, None);
2436 }
2437
2438 #[tokio::test]
2439 async fn current_branch_remote_name_returns_configured_non_origin_remote() {
2440 let temp_dir = tempdir().expect("failed to create temp dir");
2442 setup_test_git_repo(temp_dir.path());
2443 run_git_command(
2444 temp_dir.path(),
2445 &["config", "branch.main.remote", "review-remote"],
2446 );
2447 let command_runner = ProcessAsyncGitCommandRunner;
2448
2449 let remote_name = current_branch_remote_name(temp_dir.path(), &command_runner)
2451 .await
2452 .expect("configured branch remote should resolve");
2453
2454 assert_eq!(remote_name, Some("review-remote".to_string()));
2456 }
2457
2458 #[test]
2459 fn parse_current_branch_remote_output_preserves_fatal_config_error() {
2460 let output = AsyncGitCommandOutput {
2462 exit_code: Some(128),
2463 stderr: b"fatal: bad config line".to_vec(),
2464 stdout: Vec::new(),
2465 };
2466
2467 let error = parse_current_branch_remote_output(&output, "branch.main.remote")
2469 .expect_err("malformed config should remain an error");
2470
2471 assert!(matches!(
2473 error,
2474 GitError::CommandFailed { command, stderr }
2475 if command == "git config --get branch.main.remote"
2476 && stderr.contains("Failed to resolve current branch remote")
2477 ));
2478 }
2479
2480 #[tokio::test]
2481 async fn primary_upstream_reference_uses_first_non_empty_line() {
2482 let temp_dir = tempdir().expect("failed to create temp dir");
2484 let remote_dir = tempdir().expect("failed to create remote temp dir");
2485 setup_test_git_repo(temp_dir.path());
2486 run_git_command(remote_dir.path(), &["init", "--bare"]);
2487 let remote_path = remote_dir.path().to_string_lossy().to_string();
2488 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2489 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2490 run_git_command(
2491 temp_dir.path(),
2492 &[
2493 "config",
2494 "--replace-all",
2495 "branch.main.merge",
2496 "refs/heads/main",
2497 ],
2498 );
2499 run_git_command(
2500 temp_dir.path(),
2501 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
2502 );
2503 let command_runner = ProcessAsyncGitCommandRunner;
2504
2505 let upstream_reference = primary_upstream_reference(temp_dir.path(), &command_runner)
2507 .await
2508 .expect("failed to resolve upstream");
2509
2510 assert_eq!(upstream_reference, "origin/main");
2512 }
2513
2514 #[tokio::test]
2515 async fn pull_rebase_retries_index_lock_through_async_runner() {
2516 let repo_path = PathBuf::from("test-repo");
2518 let mut command_runner = MockAsyncGitCommandRunner::new();
2519 let mut sequence = Sequence::new();
2520 command_runner
2521 .expect_run()
2522 .with(function(|command: &AsyncGitCommand| {
2523 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2524 }))
2525 .times(1)
2526 .in_sequence(&mut sequence)
2527 .return_once(|_| {
2528 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2529 });
2530 for output in [
2531 async_git_output(
2532 128,
2533 Vec::new(),
2534 "fatal: Unable to create '.git/index.lock': File exists.",
2535 ),
2536 async_git_output(0, Vec::new(), Vec::new()),
2537 ] {
2538 command_runner
2539 .expect_run()
2540 .with(function(|command: &AsyncGitCommand| {
2541 command.arguments == ["pull", "--rebase", "origin", "main"]
2542 && command.environment
2543 == [
2544 ("GIT_EDITOR".to_string(), ":".to_string()),
2545 ("GIT_SEQUENCE_EDITOR".to_string(), ":".to_string()),
2546 ]
2547 }))
2548 .times(1)
2549 .in_sequence(&mut sequence)
2550 .return_once(move |_| Box::pin(async move { Ok(output) }));
2551 }
2552
2553 let result = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO).await;
2555
2556 assert!(matches!(result, Ok(PullRebaseResult::Completed)));
2558 }
2559
2560 #[tokio::test]
2561 async fn pull_rebase_preserves_non_conflict_command_failure() {
2562 let repo_path = PathBuf::from("test-repo");
2564 let mut command_runner = MockAsyncGitCommandRunner::new();
2565 let mut sequence = Sequence::new();
2566 command_runner
2567 .expect_run()
2568 .with(function(|command: &AsyncGitCommand| {
2569 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2570 }))
2571 .times(1)
2572 .in_sequence(&mut sequence)
2573 .return_once(|_| {
2574 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2575 });
2576 command_runner
2577 .expect_run()
2578 .with(function(|command: &AsyncGitCommand| {
2579 command.arguments == ["pull", "--rebase", "origin", "main"]
2580 }))
2581 .times(1)
2582 .in_sequence(&mut sequence)
2583 .return_once(|_| {
2584 Box::pin(async { Ok(async_git_output(128, Vec::new(), "fatal: transport failed")) })
2585 });
2586
2587 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2589 .await
2590 .expect_err("non-conflict pull failure should remain an error");
2591
2592 assert!(matches!(
2594 error,
2595 GitError::CommandFailed { command, stderr }
2596 if command == "git pull --rebase" && stderr == "fatal: transport failed"
2597 ));
2598 }
2599
2600 #[tokio::test]
2601 async fn pull_rebase_rejects_local_upstream_without_configured_remote() {
2602 let repo_path = PathBuf::from("test-repo");
2604 let mut command_runner = MockAsyncGitCommandRunner::new();
2605 let mut sequence = Sequence::new();
2606 let expectations = [
2607 (
2608 vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2609 async_git_output(0, "main\n", Vec::new()),
2610 ),
2611 (
2612 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2613 async_git_output(0, "main\n", Vec::new()),
2614 ),
2615 (
2616 vec!["config", "--get", "branch.main.remote"],
2617 async_git_output(1, Vec::new(), Vec::new()),
2618 ),
2619 ];
2620 for (arguments, output) in expectations {
2621 let arguments = arguments
2622 .into_iter()
2623 .map(str::to_string)
2624 .collect::<Vec<_>>();
2625 command_runner
2626 .expect_run()
2627 .with(function(move |command: &AsyncGitCommand| {
2628 command.arguments == arguments
2629 }))
2630 .times(1)
2631 .in_sequence(&mut sequence)
2632 .return_once(move |_| Box::pin(async move { Ok(output) }));
2633 }
2634
2635 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2637 .await
2638 .expect_err("local upstream without a remote should fail");
2639
2640 assert!(matches!(
2642 error,
2643 GitError::OutputParse(message)
2644 if message == "Failed to resolve current branch remote: not configured"
2645 ));
2646 }
2647
2648 #[tokio::test]
2649 async fn pull_rebase_returns_last_index_lock_failure_after_retry_exhaustion() {
2650 let repo_path = PathBuf::from("test-repo");
2652 let mut command_runner = MockAsyncGitCommandRunner::new();
2653 let mut sequence = Sequence::new();
2654 command_runner
2655 .expect_run()
2656 .with(function(|command: &AsyncGitCommand| {
2657 command.arguments == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
2658 }))
2659 .times(1)
2660 .in_sequence(&mut sequence)
2661 .return_once(|_| {
2662 Box::pin(async { Ok(async_git_output(0, "origin/main\n", Vec::new())) })
2663 });
2664 command_runner
2665 .expect_run()
2666 .with(function(|command: &AsyncGitCommand| {
2667 command.arguments == ["pull", "--rebase", "origin", "main"]
2668 }))
2669 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
2670 .in_sequence(&mut sequence)
2671 .returning(|_| {
2672 Box::pin(async {
2673 Ok(async_git_output(
2674 128,
2675 Vec::new(),
2676 "fatal: Unable to create '.git/index.lock': File exists.",
2677 ))
2678 })
2679 });
2680
2681 let error = pull_rebase_with_runner(repo_path, &command_runner, Duration::ZERO)
2683 .await
2684 .expect_err("exhausted index-lock retries should return the last failure");
2685
2686 assert!(matches!(
2688 error,
2689 GitError::CommandFailed { command, stderr }
2690 if command == "git pull --rebase" && stderr.contains("index.lock")
2691 ));
2692 }
2693
2694 #[tokio::test]
2695 async fn remote_branch_lookup_uses_origin_fallback_through_async_runner() {
2696 let repo_path = PathBuf::from("test-repo");
2698 let mut command_runner = MockAsyncGitCommandRunner::new();
2699 let mut sequence = Sequence::new();
2700 let expectations = [
2701 (
2702 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2703 async_git_output(0, "main\n", Vec::new()),
2704 ),
2705 (
2706 vec!["config", "--get", "branch.main.remote"],
2707 async_git_output(1, Vec::new(), Vec::new()),
2708 ),
2709 (
2710 vec!["ls-remote", "--heads", "origin", "review/topic"],
2711 async_git_output(0, "abc123\trefs/heads/review/topic\n", Vec::new()),
2712 ),
2713 ];
2714 for (arguments, output) in expectations {
2715 let arguments = arguments
2716 .into_iter()
2717 .map(str::to_string)
2718 .collect::<Vec<_>>();
2719 command_runner
2720 .expect_run()
2721 .with(function(move |command: &AsyncGitCommand| {
2722 command.arguments == arguments
2723 }))
2724 .times(1)
2725 .in_sequence(&mut sequence)
2726 .return_once(move |_| Box::pin(async move { Ok(output) }));
2727 }
2728
2729 let exists = remote_branch_exists_with_runner(
2731 repo_path,
2732 "review/topic".to_string(),
2733 &command_runner,
2734 )
2735 .await
2736 .expect("remote branch lookup should succeed");
2737
2738 assert!(exists);
2740 }
2741
2742 #[tokio::test]
2743 async fn new_remote_branch_push_requires_missing_remote_ref() {
2744 let repo_path = PathBuf::from("test-repo");
2746 let mut command_runner = MockAsyncGitCommandRunner::new();
2747 let mut sequence = Sequence::new();
2748 let expectations = [
2749 (
2750 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2751 async_git_output(0, "main\n", Vec::new()),
2752 ),
2753 (
2754 vec!["config", "--get", "branch.main.remote"],
2755 async_git_output(1, Vec::new(), Vec::new()),
2756 ),
2757 (
2758 vec![
2759 "push",
2760 "--force-with-lease=refs/heads/review/topic:",
2761 "--set-upstream",
2762 "origin",
2763 "HEAD:review/topic",
2764 ],
2765 async_git_output(0, Vec::new(), Vec::new()),
2766 ),
2767 ];
2768 for (arguments, output) in expectations {
2769 let arguments = arguments
2770 .into_iter()
2771 .map(str::to_string)
2772 .collect::<Vec<_>>();
2773 command_runner
2774 .expect_run()
2775 .with(function(move |command: &AsyncGitCommand| {
2776 command.arguments == arguments
2777 }))
2778 .times(1)
2779 .in_sequence(&mut sequence)
2780 .return_once(move |_| Box::pin(async move { Ok(output) }));
2781 }
2782
2783 let upstream_reference = push_current_branch_to_new_remote_branch_with_runner(
2785 repo_path,
2786 "review/topic".to_string(),
2787 &command_runner,
2788 )
2789 .await
2790 .expect("new remote branch push should succeed");
2791
2792 assert_eq!(upstream_reference, "origin/review/topic");
2794 }
2795
2796 #[tokio::test]
2797 async fn remote_branch_lookup_checks_isolated_local_remote() {
2798 let temp_dir = tempdir().expect("failed to create temp dir");
2800 let remote_dir = tempdir().expect("failed to create remote temp dir");
2801 setup_test_git_repo(temp_dir.path());
2802 run_git_command(remote_dir.path(), &["init", "--bare"]);
2803 let remote_path = remote_dir.path().to_string_lossy().to_string();
2804 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2805 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2806
2807 let exists = remote_branch_exists(temp_dir.path().to_path_buf(), "main".to_string())
2809 .await
2810 .expect("local remote branch lookup should succeed");
2811
2812 assert!(exists);
2814 }
2815
2816 #[tokio::test]
2817 async fn push_without_upstream_reuses_configured_remote() {
2818 let repo_path = PathBuf::from("test-repo");
2820 let mut command_runner = MockAsyncGitCommandRunner::new();
2821 let mut sequence = Sequence::new();
2822 let expectations = [
2823 (
2824 vec!["push", "--force-with-lease"],
2825 async_git_output(128, Vec::new(), "fatal: no upstream branch"),
2826 ),
2827 (
2828 vec!["rev-parse", "--abbrev-ref", "HEAD"],
2829 async_git_output(0, "main\n", Vec::new()),
2830 ),
2831 (
2832 vec!["config", "--get", "branch.main.remote"],
2833 async_git_output(0, "review-remote\n", Vec::new()),
2834 ),
2835 (
2836 vec![
2837 "push",
2838 "--force-with-lease",
2839 "--set-upstream",
2840 "review-remote",
2841 "HEAD",
2842 ],
2843 async_git_output(0, Vec::new(), Vec::new()),
2844 ),
2845 (
2846 vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
2847 async_git_output(0, "review-remote/main\n", Vec::new()),
2848 ),
2849 ];
2850 for (arguments, output) in expectations {
2851 let arguments = arguments
2852 .into_iter()
2853 .map(str::to_string)
2854 .collect::<Vec<_>>();
2855 command_runner
2856 .expect_run()
2857 .with(function(move |command: &AsyncGitCommand| {
2858 command.arguments == arguments
2859 }))
2860 .times(1)
2861 .in_sequence(&mut sequence)
2862 .return_once(move |_| Box::pin(async move { Ok(output) }));
2863 }
2864
2865 let upstream_reference = push_current_branch_with_runner(repo_path, &command_runner)
2867 .await
2868 .expect("configured remote push should succeed");
2869
2870 assert_eq!(upstream_reference, "review-remote/main");
2872 }
2873
2874 #[test]
2875 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
2876 let output = "\
2878main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
2879 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
2880
2881 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
2883
2884 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
2886 assert_eq!(
2887 branch_tracking_statuses.get("wt/1234abcd"),
2888 Some(&Some((3, 1)))
2889 );
2890 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
2891 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
2892 }
2893
2894 #[tokio::test]
2895 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
2896 let temp_dir = tempdir().expect("failed to create temp dir");
2898 let remote_dir = tempdir().expect("failed to create remote temp dir");
2899 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2900 let contributor_clone_path = contributor_dir.path().join("clone");
2901 setup_test_git_repo(temp_dir.path());
2902 run_git_command(remote_dir.path(), &["init", "--bare"]);
2903 let remote_path = remote_dir.path().to_string_lossy().to_string();
2904 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2905 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2906 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2907 fs::write(temp_dir.path().join("README.md"), "local change\n")
2908 .expect("failed to write local change");
2909 run_git_command(temp_dir.path(), &["add", "README.md"]);
2910 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2911 run_git_command(
2912 contributor_dir.path(),
2913 &["clone", &remote_path, &contributor_clone_path_text],
2914 );
2915 run_git_command(
2916 &contributor_clone_path,
2917 &["config", "user.name", "Contributor User"],
2918 );
2919 run_git_command(
2920 &contributor_clone_path,
2921 &["config", "user.email", "contributor@example.com"],
2922 );
2923 run_git_command(
2924 &contributor_clone_path,
2925 &["checkout", "-B", "main", "origin/main"],
2926 );
2927 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
2928 .expect("failed to write remote change");
2929 run_git_command(&contributor_clone_path, &["add", "README.md"]);
2930 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2931 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2932
2933 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
2935
2936 assert!(matches!(
2938 result,
2939 Ok(PullRebaseResult::Conflict { ref detail })
2940 if {
2941 let normalized_detail = detail.to_ascii_lowercase();
2942
2943 (normalized_detail.contains("conflict")
2944 || normalized_detail.contains("could not apply"))
2945 && !detail.is_empty()
2946 }
2947 ));
2948 }
2949
2950 #[tokio::test]
2951 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
2952 let temp_dir = tempdir().expect("failed to create temp dir");
2954 let remote_dir = tempdir().expect("failed to create remote temp dir");
2955 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2956 let contributor_clone_path = contributor_dir.path().join("clone");
2957 setup_test_git_repo(temp_dir.path());
2958 run_git_command(remote_dir.path(), &["init", "--bare"]);
2959 let remote_path = remote_dir.path().to_string_lossy().to_string();
2960 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2961 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2962 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2963 run_git_command(
2964 contributor_dir.path(),
2965 &["clone", &remote_path, &contributor_clone_path_text],
2966 );
2967 run_git_command(
2968 &contributor_clone_path,
2969 &["config", "user.name", "Contributor User"],
2970 );
2971 run_git_command(
2972 &contributor_clone_path,
2973 &["config", "user.email", "contributor@example.com"],
2974 );
2975 run_git_command(
2976 &contributor_clone_path,
2977 &["checkout", "-B", "main", "origin/main"],
2978 );
2979 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2980 .expect("failed to write remote file");
2981 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2982 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2983 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2984 fs::write(temp_dir.path().join("local.txt"), "local change")
2985 .expect("failed to write local file");
2986 run_git_command(temp_dir.path(), &["add", "local.txt"]);
2987 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2988
2989 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2991
2992 let error = result
2994 .expect_err("non-fast-forward push should fail")
2995 .to_string();
2996 assert!(error.contains("git push"));
2997 assert!(
2998 error.contains("stale info")
2999 || error.contains("rejected")
3000 || error.contains("fetch first")
3001 );
3002 }
3003
3004 #[tokio::test]
3005 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
3006 let temp_dir = tempdir().expect("failed to create temp dir");
3008 let remote_dir = tempdir().expect("failed to create remote temp dir");
3009 setup_test_git_repo(temp_dir.path());
3010 run_git_command(remote_dir.path(), &["init", "--bare"]);
3011 let remote_path = remote_dir.path().to_string_lossy().to_string();
3012 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3013 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
3014 fs::write(
3015 temp_dir.path().join("README.md"),
3016 "first published version\n",
3017 )
3018 .expect("failed to write first version");
3019 run_git_command(temp_dir.path(), &["add", "README.md"]);
3020 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
3021 push_current_branch(temp_dir.path().to_path_buf())
3022 .await
3023 .expect("initial push should succeed");
3024 fs::write(
3025 temp_dir.path().join("README.md"),
3026 "rewritten published version\n",
3027 )
3028 .expect("failed to rewrite published version");
3029 run_git_command(temp_dir.path(), &["add", "README.md"]);
3030 run_git_command(
3031 temp_dir.path(),
3032 &["commit", "--amend", "-m", "Rewrite published branch change"],
3033 );
3034
3035 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
3037 .await
3038 .expect("force-with-lease push should update rewritten history");
3039 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
3040 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
3041
3042 assert_eq!(upstream_reference, "origin/main");
3044 assert_eq!(local_head, remote_head);
3045 }
3046
3047 #[tokio::test]
3048 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
3049 let temp_dir = tempdir().expect("failed to create temp dir");
3051 let remote_dir = tempdir().expect("failed to create remote temp dir");
3052 setup_test_git_repo(temp_dir.path());
3053 run_git_command(remote_dir.path(), &["init", "--bare"]);
3054 let remote_path = remote_dir.path().to_string_lossy().to_string();
3055 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3056
3057 let upstream_reference = push_current_branch_to_remote_branch(
3059 temp_dir.path().to_path_buf(),
3060 "review/custom-branch".to_string(),
3061 )
3062 .await
3063 .expect("failed to push current branch to custom remote branch");
3064
3065 assert_eq!(upstream_reference, "origin/review/custom-branch");
3067 }
3068
3069 #[tokio::test]
3070 async fn new_remote_branch_push_rejects_existing_remote_branch() {
3071 let temp_dir = tempdir().expect("failed to create temp dir");
3073 let remote_dir = tempdir().expect("failed to create remote temp dir");
3074 setup_test_git_repo(temp_dir.path());
3075 run_git_command(remote_dir.path(), &["init", "--bare"]);
3076 let remote_path = remote_dir.path().to_string_lossy().to_string();
3077 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3078 run_git_command(
3079 temp_dir.path(),
3080 &["push", "origin", "HEAD:review/existing-branch"],
3081 );
3082 let remote_head_before = git_command_stdout(
3083 remote_dir.path(),
3084 &["rev-parse", "refs/heads/review/existing-branch"],
3085 );
3086 fs::write(temp_dir.path().join("new.txt"), "new local review\n")
3087 .expect("failed to write new local review file");
3088 run_git_command(temp_dir.path(), &["add", "new.txt"]);
3089 run_git_command(temp_dir.path(), &["commit", "-m", "New local review"]);
3090
3091 let result = push_current_branch_to_new_remote_branch(
3093 temp_dir.path().to_path_buf(),
3094 "review/existing-branch".to_string(),
3095 )
3096 .await;
3097 let remote_head_after = git_command_stdout(
3098 remote_dir.path(),
3099 &["rev-parse", "refs/heads/review/existing-branch"],
3100 );
3101
3102 let error = result.expect_err("existing remote branch should be rejected");
3104 assert!(error.to_string().contains("stale info"));
3105 assert_eq!(remote_head_before, remote_head_after);
3106 }
3107
3108 #[tokio::test]
3109 async fn new_remote_branch_push_ignores_stale_remote_tracking_ref() {
3110 let temp_dir = tempdir().expect("failed to create temp dir");
3112 let remote_dir = tempdir().expect("failed to create remote temp dir");
3113 setup_test_git_repo(temp_dir.path());
3114 run_git_command(remote_dir.path(), &["init", "--bare"]);
3115 let remote_path = remote_dir.path().to_string_lossy().to_string();
3116 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3117 run_git_command(temp_dir.path(), &["checkout", "-b", "previous-review"]);
3118 fs::write(temp_dir.path().join("previous.txt"), "previous review\n")
3119 .expect("failed to write previous review file");
3120 run_git_command(temp_dir.path(), &["add", "previous.txt"]);
3121 run_git_command(temp_dir.path(), &["commit", "-m", "Previous review"]);
3122 let previous_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
3123 run_git_command(
3124 temp_dir.path(),
3125 &[
3126 "push",
3127 "--set-upstream",
3128 "origin",
3129 "HEAD:review/deleted-branch",
3130 ],
3131 );
3132 run_git_command(
3133 temp_dir.path(),
3134 &["push", "origin", ":review/deleted-branch"],
3135 );
3136 run_git_command(
3137 temp_dir.path(),
3138 &[
3139 "update-ref",
3140 "refs/remotes/origin/review/deleted-branch",
3141 &previous_head,
3142 ],
3143 );
3144 run_git_command(temp_dir.path(), &["checkout", "main"]);
3145 fs::write(
3146 temp_dir.path().join("replacement.txt"),
3147 "replacement review\n",
3148 )
3149 .expect("failed to write replacement review file");
3150 run_git_command(temp_dir.path(), &["add", "replacement.txt"]);
3151 run_git_command(temp_dir.path(), &["commit", "-m", "Replacement review"]);
3152
3153 let upstream_reference = push_current_branch_to_new_remote_branch(
3155 temp_dir.path().to_path_buf(),
3156 "review/deleted-branch".to_string(),
3157 )
3158 .await
3159 .expect("new branch push should ignore the stale remote-tracking ref");
3160 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
3161 let remote_head = git_command_stdout(
3162 remote_dir.path(),
3163 &["rev-parse", "refs/heads/review/deleted-branch"],
3164 );
3165
3166 assert_eq!(upstream_reference, "origin/review/deleted-branch");
3168 assert_eq!(local_head, remote_head);
3169 }
3170
3171 #[tokio::test]
3172 async fn current_upstream_reference_returns_origin_main() {
3173 let temp_dir = tempdir().expect("failed to create temp dir");
3175 let remote_dir = tempdir().expect("failed to create remote temp dir");
3176 setup_test_git_repo(temp_dir.path());
3177 run_git_command(remote_dir.path(), &["init", "--bare"]);
3178 let remote_path = remote_dir.path().to_string_lossy().to_string();
3179 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3180 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
3181
3182 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
3184 .await
3185 .expect("failed to resolve upstream reference");
3186
3187 assert_eq!(upstream_reference, "origin/main");
3189 }
3190
3191 #[tokio::test]
3192 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
3193 let temp_dir = tempdir().expect("failed to create temp dir");
3195 setup_test_git_repo(temp_dir.path());
3196 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
3197 fs::write(temp_dir.path().join("session.txt"), "session change\n")
3198 .expect("failed to write session file");
3199 run_git_command(temp_dir.path(), &["add", "session.txt"]);
3200 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
3201 run_git_command(temp_dir.path(), &["checkout", "main"]);
3202 fs::write(temp_dir.path().join("main.txt"), "main change\n")
3203 .expect("failed to write main file");
3204 run_git_command(temp_dir.path(), &["add", "main.txt"]);
3205 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
3206
3207 let status = get_ref_ahead_behind(
3209 temp_dir.path().to_path_buf(),
3210 "wt/1234abcd".to_string(),
3211 "main".to_string(),
3212 )
3213 .await
3214 .expect("failed to compare branch refs");
3215
3216 assert_eq!(status, (1, 1));
3218 }
3219
3220 #[tokio::test]
3221 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
3222 let temp_dir = tempdir().expect("failed to create temp dir");
3224 let remote_dir = tempdir().expect("failed to create remote temp dir");
3225 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
3226 let contributor_clone_path = contributor_dir.path().join("clone");
3227 setup_test_git_repo(temp_dir.path());
3228 run_git_command(remote_dir.path(), &["init", "--bare"]);
3229 let remote_path = remote_dir.path().to_string_lossy().to_string();
3230 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
3231 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
3232 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
3233 run_git_command(
3234 contributor_dir.path(),
3235 &["clone", &remote_path, &contributor_clone_path_text],
3236 );
3237 run_git_command(
3238 &contributor_clone_path,
3239 &["config", "user.name", "Contributor User"],
3240 );
3241 run_git_command(
3242 &contributor_clone_path,
3243 &["config", "user.email", "contributor@example.com"],
3244 );
3245 run_git_command(
3246 &contributor_clone_path,
3247 &["checkout", "-B", "main", "origin/main"],
3248 );
3249 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
3250 .expect("failed to write remote file");
3251 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
3252 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
3253 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
3254 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
3255 fs::write(temp_dir.path().join("session.txt"), "session change\n")
3256 .expect("failed to write session file");
3257 run_git_command(temp_dir.path(), &["add", "session.txt"]);
3258 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
3259 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
3260 fs::write(
3261 temp_dir.path().join("session.txt"),
3262 "session change\nmore local\n",
3263 )
3264 .expect("failed to extend session file");
3265 run_git_command(temp_dir.path(), &["add", "session.txt"]);
3266 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
3267 run_git_command(temp_dir.path(), &["fetch"]);
3268
3269 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
3271 .await
3272 .expect("failed to read branch tracking statuses");
3273
3274 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
3276 assert_eq!(
3277 branch_tracking_statuses.get("wt/1234abcd"),
3278 Some(&Some((1, 0)))
3279 );
3280 }
3281
3282 #[tokio::test]
3283 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
3288 let temp_dir = tempdir().expect("failed to create temp dir");
3290 setup_test_git_repo(temp_dir.path());
3291 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
3292 fs::write(temp_dir.path().join("session.txt"), "session work\n")
3293 .expect("failed to write session file");
3294 run_git_command(temp_dir.path(), &["add", "session.txt"]);
3295 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
3296 fs::remove_file(temp_dir.path().join("session.txt"))
3297 .expect("failed to remove session file");
3298
3299 let result = commit_all_preserving_single_commit(
3303 temp_dir.path().to_path_buf(),
3304 "main".to_string(),
3305 "Session commit".to_string(),
3306 SingleCommitMessageStrategy::Replace,
3307 )
3308 .await;
3309
3310 let error = result.expect_err("amend-would-be-empty should fail");
3312 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
3313 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
3314 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
3315
3316 assert!(
3317 error.to_string().contains("Nothing to commit"),
3318 "expected 'Nothing to commit' sentinel but got: {error}"
3319 );
3320 assert_eq!(commit_count, "1");
3321 assert_eq!(head_message, "Initial commit");
3322 assert_eq!(status, "");
3323 }
3324}