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 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 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 async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
117 spawn_blocking(move || stage_all_sync(&repo_path)).await?
118}
119
120pub 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 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 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 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 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 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 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 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 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 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 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 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 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 async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
672 spawn_blocking(move || primary_upstream_reference(&repo_path)).await?
673}
674
675pub 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 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 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 async fn branch_tracking_statuses(repo_path: PathBuf) -> Result<BranchTrackingMap, GitError> {
765 let git_output = run_git_command(
766 repo_path,
767 vec![
768 "for-each-ref".to_string(),
769 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
770 "refs/heads".to_string(),
771 ],
772 "Git for-each-ref failed".to_string(),
773 )
774 .await?;
775
776 Ok(parse_branch_tracking_statuses(&git_output))
777}
778
779pub async fn list_upstream_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
790 let git_output = run_git_command(
791 repo_path,
792 vec![
793 "log".to_string(),
794 "--reverse".to_string(),
795 "--pretty=%s".to_string(),
796 "HEAD..@{u}".to_string(),
797 ],
798 "Git log failed".to_string(),
799 )
800 .await?;
801
802 Ok(parse_commit_titles(&git_output))
803}
804
805pub async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
816 let git_output = run_git_command(
817 repo_path,
818 vec![
819 "log".to_string(),
820 "--reverse".to_string(),
821 "--pretty=%s".to_string(),
822 "@{u}..HEAD".to_string(),
823 ],
824 "Git log failed".to_string(),
825 )
826 .await?;
827
828 Ok(parse_commit_titles(&git_output))
829}
830
831pub async fn has_commits_since(repo_path: PathBuf, base_branch: String) -> Result<bool, GitError> {
837 spawn_blocking(move || -> Result<bool, GitError> {
838 let rev_list_output = run_git_command_sync(
839 &repo_path,
840 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
841 "Failed to count commits since base branch",
842 )?;
843 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
844 GitError::OutputParse(format!(
845 "Failed to parse commit count since base branch `{base_branch}`: {error}"
846 ))
847 })?;
848
849 Ok(commit_count > 0)
850 })
851 .await?
852}
853
854fn parse_commit_titles(output: &str) -> Vec<String> {
856 output
857 .lines()
858 .map(str::trim)
859 .filter(|title| !title.is_empty())
860 .map(ToString::to_string)
861 .collect()
862}
863
864fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
866 let mut branch_tracking_statuses = HashMap::new();
867
868 for line in output
869 .lines()
870 .map(str::trim)
871 .filter(|line| !line.is_empty())
872 {
873 let mut parts = line.splitn(3, '\t');
874 let Some(branch_name) = parts
875 .next()
876 .map(str::trim)
877 .filter(|value| !value.is_empty())
878 else {
879 continue;
880 };
881 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
882 let track = parts.next().map(str::trim).unwrap_or_default();
883
884 let status = if upstream_ref.is_empty() {
885 None
886 } else {
887 parse_branch_tracking_counts(track)
888 };
889 branch_tracking_statuses.insert(branch_name.to_string(), status);
890 }
891
892 branch_tracking_statuses
893}
894
895fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
897 let normalized_track = track.trim();
898 if normalized_track.is_empty() || normalized_track == "gone" {
899 return None;
900 }
901
902 let mut ahead = 0;
903 let mut behind = 0;
904
905 for part in normalized_track.split(',').map(str::trim) {
906 if let Some(count) = part.strip_prefix("ahead ") {
907 ahead = count.parse().ok()?;
908 } else if let Some(count) = part.strip_prefix("behind ") {
909 behind = count.parse().ok()?;
910 }
911 }
912
913 Some((ahead, behind))
914}
915
916fn resolve_diff_target(
922 repo_path: &Path,
923 base_branch: &str,
924 merge_base: &str,
925) -> Result<String, GitError> {
926 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
927 if !cherry_output.status.success() {
928 return Ok(merge_base.to_string());
929 }
930
931 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
932 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
933 return Ok(merge_base.to_string());
934 };
935
936 Ok(last_leading_applied_commit.to_string())
937}
938
939fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
946 let mut last_applied_commit = None;
947
948 for line in cherry_output.lines() {
949 let trimmed_line = line.trim();
950 if trimmed_line.is_empty() {
951 continue;
952 }
953
954 let mut parts = trimmed_line.split_whitespace();
955 let marker = parts.next()?;
956 let commit_hash = parts.next()?;
957
958 if marker == "-" {
959 last_applied_commit = Some(commit_hash);
960
961 continue;
962 }
963
964 if marker == "+" {
965 break;
966 }
967
968 break;
969 }
970
971 last_applied_commit
972}
973
974async fn commit_all_with_retry(
982 repo_path: PathBuf,
983 commit_message: String,
984 message_strategy: SingleCommitMessageStrategy,
985 no_verify: bool,
986 amend_existing_commit: bool,
987) -> Result<(), GitError> {
988 spawn_blocking(move || {
989 stage_all_sync(&repo_path)?;
990
991 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
992 let output = run_commit_command(
993 &repo_path,
994 &commit_message,
995 message_strategy,
996 no_verify,
997 amend_existing_commit,
998 )?;
999
1000 if output.status.success() {
1001 return Ok(());
1002 }
1003
1004 let stderr = String::from_utf8_lossy(&output.stderr);
1005 let stdout = String::from_utf8_lossy(&output.stdout);
1006 if is_nothing_to_commit_output(&stdout, &stderr) {
1007 return Err(nothing_to_commit_error());
1008 }
1009
1010 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1011 reset_empty_amend_sync(&repo_path)?;
1012
1013 return Err(nothing_to_commit_error());
1014 }
1015
1016 if is_hook_modified_error(&stdout, &stderr) {
1017 stage_all_sync(&repo_path)?;
1018
1019 continue;
1020 }
1021
1022 let detail = command_output_detail(&output.stdout, &output.stderr);
1023
1024 return Err(GitError::CommandFailed {
1025 command: "git commit".to_string(),
1026 stderr: detail,
1027 });
1028 }
1029
1030 Err(GitError::CommandFailed {
1031 command: "git commit".to_string(),
1032 stderr: format!(
1033 "Failed to commit: commit hooks kept modifying files after \
1034 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1035 ),
1036 })
1037 })
1038 .await?
1039}
1040
1041fn nothing_to_commit_error() -> GitError {
1043 GitError::CommandFailed {
1044 command: "git commit".to_string(),
1045 stderr: "Nothing to commit: no changes detected".to_string(),
1046 }
1047}
1048
1049fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1052 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1053
1054 combined.contains("nothing to commit")
1055}
1056
1057fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1060 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1061 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1062
1063 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1064}
1065
1066fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1069 run_git_command_sync(
1070 repo_path,
1071 &["reset", "HEAD^"],
1072 "Git reset after empty amend failed",
1073 )?;
1074
1075 Ok(())
1076}
1077
1078fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1082 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1083
1084 if !output.status.success() {
1085 let detail = command_output_detail(&output.stdout, &output.stderr);
1086
1087 return Err(GitError::CommandFailed {
1088 command: "git add -A".to_string(),
1089 stderr: format!("Failed to stage changes: {detail}"),
1090 });
1091 }
1092
1093 Ok(())
1094}
1095
1096fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1098 if !has_head_commit_sync(repo_path)? {
1099 return Ok(None);
1100 }
1101
1102 let output = run_git_command_sync(
1103 repo_path,
1104 &["log", "-1", "--pretty=%B"],
1105 "Failed to read HEAD commit message",
1106 )?;
1107
1108 Ok(Some(output.trim().to_string()))
1109}
1110
1111fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1113 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1114
1115 if output.status.success() {
1116 return Ok(true);
1117 }
1118
1119 let detail = command_output_detail(&output.stdout, &output.stderr);
1120 let normalized_detail = detail.to_ascii_lowercase();
1121 if normalized_detail.contains("needed a single revision")
1122 || normalized_detail.contains("unknown revision")
1123 || normalized_detail.contains("does not have any commits yet")
1124 {
1125 return Ok(false);
1126 }
1127
1128 Err(GitError::CommandFailed {
1129 command: "git rev-parse --verify HEAD".to_string(),
1130 stderr: detail,
1131 })
1132}
1133
1134fn run_commit_command(
1138 repo_path: &Path,
1139 commit_message: &str,
1140 message_strategy: SingleCommitMessageStrategy,
1141 no_verify: bool,
1142 amend_existing_commit: bool,
1143) -> Result<Output, GitError> {
1144 let mut args = vec!["commit"];
1145 if amend_existing_commit {
1146 args.push("--amend");
1147 match message_strategy {
1148 SingleCommitMessageStrategy::Replace => {
1149 args.push("-m");
1150 args.push(commit_message);
1151 }
1152 SingleCommitMessageStrategy::Reuse => {
1153 args.push("--no-edit");
1154 }
1155 }
1156 } else {
1157 args.push("-m");
1158 args.push(commit_message);
1159 }
1160
1161 if no_verify {
1162 args.push("--no-verify");
1163 }
1164
1165 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1166}
1167
1168fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1170 let combined = format!(
1171 "{stdout}
1172{stderr}"
1173 )
1174 .to_ascii_lowercase();
1175
1176 combined.contains("files were modified by this hook")
1177}
1178
1179pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1181 let normalized_detail = detail.to_ascii_lowercase();
1182
1183 normalized_detail.contains("has no upstream branch")
1184 || normalized_detail.contains("no upstream branch")
1185 || normalized_detail.contains("set-upstream")
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use std::fs;
1191 use std::path::Path;
1192 use std::process::{Command, Output};
1193
1194 use tempfile::tempdir;
1195
1196 use super::*;
1197
1198 fn run_git_command(repo_path: &Path, args: &[&str]) {
1200 let output = git_command_output(repo_path, args);
1201
1202 assert!(
1203 output.status.success(),
1204 "git command {:?} failed: {}",
1205 args,
1206 String::from_utf8_lossy(&output.stderr)
1207 );
1208 }
1209
1210 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1212 Command::new("git")
1213 .args(args)
1214 .current_dir(repo_path)
1215 .output()
1216 .expect("failed to run git command")
1217 }
1218
1219 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1221 let output = git_command_output(repo_path, args);
1222
1223 assert!(
1224 output.status.success(),
1225 "git command {:?} failed: {}",
1226 args,
1227 String::from_utf8_lossy(&output.stderr)
1228 );
1229
1230 String::from_utf8(output.stdout)
1231 .expect("git stdout should be valid utf-8")
1232 .trim()
1233 .to_string()
1234 }
1235
1236 fn setup_test_git_repo(repo_path: &Path) {
1238 run_git_command(repo_path, &["init", "-b", "main"]);
1239 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1240 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1241 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1242 run_git_command(repo_path, &["add", "README.md"]);
1243 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1244 }
1245
1246 #[test]
1247 fn current_branch_name_returns_error_for_detached_head() {
1248 let temp_dir = tempdir().expect("failed to create temp dir");
1250 setup_test_git_repo(temp_dir.path());
1251 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1252
1253 let result = current_branch_name(temp_dir.path());
1255
1256 let error = result.expect_err("detached HEAD should fail");
1258 assert!(error.to_string().contains("detached HEAD"));
1259 }
1260
1261 #[test]
1262 fn primary_upstream_reference_uses_first_non_empty_line() {
1263 let temp_dir = tempdir().expect("failed to create temp dir");
1265 let remote_dir = tempdir().expect("failed to create remote temp dir");
1266 setup_test_git_repo(temp_dir.path());
1267 run_git_command(remote_dir.path(), &["init", "--bare"]);
1268 let remote_path = remote_dir.path().to_string_lossy().to_string();
1269 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1270 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1271 run_git_command(
1272 temp_dir.path(),
1273 &[
1274 "config",
1275 "--replace-all",
1276 "branch.main.merge",
1277 "refs/heads/main",
1278 ],
1279 );
1280 run_git_command(
1281 temp_dir.path(),
1282 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1283 );
1284
1285 let upstream_reference =
1287 primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1288
1289 assert_eq!(upstream_reference, "origin/main");
1291 }
1292
1293 #[test]
1294 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1295 let output = "\
1297main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1298 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1299
1300 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1302
1303 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1305 assert_eq!(
1306 branch_tracking_statuses.get("wt/1234abcd"),
1307 Some(&Some((3, 1)))
1308 );
1309 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1310 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1311 }
1312
1313 #[tokio::test]
1314 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1315 let temp_dir = tempdir().expect("failed to create temp dir");
1317 let remote_dir = tempdir().expect("failed to create remote temp dir");
1318 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1319 let contributor_clone_path = contributor_dir.path().join("clone");
1320 setup_test_git_repo(temp_dir.path());
1321 run_git_command(remote_dir.path(), &["init", "--bare"]);
1322 let remote_path = remote_dir.path().to_string_lossy().to_string();
1323 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1324 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1325 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1326 fs::write(temp_dir.path().join("README.md"), "local change\n")
1327 .expect("failed to write local change");
1328 run_git_command(temp_dir.path(), &["add", "README.md"]);
1329 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1330 run_git_command(
1331 contributor_dir.path(),
1332 &["clone", &remote_path, &contributor_clone_path_text],
1333 );
1334 run_git_command(
1335 &contributor_clone_path,
1336 &["config", "user.name", "Contributor User"],
1337 );
1338 run_git_command(
1339 &contributor_clone_path,
1340 &["config", "user.email", "contributor@example.com"],
1341 );
1342 run_git_command(
1343 &contributor_clone_path,
1344 &["checkout", "-B", "main", "origin/main"],
1345 );
1346 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1347 .expect("failed to write remote change");
1348 run_git_command(&contributor_clone_path, &["add", "README.md"]);
1349 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1350 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1351
1352 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1354
1355 assert!(matches!(
1357 result,
1358 Ok(PullRebaseResult::Conflict { ref detail })
1359 if {
1360 let normalized_detail = detail.to_ascii_lowercase();
1361
1362 (normalized_detail.contains("conflict")
1363 || normalized_detail.contains("could not apply"))
1364 && !detail.is_empty()
1365 }
1366 ));
1367 }
1368
1369 #[tokio::test]
1370 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1371 let temp_dir = tempdir().expect("failed to create temp dir");
1373 let remote_dir = tempdir().expect("failed to create remote temp dir");
1374 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1375 let contributor_clone_path = contributor_dir.path().join("clone");
1376 setup_test_git_repo(temp_dir.path());
1377 run_git_command(remote_dir.path(), &["init", "--bare"]);
1378 let remote_path = remote_dir.path().to_string_lossy().to_string();
1379 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1380 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1381 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1382 run_git_command(
1383 contributor_dir.path(),
1384 &["clone", &remote_path, &contributor_clone_path_text],
1385 );
1386 run_git_command(
1387 &contributor_clone_path,
1388 &["config", "user.name", "Contributor User"],
1389 );
1390 run_git_command(
1391 &contributor_clone_path,
1392 &["config", "user.email", "contributor@example.com"],
1393 );
1394 run_git_command(
1395 &contributor_clone_path,
1396 &["checkout", "-B", "main", "origin/main"],
1397 );
1398 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1399 .expect("failed to write remote file");
1400 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1401 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1402 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1403 fs::write(temp_dir.path().join("local.txt"), "local change")
1404 .expect("failed to write local file");
1405 run_git_command(temp_dir.path(), &["add", "local.txt"]);
1406 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1407
1408 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
1410
1411 let error = result
1413 .expect_err("non-fast-forward push should fail")
1414 .to_string();
1415 assert!(error.contains("git push"));
1416 assert!(
1417 error.contains("stale info")
1418 || error.contains("rejected")
1419 || error.contains("fetch first")
1420 );
1421 }
1422
1423 #[tokio::test]
1424 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
1425 let temp_dir = tempdir().expect("failed to create temp dir");
1427 let remote_dir = tempdir().expect("failed to create remote temp dir");
1428 setup_test_git_repo(temp_dir.path());
1429 run_git_command(remote_dir.path(), &["init", "--bare"]);
1430 let remote_path = remote_dir.path().to_string_lossy().to_string();
1431 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1432 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1433 fs::write(
1434 temp_dir.path().join("README.md"),
1435 "first published version\n",
1436 )
1437 .expect("failed to write first version");
1438 run_git_command(temp_dir.path(), &["add", "README.md"]);
1439 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
1440 push_current_branch(temp_dir.path().to_path_buf())
1441 .await
1442 .expect("initial push should succeed");
1443 fs::write(
1444 temp_dir.path().join("README.md"),
1445 "rewritten published version\n",
1446 )
1447 .expect("failed to rewrite published version");
1448 run_git_command(temp_dir.path(), &["add", "README.md"]);
1449 run_git_command(
1450 temp_dir.path(),
1451 &["commit", "--amend", "-m", "Rewrite published branch change"],
1452 );
1453
1454 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
1456 .await
1457 .expect("force-with-lease push should update rewritten history");
1458 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
1459 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
1460
1461 assert_eq!(upstream_reference, "origin/main");
1463 assert_eq!(local_head, remote_head);
1464 }
1465
1466 #[tokio::test]
1467 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
1468 let temp_dir = tempdir().expect("failed to create temp dir");
1470 let remote_dir = tempdir().expect("failed to create remote temp dir");
1471 setup_test_git_repo(temp_dir.path());
1472 run_git_command(remote_dir.path(), &["init", "--bare"]);
1473 let remote_path = remote_dir.path().to_string_lossy().to_string();
1474 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1475
1476 let upstream_reference = push_current_branch_to_remote_branch(
1478 temp_dir.path().to_path_buf(),
1479 "review/custom-branch".to_string(),
1480 )
1481 .await
1482 .expect("failed to push current branch to custom remote branch");
1483
1484 assert_eq!(upstream_reference, "origin/review/custom-branch");
1486 }
1487
1488 #[tokio::test]
1489 async fn current_upstream_reference_returns_origin_main() {
1490 let temp_dir = tempdir().expect("failed to create temp dir");
1492 let remote_dir = tempdir().expect("failed to create remote temp dir");
1493 setup_test_git_repo(temp_dir.path());
1494 run_git_command(remote_dir.path(), &["init", "--bare"]);
1495 let remote_path = remote_dir.path().to_string_lossy().to_string();
1496 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1497 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1498
1499 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
1501 .await
1502 .expect("failed to resolve upstream reference");
1503
1504 assert_eq!(upstream_reference, "origin/main");
1506 }
1507
1508 #[tokio::test]
1509 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
1510 let temp_dir = tempdir().expect("failed to create temp dir");
1512 setup_test_git_repo(temp_dir.path());
1513 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1514 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1515 .expect("failed to write session file");
1516 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1517 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1518 run_git_command(temp_dir.path(), &["checkout", "main"]);
1519 fs::write(temp_dir.path().join("main.txt"), "main change\n")
1520 .expect("failed to write main file");
1521 run_git_command(temp_dir.path(), &["add", "main.txt"]);
1522 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
1523
1524 let status = get_ref_ahead_behind(
1526 temp_dir.path().to_path_buf(),
1527 "wt/1234abcd".to_string(),
1528 "main".to_string(),
1529 )
1530 .await
1531 .expect("failed to compare branch refs");
1532
1533 assert_eq!(status, (1, 1));
1535 }
1536
1537 #[tokio::test]
1538 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
1539 let temp_dir = tempdir().expect("failed to create temp dir");
1541 let remote_dir = tempdir().expect("failed to create remote temp dir");
1542 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1543 let contributor_clone_path = contributor_dir.path().join("clone");
1544 setup_test_git_repo(temp_dir.path());
1545 run_git_command(remote_dir.path(), &["init", "--bare"]);
1546 let remote_path = remote_dir.path().to_string_lossy().to_string();
1547 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1548 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1549 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1550 run_git_command(
1551 contributor_dir.path(),
1552 &["clone", &remote_path, &contributor_clone_path_text],
1553 );
1554 run_git_command(
1555 &contributor_clone_path,
1556 &["config", "user.name", "Contributor User"],
1557 );
1558 run_git_command(
1559 &contributor_clone_path,
1560 &["config", "user.email", "contributor@example.com"],
1561 );
1562 run_git_command(
1563 &contributor_clone_path,
1564 &["checkout", "-B", "main", "origin/main"],
1565 );
1566 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1567 .expect("failed to write remote file");
1568 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1569 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1570 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1571 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1572 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1573 .expect("failed to write session file");
1574 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1575 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1576 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
1577 fs::write(
1578 temp_dir.path().join("session.txt"),
1579 "session change\nmore local\n",
1580 )
1581 .expect("failed to extend session file");
1582 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1583 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
1584 run_git_command(temp_dir.path(), &["fetch"]);
1585
1586 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
1588 .await
1589 .expect("failed to read branch tracking statuses");
1590
1591 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
1593 assert_eq!(
1594 branch_tracking_statuses.get("wt/1234abcd"),
1595 Some(&Some((1, 0)))
1596 );
1597 }
1598
1599 #[tokio::test]
1600 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
1605 let temp_dir = tempdir().expect("failed to create temp dir");
1607 setup_test_git_repo(temp_dir.path());
1608 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
1609 fs::write(temp_dir.path().join("session.txt"), "session work\n")
1610 .expect("failed to write session file");
1611 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1612 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
1613 fs::remove_file(temp_dir.path().join("session.txt"))
1614 .expect("failed to remove session file");
1615
1616 let result = commit_all_preserving_single_commit(
1620 temp_dir.path().to_path_buf(),
1621 "main".to_string(),
1622 "Session commit".to_string(),
1623 SingleCommitMessageStrategy::Replace,
1624 true,
1625 )
1626 .await;
1627
1628 let error = result.expect_err("amend-would-be-empty should fail");
1630 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
1631 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
1632 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
1633
1634 assert!(
1635 error.to_string().contains("Nothing to commit"),
1636 "expected 'Nothing to commit' sentinel but got: {error}"
1637 );
1638 assert_eq!(commit_count, "1");
1639 assert_eq!(head_message, "Initial commit");
1640 assert!(status.is_empty());
1641 }
1642}