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)]
1701#[path = "sync_test.rs"]
1702mod tests;