1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Output;
4
5use tokio::task::spawn_blocking;
6
7use super::error::GitError;
8use super::rebase::{is_rebase_conflict, run_git_command_with_index_lock_retry};
9use super::repo::{
10 command_output_detail, run_git_command, run_git_command_output_sync, run_git_command_sync,
11};
12
13pub type BranchTrackingMap = HashMap<String, Option<(u32, u32)>>;
16
17const COMMIT_ALL_HOOK_RETRY_ATTEMPTS: usize = 5;
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum SingleCommitMessageStrategy {
23 Replace,
25 Reuse,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum PullRebaseResult {
32 Completed,
34 Conflict { detail: String },
36}
37
38pub(crate) async fn commit_all(
52 repo_path: PathBuf,
53 commit_message: String,
54 no_verify: bool,
55) -> Result<(), GitError> {
56 commit_all_with_retry(
57 repo_path,
58 commit_message,
59 SingleCommitMessageStrategy::Replace,
60 no_verify,
61 false,
62 )
63 .await
64}
65
66pub(crate) async fn commit_all_preserving_single_commit(
88 repo_path: PathBuf,
89 base_branch: String,
90 commit_message: String,
91 message_strategy: SingleCommitMessageStrategy,
92 no_verify: bool,
93) -> Result<(), GitError> {
94 let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
95
96 commit_all_with_retry(
97 repo_path,
98 commit_message,
99 message_strategy,
100 no_verify,
101 amend_existing_commit,
102 )
103 .await
104}
105
106pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
117 spawn_blocking(move || stage_all_sync(&repo_path)).await?
118}
119
120pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
131 let hash = run_git_command(
132 repo_path,
133 vec![
134 "rev-parse".to_string(),
135 "--short".to_string(),
136 "HEAD".to_string(),
137 ],
138 "Failed to resolve HEAD hash".to_string(),
139 )
140 .await?;
141 let hash = hash.trim().to_string();
142 if hash.is_empty() {
143 return Err(GitError::OutputParse(
144 "Failed to resolve HEAD hash: empty output".to_string(),
145 ));
146 }
147
148 Ok(hash)
149}
150
151pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
162 let hash = run_git_command(
163 repo_path,
164 vec!["rev-parse".to_string(), "HEAD".to_string()],
165 "Failed to resolve HEAD hash".to_string(),
166 )
167 .await?;
168 let hash = hash.trim().to_string();
169 if hash.is_empty() {
170 return Err(GitError::OutputParse(
171 "Failed to resolve HEAD hash: empty output".to_string(),
172 ));
173 }
174
175 Ok(hash)
176}
177
178pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
190 let hash = run_git_command(
191 repo_path,
192 vec![
193 "rev-parse".to_string(),
194 "--verify".to_string(),
195 format!("{reference}^{{commit}}"),
196 ],
197 format!("Failed to resolve `{reference}` hash"),
198 )
199 .await?;
200 let hash = hash.trim().to_string();
201 if hash.is_empty() {
202 return Err(GitError::OutputParse(format!(
203 "Failed to resolve `{reference}` hash: empty output"
204 )));
205 }
206
207 Ok(hash)
208}
209
210pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
215 spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
216}
217
218pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
232 run_git_command(
233 repo_path,
234 vec!["branch".to_string(), "-D".to_string(), branch_name],
235 "Git branch deletion failed".to_string(),
236 )
237 .await?;
238
239 Ok(())
240}
241
242pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
263 spawn_blocking(move || -> Result<String, GitError> {
264 run_git_command_sync(
265 &repo_path,
266 &["add", "-A", "--intent-to-add"],
267 "Git add --intent-to-add failed",
268 )?;
269
270 let merge_base_output =
271 run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
272
273 let diff_target = if merge_base_output.status.success() {
274 resolve_diff_target(
275 &repo_path,
276 &base_branch,
277 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
278 )?
279 } else {
280 base_branch
281 };
282
283 let diff_output = run_git_command_sync(
284 &repo_path,
285 &["diff", diff_target.as_str()],
286 "Git diff failed",
287 );
288 let reset_result = run_git_command_sync(&repo_path, &["reset"], "Git reset failed");
289
290 if let Err(diff_error) = diff_output {
291 return match reset_result {
292 Ok(_) => Err(diff_error),
293 Err(reset_error) => Err(GitError::CommandFailed {
294 command: "git diff".to_string(),
295 stderr: format!(
296 "{diff_error} Additionally failed to restore index state: {reset_error}"
297 ),
298 }),
299 };
300 }
301
302 reset_result?;
303
304 diff_output
305 })
306 .await?
307}
308
309pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
320 let status_output = worktree_status(repo_path).await?;
321
322 Ok(status_output.trim().is_empty())
323}
324
325pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
339 run_git_command(
340 repo_path,
341 vec![
342 "status".to_string(),
343 "--porcelain=v1".to_string(),
344 "--untracked-files=all".to_string(),
345 ],
346 "Git status --porcelain=v1 failed".to_string(),
347 )
348 .await
349}
350
351pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
366 run_git_command(
367 repo_path,
368 vec![
369 "status".to_string(),
370 "--porcelain=v1".to_string(),
371 "--untracked-files=no".to_string(),
372 ],
373 "Git tracked status --porcelain=v1 failed".to_string(),
374 )
375 .await
376}
377
378pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
394 spawn_blocking(move || {
395 let pull_arguments = pull_rebase_arguments(&repo_path)
396 .unwrap_or_else(|_| vec!["pull".to_string(), "--rebase".to_string()]);
397 let pull_argument_refs: Vec<&str> = pull_arguments.iter().map(String::as_str).collect();
398 let output = run_git_command_with_index_lock_retry(
399 &repo_path,
400 &pull_argument_refs,
401 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
402 )?;
403
404 if output.status.success() {
405 return Ok(PullRebaseResult::Completed);
406 }
407
408 let detail = command_output_detail(&output.stdout, &output.stderr);
409 if is_rebase_conflict(&detail) {
410 return Ok(PullRebaseResult::Conflict { detail });
411 }
412
413 Err(GitError::CommandFailed {
414 command: "git pull --rebase".to_string(),
415 stderr: detail,
416 })
417 })
418 .await?
419}
420
421fn pull_rebase_arguments(repo_path: &Path) -> Result<Vec<String>, GitError> {
426 let upstream_reference = primary_upstream_reference(repo_path)?;
427
428 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
429 return Ok(vec![
430 "pull".to_string(),
431 "--rebase".to_string(),
432 remote_name.to_string(),
433 branch_name.to_string(),
434 ]);
435 }
436
437 let remote_name = current_branch_remote_name(repo_path)?;
438
439 Ok(vec![
440 "pull".to_string(),
441 "--rebase".to_string(),
442 remote_name,
443 upstream_reference,
444 ])
445}
446
447fn primary_upstream_reference(repo_path: &Path) -> Result<String, GitError> {
453 let upstream_reference = upstream_reference_name(repo_path)?;
454 let Some(primary_reference) = upstream_reference
455 .lines()
456 .map(str::trim)
457 .find(|line| !line.is_empty())
458 else {
459 return Err(GitError::OutputParse(
460 "Failed to resolve upstream branch: empty output".to_string(),
461 ));
462 };
463
464 Ok(primary_reference.to_string())
465}
466
467fn upstream_reference_name(repo_path: &Path) -> Result<String, GitError> {
469 let upstream_reference = run_git_command_sync(
470 repo_path,
471 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
472 "Failed to resolve upstream branch",
473 )?;
474 let upstream_reference = upstream_reference.trim().to_string();
475 if upstream_reference.is_empty() {
476 return Err(GitError::OutputParse(
477 "Failed to resolve upstream branch: empty output".to_string(),
478 ));
479 }
480
481 Ok(upstream_reference)
482}
483
484fn current_branch_remote_name(repo_path: &Path) -> Result<String, GitError> {
489 let current_branch_name = current_branch_name(repo_path)?;
490 let remote_config_key = format!("branch.{current_branch_name}.remote");
491 let remote_name = run_git_command_sync(
492 repo_path,
493 &["config", "--get", &remote_config_key],
494 &format!("Failed to resolve current branch remote `{remote_config_key}`"),
495 )?;
496 let remote_name = remote_name.trim().to_string();
497 if remote_name.is_empty() {
498 return Err(GitError::OutputParse(format!(
499 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
500 )));
501 }
502
503 Ok(remote_name)
504}
505
506fn current_branch_name(repo_path: &Path) -> Result<String, GitError> {
508 let branch_name = run_git_command_sync(
509 repo_path,
510 &["rev-parse", "--abbrev-ref", "HEAD"],
511 "Failed to resolve current branch name",
512 )?;
513 let branch_name = branch_name.trim().to_string();
514 if branch_name.is_empty() {
515 return Err(GitError::OutputParse(
516 "Failed to resolve current branch name: empty output".to_string(),
517 ));
518 }
519
520 if branch_name == "HEAD" {
521 return Err(GitError::OutputParse(
522 "Failed to resolve current branch name: detached HEAD".to_string(),
523 ));
524 }
525
526 Ok(branch_name)
527}
528
529pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
546 spawn_blocking(move || -> Result<String, GitError> {
547 let push_output = run_git_command_output_sync(&repo_path, &["push", "--force-with-lease"])?;
548
549 if push_output.status.success() {
550 return primary_upstream_reference(&repo_path);
551 }
552
553 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
554 if !is_no_upstream_error(&push_detail) {
555 return Err(GitError::CommandFailed {
556 command: "git push".to_string(),
557 stderr: push_detail,
558 });
559 }
560
561 run_git_command_sync(
562 &repo_path,
563 &[
564 "push",
565 "--force-with-lease",
566 "--set-upstream",
567 "origin",
568 "HEAD",
569 ],
570 "Git push failed",
571 )?;
572
573 primary_upstream_reference(&repo_path)
574 })
575 .await?
576}
577
578pub(crate) async fn remote_branch_exists(
591 repo_path: PathBuf,
592 remote_branch_name: String,
593) -> Result<bool, GitError> {
594 spawn_blocking(move || -> Result<bool, GitError> {
595 let remote_name =
596 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
597 let output = run_git_command_output_sync(
598 &repo_path,
599 &["ls-remote", "--heads", &remote_name, &remote_branch_name],
600 )?;
601
602 if !output.status.success() {
603 let detail = command_output_detail(&output.stdout, &output.stderr);
604
605 return Err(GitError::CommandFailed {
606 command: "git ls-remote".to_string(),
607 stderr: detail,
608 });
609 }
610
611 let stdout = String::from_utf8_lossy(&output.stdout);
612
613 Ok(!stdout.trim().is_empty())
614 })
615 .await?
616}
617
618pub(crate) async fn push_current_branch_to_remote_branch(
635 repo_path: PathBuf,
636 remote_branch_name: String,
637) -> Result<String, GitError> {
638 spawn_blocking(move || -> Result<String, GitError> {
639 let remote_name =
640 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
641 let push_refspec = format!("HEAD:{remote_branch_name}");
642
643 run_git_command_sync(
644 &repo_path,
645 &[
646 "push",
647 "--force-with-lease",
648 "--set-upstream",
649 &remote_name,
650 &push_refspec,
651 ],
652 "Git push failed",
653 )?;
654
655 Ok(format!("{remote_name}/{remote_branch_name}"))
656 })
657 .await?
658}
659
660pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
672 spawn_blocking(move || primary_upstream_reference(&repo_path)).await?
673}
674
675pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
686 run_git_command(
687 repo_path,
688 vec!["fetch".to_string()],
689 "Git fetch failed".to_string(),
690 )
691 .await?;
692
693 Ok(())
694}
695
696pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
708 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
709}
710
711pub(crate) async fn get_ref_ahead_behind(
721 repo_path: PathBuf,
722 left_ref: String,
723 right_ref: String,
724) -> Result<(u32, u32), GitError> {
725 let rev_list_output = run_git_command(
726 repo_path,
727 vec![
728 "rev-list".to_string(),
729 "--left-right".to_string(),
730 "--count".to_string(),
731 format!("{left_ref}...{right_ref}"),
732 ],
733 "Git rev-list failed".to_string(),
734 )
735 .await?;
736
737 parse_ahead_behind_counts(&rev_list_output)
738}
739
740fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
743 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
744 if parts.len() >= 2 {
745 let ahead = parts[0].parse().unwrap_or(0);
746 let behind = parts[1].parse().unwrap_or(0);
747
748 return Ok((ahead, behind));
749 }
750
751 Err(GitError::OutputParse(
752 "Unexpected output format from git rev-list".to_string(),
753 ))
754}
755
756pub(crate) async fn branch_tracking_statuses(
765 repo_path: PathBuf,
766) -> Result<BranchTrackingMap, GitError> {
767 let git_output = run_git_command(
768 repo_path,
769 vec![
770 "for-each-ref".to_string(),
771 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
772 "refs/heads".to_string(),
773 ],
774 "Git for-each-ref failed".to_string(),
775 )
776 .await?;
777
778 Ok(parse_branch_tracking_statuses(&git_output))
779}
780
781pub(crate) async fn list_upstream_commit_titles(
792 repo_path: PathBuf,
793) -> Result<Vec<String>, GitError> {
794 let git_output = run_git_command(
795 repo_path,
796 vec![
797 "log".to_string(),
798 "--reverse".to_string(),
799 "--pretty=%s".to_string(),
800 "HEAD..@{u}".to_string(),
801 ],
802 "Git log failed".to_string(),
803 )
804 .await?;
805
806 Ok(parse_commit_titles(&git_output))
807}
808
809pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
820 let git_output = run_git_command(
821 repo_path,
822 vec![
823 "log".to_string(),
824 "--reverse".to_string(),
825 "--pretty=%s".to_string(),
826 "@{u}..HEAD".to_string(),
827 ],
828 "Git log failed".to_string(),
829 )
830 .await?;
831
832 Ok(parse_commit_titles(&git_output))
833}
834
835pub(crate) async fn has_commits_since(
841 repo_path: PathBuf,
842 base_branch: String,
843) -> Result<bool, GitError> {
844 spawn_blocking(move || -> Result<bool, GitError> {
845 let rev_list_output = run_git_command_sync(
846 &repo_path,
847 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
848 "Failed to count commits since base branch",
849 )?;
850 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
851 GitError::OutputParse(format!(
852 "Failed to parse commit count since base branch `{base_branch}`: {error}"
853 ))
854 })?;
855
856 Ok(commit_count > 0)
857 })
858 .await?
859}
860
861fn parse_commit_titles(output: &str) -> Vec<String> {
863 output
864 .lines()
865 .map(str::trim)
866 .filter(|title| !title.is_empty())
867 .map(ToString::to_string)
868 .collect()
869}
870
871fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
873 let mut branch_tracking_statuses = HashMap::new();
874
875 for line in output
876 .lines()
877 .map(str::trim)
878 .filter(|line| !line.is_empty())
879 {
880 let mut parts = line.splitn(3, '\t');
881 let Some(branch_name) = parts
882 .next()
883 .map(str::trim)
884 .filter(|value| !value.is_empty())
885 else {
886 continue;
887 };
888 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
889 let track = parts.next().map(str::trim).unwrap_or_default();
890
891 let status = if upstream_ref.is_empty() {
892 None
893 } else {
894 parse_branch_tracking_counts(track)
895 };
896 branch_tracking_statuses.insert(branch_name.to_string(), status);
897 }
898
899 branch_tracking_statuses
900}
901
902fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
904 let normalized_track = track.trim();
905 if normalized_track.is_empty() || normalized_track == "gone" {
906 return None;
907 }
908
909 let mut ahead = 0;
910 let mut behind = 0;
911
912 for part in normalized_track.split(',').map(str::trim) {
913 if let Some(count) = part.strip_prefix("ahead ") {
914 ahead = count.parse().ok()?;
915 } else if let Some(count) = part.strip_prefix("behind ") {
916 behind = count.parse().ok()?;
917 }
918 }
919
920 Some((ahead, behind))
921}
922
923fn resolve_diff_target(
929 repo_path: &Path,
930 base_branch: &str,
931 merge_base: &str,
932) -> Result<String, GitError> {
933 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
934 if !cherry_output.status.success() {
935 return Ok(merge_base.to_string());
936 }
937
938 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
939 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
940 return Ok(merge_base.to_string());
941 };
942
943 Ok(last_leading_applied_commit.to_string())
944}
945
946fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
953 let mut last_applied_commit = None;
954
955 for line in cherry_output.lines() {
956 let trimmed_line = line.trim();
957 if trimmed_line.is_empty() {
958 continue;
959 }
960
961 let mut parts = trimmed_line.split_whitespace();
962 let marker = parts.next()?;
963 let commit_hash = parts.next()?;
964
965 if marker == "-" {
966 last_applied_commit = Some(commit_hash);
967
968 continue;
969 }
970
971 if marker == "+" {
972 break;
973 }
974
975 break;
976 }
977
978 last_applied_commit
979}
980
981async fn commit_all_with_retry(
989 repo_path: PathBuf,
990 commit_message: String,
991 message_strategy: SingleCommitMessageStrategy,
992 no_verify: bool,
993 amend_existing_commit: bool,
994) -> Result<(), GitError> {
995 spawn_blocking(move || {
996 stage_all_sync(&repo_path)?;
997
998 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
999 let output = run_commit_command(
1000 &repo_path,
1001 &commit_message,
1002 message_strategy,
1003 no_verify,
1004 amend_existing_commit,
1005 )?;
1006
1007 if output.status.success() {
1008 return Ok(());
1009 }
1010
1011 let stderr = String::from_utf8_lossy(&output.stderr);
1012 let stdout = String::from_utf8_lossy(&output.stdout);
1013 if is_nothing_to_commit_output(&stdout, &stderr) {
1014 return Err(nothing_to_commit_error());
1015 }
1016
1017 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1018 reset_empty_amend_sync(&repo_path)?;
1019
1020 return Err(nothing_to_commit_error());
1021 }
1022
1023 if is_hook_modified_error(&stdout, &stderr) {
1024 stage_all_sync(&repo_path)?;
1025
1026 continue;
1027 }
1028
1029 let detail = command_output_detail(&output.stdout, &output.stderr);
1030
1031 return Err(GitError::CommandFailed {
1032 command: "git commit".to_string(),
1033 stderr: detail,
1034 });
1035 }
1036
1037 Err(GitError::CommandFailed {
1038 command: "git commit".to_string(),
1039 stderr: format!(
1040 "Failed to commit: commit hooks kept modifying files after \
1041 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1042 ),
1043 })
1044 })
1045 .await?
1046}
1047
1048fn nothing_to_commit_error() -> GitError {
1050 GitError::CommandFailed {
1051 command: "git commit".to_string(),
1052 stderr: "Nothing to commit: no changes detected".to_string(),
1053 }
1054}
1055
1056fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1059 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1060
1061 combined.contains("nothing to commit")
1062}
1063
1064fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1067 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1068 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1069
1070 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1071}
1072
1073fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1076 run_git_command_sync(
1077 repo_path,
1078 &["reset", "HEAD^"],
1079 "Git reset after empty amend failed",
1080 )?;
1081
1082 Ok(())
1083}
1084
1085fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1089 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1090
1091 if !output.status.success() {
1092 let detail = command_output_detail(&output.stdout, &output.stderr);
1093
1094 return Err(GitError::CommandFailed {
1095 command: "git add -A".to_string(),
1096 stderr: format!("Failed to stage changes: {detail}"),
1097 });
1098 }
1099
1100 Ok(())
1101}
1102
1103fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1105 if !has_head_commit_sync(repo_path)? {
1106 return Ok(None);
1107 }
1108
1109 let output = run_git_command_sync(
1110 repo_path,
1111 &["log", "-1", "--pretty=%B"],
1112 "Failed to read HEAD commit message",
1113 )?;
1114
1115 Ok(Some(output.trim().to_string()))
1116}
1117
1118fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1120 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1121
1122 if output.status.success() {
1123 return Ok(true);
1124 }
1125
1126 let detail = command_output_detail(&output.stdout, &output.stderr);
1127 let normalized_detail = detail.to_ascii_lowercase();
1128 if normalized_detail.contains("needed a single revision")
1129 || normalized_detail.contains("unknown revision")
1130 || normalized_detail.contains("does not have any commits yet")
1131 {
1132 return Ok(false);
1133 }
1134
1135 Err(GitError::CommandFailed {
1136 command: "git rev-parse --verify HEAD".to_string(),
1137 stderr: detail,
1138 })
1139}
1140
1141fn run_commit_command(
1145 repo_path: &Path,
1146 commit_message: &str,
1147 message_strategy: SingleCommitMessageStrategy,
1148 no_verify: bool,
1149 amend_existing_commit: bool,
1150) -> Result<Output, GitError> {
1151 let mut args = vec!["commit"];
1152 if amend_existing_commit {
1153 args.push("--amend");
1154 match message_strategy {
1155 SingleCommitMessageStrategy::Replace => {
1156 args.push("-m");
1157 args.push(commit_message);
1158 }
1159 SingleCommitMessageStrategy::Reuse => {
1160 args.push("--no-edit");
1161 }
1162 }
1163 } else {
1164 args.push("-m");
1165 args.push(commit_message);
1166 }
1167
1168 if no_verify {
1169 args.push("--no-verify");
1170 }
1171
1172 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1173}
1174
1175fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1177 let combined = format!(
1178 "{stdout}
1179{stderr}"
1180 )
1181 .to_ascii_lowercase();
1182
1183 combined.contains("files were modified by this hook")
1184}
1185
1186pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1188 let normalized_detail = detail.to_ascii_lowercase();
1189
1190 normalized_detail.contains("has no upstream branch")
1191 || normalized_detail.contains("no upstream branch")
1192 || normalized_detail.contains("set-upstream")
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use std::fs;
1198 use std::path::Path;
1199 use std::process::{Command, Output};
1200
1201 use tempfile::tempdir;
1202
1203 use super::*;
1204
1205 fn run_git_command(repo_path: &Path, args: &[&str]) {
1207 let output = git_command_output(repo_path, args);
1208
1209 assert!(
1210 output.status.success(),
1211 "git command {:?} failed: {}",
1212 args,
1213 String::from_utf8_lossy(&output.stderr)
1214 );
1215 }
1216
1217 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1219 Command::new("git")
1220 .args(args)
1221 .current_dir(repo_path)
1222 .output()
1223 .expect("failed to run git command")
1224 }
1225
1226 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1228 let output = git_command_output(repo_path, args);
1229
1230 assert!(
1231 output.status.success(),
1232 "git command {:?} failed: {}",
1233 args,
1234 String::from_utf8_lossy(&output.stderr)
1235 );
1236
1237 String::from_utf8(output.stdout)
1238 .expect("git stdout should be valid utf-8")
1239 .trim()
1240 .to_string()
1241 }
1242
1243 fn setup_test_git_repo(repo_path: &Path) {
1245 run_git_command(repo_path, &["init", "-b", "main"]);
1246 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1247 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1248 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1249 run_git_command(repo_path, &["add", "README.md"]);
1250 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1251 }
1252
1253 #[test]
1254 fn current_branch_name_returns_error_for_detached_head() {
1255 let temp_dir = tempdir().expect("failed to create temp dir");
1257 setup_test_git_repo(temp_dir.path());
1258 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1259
1260 let result = current_branch_name(temp_dir.path());
1262
1263 let error = result.expect_err("detached HEAD should fail");
1265 assert!(error.to_string().contains("detached HEAD"));
1266 }
1267
1268 #[test]
1269 fn primary_upstream_reference_uses_first_non_empty_line() {
1270 let temp_dir = tempdir().expect("failed to create temp dir");
1272 let remote_dir = tempdir().expect("failed to create remote temp dir");
1273 setup_test_git_repo(temp_dir.path());
1274 run_git_command(remote_dir.path(), &["init", "--bare"]);
1275 let remote_path = remote_dir.path().to_string_lossy().to_string();
1276 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1277 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1278 run_git_command(
1279 temp_dir.path(),
1280 &[
1281 "config",
1282 "--replace-all",
1283 "branch.main.merge",
1284 "refs/heads/main",
1285 ],
1286 );
1287 run_git_command(
1288 temp_dir.path(),
1289 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1290 );
1291
1292 let upstream_reference =
1294 primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1295
1296 assert_eq!(upstream_reference, "origin/main");
1298 }
1299
1300 #[test]
1301 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1302 let output = "\
1304main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1305 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1306
1307 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1309
1310 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1312 assert_eq!(
1313 branch_tracking_statuses.get("wt/1234abcd"),
1314 Some(&Some((3, 1)))
1315 );
1316 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1317 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1318 }
1319
1320 #[tokio::test]
1321 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1322 let temp_dir = tempdir().expect("failed to create temp dir");
1324 let remote_dir = tempdir().expect("failed to create remote temp dir");
1325 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1326 let contributor_clone_path = contributor_dir.path().join("clone");
1327 setup_test_git_repo(temp_dir.path());
1328 run_git_command(remote_dir.path(), &["init", "--bare"]);
1329 let remote_path = remote_dir.path().to_string_lossy().to_string();
1330 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1331 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1332 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1333 fs::write(temp_dir.path().join("README.md"), "local change\n")
1334 .expect("failed to write local change");
1335 run_git_command(temp_dir.path(), &["add", "README.md"]);
1336 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1337 run_git_command(
1338 contributor_dir.path(),
1339 &["clone", &remote_path, &contributor_clone_path_text],
1340 );
1341 run_git_command(
1342 &contributor_clone_path,
1343 &["config", "user.name", "Contributor User"],
1344 );
1345 run_git_command(
1346 &contributor_clone_path,
1347 &["config", "user.email", "contributor@example.com"],
1348 );
1349 run_git_command(
1350 &contributor_clone_path,
1351 &["checkout", "-B", "main", "origin/main"],
1352 );
1353 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1354 .expect("failed to write remote change");
1355 run_git_command(&contributor_clone_path, &["add", "README.md"]);
1356 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1357 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1358
1359 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1361
1362 assert!(matches!(
1364 result,
1365 Ok(PullRebaseResult::Conflict { ref detail })
1366 if {
1367 let normalized_detail = detail.to_ascii_lowercase();
1368
1369 (normalized_detail.contains("conflict")
1370 || normalized_detail.contains("could not apply"))
1371 && !detail.is_empty()
1372 }
1373 ));
1374 }
1375
1376 #[tokio::test]
1377 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1378 let temp_dir = tempdir().expect("failed to create temp dir");
1380 let remote_dir = tempdir().expect("failed to create remote temp dir");
1381 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1382 let contributor_clone_path = contributor_dir.path().join("clone");
1383 setup_test_git_repo(temp_dir.path());
1384 run_git_command(remote_dir.path(), &["init", "--bare"]);
1385 let remote_path = remote_dir.path().to_string_lossy().to_string();
1386 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1387 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1388 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1389 run_git_command(
1390 contributor_dir.path(),
1391 &["clone", &remote_path, &contributor_clone_path_text],
1392 );
1393 run_git_command(
1394 &contributor_clone_path,
1395 &["config", "user.name", "Contributor User"],
1396 );
1397 run_git_command(
1398 &contributor_clone_path,
1399 &["config", "user.email", "contributor@example.com"],
1400 );
1401 run_git_command(
1402 &contributor_clone_path,
1403 &["checkout", "-B", "main", "origin/main"],
1404 );
1405 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1406 .expect("failed to write remote file");
1407 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1408 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1409 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1410 fs::write(temp_dir.path().join("local.txt"), "local change")
1411 .expect("failed to write local file");
1412 run_git_command(temp_dir.path(), &["add", "local.txt"]);
1413 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1414
1415 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
1417
1418 let error = result
1420 .expect_err("non-fast-forward push should fail")
1421 .to_string();
1422 assert!(error.contains("git push"));
1423 assert!(
1424 error.contains("stale info")
1425 || error.contains("rejected")
1426 || error.contains("fetch first")
1427 );
1428 }
1429
1430 #[tokio::test]
1431 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
1432 let temp_dir = tempdir().expect("failed to create temp dir");
1434 let remote_dir = tempdir().expect("failed to create remote temp dir");
1435 setup_test_git_repo(temp_dir.path());
1436 run_git_command(remote_dir.path(), &["init", "--bare"]);
1437 let remote_path = remote_dir.path().to_string_lossy().to_string();
1438 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1439 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1440 fs::write(
1441 temp_dir.path().join("README.md"),
1442 "first published version\n",
1443 )
1444 .expect("failed to write first version");
1445 run_git_command(temp_dir.path(), &["add", "README.md"]);
1446 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
1447 push_current_branch(temp_dir.path().to_path_buf())
1448 .await
1449 .expect("initial push should succeed");
1450 fs::write(
1451 temp_dir.path().join("README.md"),
1452 "rewritten published version\n",
1453 )
1454 .expect("failed to rewrite published version");
1455 run_git_command(temp_dir.path(), &["add", "README.md"]);
1456 run_git_command(
1457 temp_dir.path(),
1458 &["commit", "--amend", "-m", "Rewrite published branch change"],
1459 );
1460
1461 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
1463 .await
1464 .expect("force-with-lease push should update rewritten history");
1465 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
1466 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
1467
1468 assert_eq!(upstream_reference, "origin/main");
1470 assert_eq!(local_head, remote_head);
1471 }
1472
1473 #[tokio::test]
1474 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
1475 let temp_dir = tempdir().expect("failed to create temp dir");
1477 let remote_dir = tempdir().expect("failed to create remote temp dir");
1478 setup_test_git_repo(temp_dir.path());
1479 run_git_command(remote_dir.path(), &["init", "--bare"]);
1480 let remote_path = remote_dir.path().to_string_lossy().to_string();
1481 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1482
1483 let upstream_reference = push_current_branch_to_remote_branch(
1485 temp_dir.path().to_path_buf(),
1486 "review/custom-branch".to_string(),
1487 )
1488 .await
1489 .expect("failed to push current branch to custom remote branch");
1490
1491 assert_eq!(upstream_reference, "origin/review/custom-branch");
1493 }
1494
1495 #[tokio::test]
1496 async fn current_upstream_reference_returns_origin_main() {
1497 let temp_dir = tempdir().expect("failed to create temp dir");
1499 let remote_dir = tempdir().expect("failed to create remote temp dir");
1500 setup_test_git_repo(temp_dir.path());
1501 run_git_command(remote_dir.path(), &["init", "--bare"]);
1502 let remote_path = remote_dir.path().to_string_lossy().to_string();
1503 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1504 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1505
1506 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
1508 .await
1509 .expect("failed to resolve upstream reference");
1510
1511 assert_eq!(upstream_reference, "origin/main");
1513 }
1514
1515 #[tokio::test]
1516 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
1517 let temp_dir = tempdir().expect("failed to create temp dir");
1519 setup_test_git_repo(temp_dir.path());
1520 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1521 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1522 .expect("failed to write session file");
1523 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1524 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1525 run_git_command(temp_dir.path(), &["checkout", "main"]);
1526 fs::write(temp_dir.path().join("main.txt"), "main change\n")
1527 .expect("failed to write main file");
1528 run_git_command(temp_dir.path(), &["add", "main.txt"]);
1529 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
1530
1531 let status = get_ref_ahead_behind(
1533 temp_dir.path().to_path_buf(),
1534 "wt/1234abcd".to_string(),
1535 "main".to_string(),
1536 )
1537 .await
1538 .expect("failed to compare branch refs");
1539
1540 assert_eq!(status, (1, 1));
1542 }
1543
1544 #[tokio::test]
1545 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
1546 let temp_dir = tempdir().expect("failed to create temp dir");
1548 let remote_dir = tempdir().expect("failed to create remote temp dir");
1549 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1550 let contributor_clone_path = contributor_dir.path().join("clone");
1551 setup_test_git_repo(temp_dir.path());
1552 run_git_command(remote_dir.path(), &["init", "--bare"]);
1553 let remote_path = remote_dir.path().to_string_lossy().to_string();
1554 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1555 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1556 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1557 run_git_command(
1558 contributor_dir.path(),
1559 &["clone", &remote_path, &contributor_clone_path_text],
1560 );
1561 run_git_command(
1562 &contributor_clone_path,
1563 &["config", "user.name", "Contributor User"],
1564 );
1565 run_git_command(
1566 &contributor_clone_path,
1567 &["config", "user.email", "contributor@example.com"],
1568 );
1569 run_git_command(
1570 &contributor_clone_path,
1571 &["checkout", "-B", "main", "origin/main"],
1572 );
1573 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1574 .expect("failed to write remote file");
1575 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1576 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1577 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1578 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1579 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1580 .expect("failed to write session file");
1581 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1582 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1583 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
1584 fs::write(
1585 temp_dir.path().join("session.txt"),
1586 "session change\nmore local\n",
1587 )
1588 .expect("failed to extend session file");
1589 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1590 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
1591 run_git_command(temp_dir.path(), &["fetch"]);
1592
1593 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
1595 .await
1596 .expect("failed to read branch tracking statuses");
1597
1598 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
1600 assert_eq!(
1601 branch_tracking_statuses.get("wt/1234abcd"),
1602 Some(&Some((1, 0)))
1603 );
1604 }
1605
1606 #[tokio::test]
1607 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
1612 let temp_dir = tempdir().expect("failed to create temp dir");
1614 setup_test_git_repo(temp_dir.path());
1615 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
1616 fs::write(temp_dir.path().join("session.txt"), "session work\n")
1617 .expect("failed to write session file");
1618 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1619 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
1620 fs::remove_file(temp_dir.path().join("session.txt"))
1621 .expect("failed to remove session file");
1622
1623 let result = commit_all_preserving_single_commit(
1627 temp_dir.path().to_path_buf(),
1628 "main".to_string(),
1629 "Session commit".to_string(),
1630 SingleCommitMessageStrategy::Replace,
1631 true,
1632 )
1633 .await;
1634
1635 let error = result.expect_err("amend-would-be-empty should fail");
1637 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
1638 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
1639 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
1640
1641 assert!(
1642 error.to_string().contains("Nothing to commit"),
1643 "expected 'Nothing to commit' sentinel but got: {error}"
1644 );
1645 assert_eq!(commit_count, "1");
1646 assert_eq!(head_message, "Initial commit");
1647 assert!(status.is_empty());
1648 }
1649}