1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Output;
4
5#[cfg(unix)]
6use rustix::fs::{self as rustix_fs, Access};
7use tokio::task::spawn_blocking;
8
9use super::error::GitError;
10use super::rebase::{is_rebase_conflict, run_git_command_with_index_lock_retry};
11use super::repo::{
12 command_output_detail, run_git_command, run_git_command_cancellable,
13 run_git_command_output_sync, run_git_command_output_with_env_sync, run_git_command_sync,
14};
15
16pub type BranchTrackingMap = HashMap<String, Option<(u32, u32)>>;
19
20const COMMIT_ALL_HOOK_RETRY_ATTEMPTS: usize = 5;
21const PRE_COMMIT_CONFIG_FILES: [&str; 2] = [".pre-commit-config.yaml", ".pre-commit-config.yml"];
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum SingleCommitMessageStrategy {
27 Replace,
29 Reuse,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub enum PullRebaseResult {
36 Completed,
38 Conflict {
40 detail: String,
42 },
43}
44
45pub(crate) async fn commit_all(
59 repo_path: PathBuf,
60 commit_message: String,
61 no_verify: bool,
62) -> Result<(), GitError> {
63 commit_all_with_retry(
64 repo_path,
65 commit_message,
66 SingleCommitMessageStrategy::Replace,
67 no_verify,
68 false,
69 )
70 .await
71}
72
73pub(crate) async fn commit_all_preserving_single_commit(
95 repo_path: PathBuf,
96 base_branch: String,
97 commit_message: String,
98 message_strategy: SingleCommitMessageStrategy,
99 no_verify: bool,
100) -> Result<(), GitError> {
101 let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
102
103 commit_all_with_retry(
104 repo_path,
105 commit_message,
106 message_strategy,
107 no_verify,
108 amend_existing_commit,
109 )
110 .await
111}
112
113pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
124 spawn_blocking(move || stage_all_sync(&repo_path)).await?
125}
126
127pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
134 spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
135}
136
137pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
148 let hash = run_git_command(
149 repo_path,
150 vec![
151 "rev-parse".to_string(),
152 "--short".to_string(),
153 "HEAD".to_string(),
154 ],
155 "Failed to resolve HEAD hash".to_string(),
156 )
157 .await?;
158 let hash = hash.trim().to_string();
159 if hash.is_empty() {
160 return Err(GitError::OutputParse(
161 "Failed to resolve HEAD hash: empty output".to_string(),
162 ));
163 }
164
165 Ok(hash)
166}
167
168pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
179 let hash = run_git_command(
180 repo_path,
181 vec!["rev-parse".to_string(), "HEAD".to_string()],
182 "Failed to resolve HEAD hash".to_string(),
183 )
184 .await?;
185 let hash = hash.trim().to_string();
186 if hash.is_empty() {
187 return Err(GitError::OutputParse(
188 "Failed to resolve HEAD hash: empty output".to_string(),
189 ));
190 }
191
192 Ok(hash)
193}
194
195pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
207 let hash = run_git_command(
208 repo_path,
209 vec![
210 "rev-parse".to_string(),
211 "--verify".to_string(),
212 format!("{reference}^{{commit}}"),
213 ],
214 format!("Failed to resolve `{reference}` hash"),
215 )
216 .await?;
217 let hash = hash.trim().to_string();
218 if hash.is_empty() {
219 return Err(GitError::OutputParse(format!(
220 "Failed to resolve `{reference}` hash: empty output"
221 )));
222 }
223
224 Ok(hash)
225}
226
227pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
232 spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
233}
234
235pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
250 run_git_command_cancellable(
251 repo_path,
252 vec!["branch".to_string(), "-D".to_string(), branch_name],
253 "Git branch deletion failed".to_string(),
254 )
255 .await?;
256
257 Ok(())
258}
259
260pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
282 spawn_blocking(move || -> Result<String, GitError> {
283 let index_path = run_git_command_sync(
284 &repo_path,
285 &["rev-parse", "--git-path", "index"],
286 "Git index path resolution failed",
287 )?;
288 let index_path = PathBuf::from(index_path.trim());
289 let index_path = if index_path.is_absolute() {
290 index_path
291 } else {
292 repo_path.join(index_path)
293 };
294 let temporary_index = copy_git_index_to_temp(&index_path)?;
295
296 run_git_command_with_index_sync(
297 &repo_path,
298 &["add", "-A", "--intent-to-add"],
299 &temporary_index,
300 "Git add --intent-to-add failed",
301 )?;
302
303 let merge_base_output =
304 run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
305
306 let diff_target = if merge_base_output.status.success() {
307 resolve_diff_target(
308 &repo_path,
309 &base_branch,
310 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
311 )?
312 } else {
313 base_branch
314 };
315
316 run_git_command_with_index_sync(
317 &repo_path,
318 &["diff", diff_target.as_str()],
319 &temporary_index,
320 "Git diff failed",
321 )
322 })
323 .await?
324}
325
326fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
329 let index_parent = index_path.parent().ok_or_else(|| {
330 GitError::OutputParse(format!(
331 "Git index path has no parent: {}",
332 index_path.display()
333 ))
334 })?;
335 let temporary_index =
336 tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
337 command: "create temporary git index".to_string(),
338 stderr: error.to_string(),
339 })?;
340 std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
341 command: "copy git index".to_string(),
342 stderr: error.to_string(),
343 })?;
344
345 Ok(temporary_index.into_temp_path())
346}
347
348fn run_git_command_with_index_sync(
351 repo_path: &Path,
352 args: &[&str],
353 index_path: &Path,
354 error_context: &str,
355) -> Result<String, GitError> {
356 let output = run_git_command_output_with_env_sync(
357 repo_path,
358 args,
359 &[("GIT_INDEX_FILE", index_path.as_os_str())],
360 )?;
361 if !output.status.success() {
362 return Err(GitError::CommandFailed {
363 command: format!("git {}", args.join(" ")),
364 stderr: format!(
365 "{error_context}: {}",
366 command_output_detail(&output.stdout, &output.stderr)
367 ),
368 });
369 }
370
371 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
372}
373
374pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
385 let status_output = worktree_status(repo_path).await?;
386
387 Ok(status_output.trim().is_empty())
388}
389
390pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
404 run_git_command(
405 repo_path,
406 vec![
407 "status".to_string(),
408 "--porcelain=v1".to_string(),
409 "--untracked-files=all".to_string(),
410 ],
411 "Git status --porcelain=v1 failed".to_string(),
412 )
413 .await
414}
415
416pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
431 run_git_command(
432 repo_path,
433 vec![
434 "status".to_string(),
435 "--porcelain=v1".to_string(),
436 "--untracked-files=no".to_string(),
437 ],
438 "Git tracked status --porcelain=v1 failed".to_string(),
439 )
440 .await
441}
442
443pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
459 spawn_blocking(move || {
460 let pull_arguments = pull_rebase_arguments(&repo_path)
461 .unwrap_or_else(|_| vec!["pull".to_string(), "--rebase".to_string()]);
462 let pull_argument_refs: Vec<&str> = pull_arguments.iter().map(String::as_str).collect();
463 let output = run_git_command_with_index_lock_retry(
464 &repo_path,
465 &pull_argument_refs,
466 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
467 )?;
468
469 if output.status.success() {
470 return Ok(PullRebaseResult::Completed);
471 }
472
473 let detail = command_output_detail(&output.stdout, &output.stderr);
474 if is_rebase_conflict(&detail) {
475 return Ok(PullRebaseResult::Conflict { detail });
476 }
477
478 Err(GitError::CommandFailed {
479 command: "git pull --rebase".to_string(),
480 stderr: detail,
481 })
482 })
483 .await?
484}
485
486fn pull_rebase_arguments(repo_path: &Path) -> Result<Vec<String>, GitError> {
491 let upstream_reference = primary_upstream_reference(repo_path)?;
492
493 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
494 return Ok(vec![
495 "pull".to_string(),
496 "--rebase".to_string(),
497 remote_name.to_string(),
498 branch_name.to_string(),
499 ]);
500 }
501
502 let remote_name = current_branch_remote_name(repo_path)?;
503
504 Ok(vec![
505 "pull".to_string(),
506 "--rebase".to_string(),
507 remote_name,
508 upstream_reference,
509 ])
510}
511
512fn primary_upstream_reference(repo_path: &Path) -> Result<String, GitError> {
518 let upstream_reference = upstream_reference_name(repo_path)?;
519 let Some(primary_reference) = upstream_reference
520 .lines()
521 .map(str::trim)
522 .find(|line| !line.is_empty())
523 else {
524 return Err(GitError::OutputParse(
525 "Failed to resolve upstream branch: empty output".to_string(),
526 ));
527 };
528
529 Ok(primary_reference.to_string())
530}
531
532fn upstream_reference_name(repo_path: &Path) -> Result<String, GitError> {
534 let upstream_reference = run_git_command_sync(
535 repo_path,
536 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
537 "Failed to resolve upstream branch",
538 )?;
539 let upstream_reference = upstream_reference.trim().to_string();
540 if upstream_reference.is_empty() {
541 return Err(GitError::OutputParse(
542 "Failed to resolve upstream branch: empty output".to_string(),
543 ));
544 }
545
546 Ok(upstream_reference)
547}
548
549fn current_branch_remote_name(repo_path: &Path) -> Result<String, GitError> {
554 let current_branch_name = current_branch_name(repo_path)?;
555 let remote_config_key = format!("branch.{current_branch_name}.remote");
556 let remote_name = run_git_command_sync(
557 repo_path,
558 &["config", "--get", &remote_config_key],
559 &format!("Failed to resolve current branch remote `{remote_config_key}`"),
560 )?;
561 let remote_name = remote_name.trim().to_string();
562 if remote_name.is_empty() {
563 return Err(GitError::OutputParse(format!(
564 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
565 )));
566 }
567
568 Ok(remote_name)
569}
570
571fn current_branch_name(repo_path: &Path) -> Result<String, GitError> {
573 let branch_name = run_git_command_sync(
574 repo_path,
575 &["rev-parse", "--abbrev-ref", "HEAD"],
576 "Failed to resolve current branch name",
577 )?;
578 let branch_name = branch_name.trim().to_string();
579 if branch_name.is_empty() {
580 return Err(GitError::OutputParse(
581 "Failed to resolve current branch name: empty output".to_string(),
582 ));
583 }
584
585 if branch_name == "HEAD" {
586 return Err(GitError::OutputParse(
587 "Failed to resolve current branch name: detached HEAD".to_string(),
588 ));
589 }
590
591 Ok(branch_name)
592}
593
594pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
611 spawn_blocking(move || -> Result<String, GitError> {
612 let push_output = run_git_command_output_sync(&repo_path, &["push", "--force-with-lease"])?;
613
614 if push_output.status.success() {
615 return primary_upstream_reference(&repo_path);
616 }
617
618 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
619 if !is_no_upstream_error(&push_detail) {
620 return Err(GitError::CommandFailed {
621 command: "git push".to_string(),
622 stderr: push_detail,
623 });
624 }
625
626 run_git_command_sync(
627 &repo_path,
628 &[
629 "push",
630 "--force-with-lease",
631 "--set-upstream",
632 "origin",
633 "HEAD",
634 ],
635 "Git push failed",
636 )?;
637
638 primary_upstream_reference(&repo_path)
639 })
640 .await?
641}
642
643pub(crate) async fn remote_branch_exists(
656 repo_path: PathBuf,
657 remote_branch_name: String,
658) -> Result<bool, GitError> {
659 spawn_blocking(move || -> Result<bool, GitError> {
660 let remote_name =
661 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
662 let output = run_git_command_output_sync(
663 &repo_path,
664 &["ls-remote", "--heads", &remote_name, &remote_branch_name],
665 )?;
666
667 if !output.status.success() {
668 let detail = command_output_detail(&output.stdout, &output.stderr);
669
670 return Err(GitError::CommandFailed {
671 command: "git ls-remote".to_string(),
672 stderr: detail,
673 });
674 }
675
676 let stdout = String::from_utf8_lossy(&output.stdout);
677
678 Ok(!stdout.trim().is_empty())
679 })
680 .await?
681}
682
683pub(crate) async fn push_current_branch_to_remote_branch(
700 repo_path: PathBuf,
701 remote_branch_name: String,
702) -> Result<String, GitError> {
703 spawn_blocking(move || -> Result<String, GitError> {
704 let remote_name =
705 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
706 let push_refspec = format!("HEAD:{remote_branch_name}");
707
708 run_git_command_sync(
709 &repo_path,
710 &[
711 "push",
712 "--force-with-lease",
713 "--set-upstream",
714 &remote_name,
715 &push_refspec,
716 ],
717 "Git push failed",
718 )?;
719
720 Ok(format!("{remote_name}/{remote_branch_name}"))
721 })
722 .await?
723}
724
725pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
737 spawn_blocking(move || primary_upstream_reference(&repo_path)).await?
738}
739
740pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
751 run_git_command(
752 repo_path,
753 vec!["fetch".to_string()],
754 "Git fetch failed".to_string(),
755 )
756 .await?;
757
758 Ok(())
759}
760
761pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
773 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
774}
775
776pub(crate) async fn get_ref_ahead_behind(
786 repo_path: PathBuf,
787 left_ref: String,
788 right_ref: String,
789) -> Result<(u32, u32), GitError> {
790 let rev_list_output = run_git_command(
791 repo_path,
792 vec![
793 "rev-list".to_string(),
794 "--left-right".to_string(),
795 "--count".to_string(),
796 format!("{left_ref}...{right_ref}"),
797 ],
798 "Git rev-list failed".to_string(),
799 )
800 .await?;
801
802 parse_ahead_behind_counts(&rev_list_output)
803}
804
805fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
808 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
809 if parts.len() >= 2 {
810 let ahead = parts[0].parse().unwrap_or(0);
811 let behind = parts[1].parse().unwrap_or(0);
812
813 return Ok((ahead, behind));
814 }
815
816 Err(GitError::OutputParse(
817 "Unexpected output format from git rev-list".to_string(),
818 ))
819}
820
821pub(crate) async fn branch_tracking_statuses(
830 repo_path: PathBuf,
831) -> Result<BranchTrackingMap, GitError> {
832 let git_output = run_git_command(
833 repo_path,
834 vec![
835 "for-each-ref".to_string(),
836 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
837 "refs/heads".to_string(),
838 ],
839 "Git for-each-ref failed".to_string(),
840 )
841 .await?;
842
843 Ok(parse_branch_tracking_statuses(&git_output))
844}
845
846pub(crate) async fn list_upstream_commit_titles(
857 repo_path: PathBuf,
858) -> Result<Vec<String>, GitError> {
859 let git_output = run_git_command(
860 repo_path,
861 vec![
862 "log".to_string(),
863 "--reverse".to_string(),
864 "--pretty=%s".to_string(),
865 "HEAD..@{u}".to_string(),
866 ],
867 "Git log failed".to_string(),
868 )
869 .await?;
870
871 Ok(parse_commit_titles(&git_output))
872}
873
874pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
885 let git_output = run_git_command(
886 repo_path,
887 vec![
888 "log".to_string(),
889 "--reverse".to_string(),
890 "--pretty=%s".to_string(),
891 "@{u}..HEAD".to_string(),
892 ],
893 "Git log failed".to_string(),
894 )
895 .await?;
896
897 Ok(parse_commit_titles(&git_output))
898}
899
900pub(crate) async fn has_commits_since(
906 repo_path: PathBuf,
907 base_branch: String,
908) -> Result<bool, GitError> {
909 spawn_blocking(move || -> Result<bool, GitError> {
910 let rev_list_output = run_git_command_sync(
911 &repo_path,
912 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
913 "Failed to count commits since base branch",
914 )?;
915 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
916 GitError::OutputParse(format!(
917 "Failed to parse commit count since base branch `{base_branch}`: {error}"
918 ))
919 })?;
920
921 Ok(commit_count > 0)
922 })
923 .await?
924}
925
926fn parse_commit_titles(output: &str) -> Vec<String> {
928 output
929 .lines()
930 .map(str::trim)
931 .filter(|title| !title.is_empty())
932 .map(ToString::to_string)
933 .collect()
934}
935
936fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
938 let mut branch_tracking_statuses = HashMap::new();
939
940 for line in output
941 .lines()
942 .map(str::trim)
943 .filter(|line| !line.is_empty())
944 {
945 let mut parts = line.splitn(3, '\t');
946 let Some(branch_name) = parts
947 .next()
948 .map(str::trim)
949 .filter(|value| !value.is_empty())
950 else {
951 continue;
952 };
953 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
954 let track = parts.next().map(str::trim).unwrap_or_default();
955
956 let status = if upstream_ref.is_empty() {
957 None
958 } else {
959 parse_branch_tracking_counts(track)
960 };
961 branch_tracking_statuses.insert(branch_name.to_string(), status);
962 }
963
964 branch_tracking_statuses
965}
966
967fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
969 let normalized_track = track.trim();
970 if normalized_track.is_empty() || normalized_track == "gone" {
971 return None;
972 }
973
974 let mut ahead = 0;
975 let mut behind = 0;
976
977 for part in normalized_track.split(',').map(str::trim) {
978 if let Some(count) = part.strip_prefix("ahead ") {
979 ahead = count.parse().ok()?;
980 } else if let Some(count) = part.strip_prefix("behind ") {
981 behind = count.parse().ok()?;
982 }
983 }
984
985 Some((ahead, behind))
986}
987
988fn resolve_diff_target(
994 repo_path: &Path,
995 base_branch: &str,
996 merge_base: &str,
997) -> Result<String, GitError> {
998 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
999 if !cherry_output.status.success() {
1000 return Ok(merge_base.to_string());
1001 }
1002
1003 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1004 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1005 return Ok(merge_base.to_string());
1006 };
1007
1008 Ok(last_leading_applied_commit.to_string())
1009}
1010
1011fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1018 let mut last_applied_commit = None;
1019
1020 for line in cherry_output.lines() {
1021 let trimmed_line = line.trim();
1022 if trimmed_line.is_empty() {
1023 continue;
1024 }
1025
1026 let mut parts = trimmed_line.split_whitespace();
1027 let marker = parts.next()?;
1028 let commit_hash = parts.next()?;
1029
1030 if marker == "-" {
1031 last_applied_commit = Some(commit_hash);
1032
1033 continue;
1034 }
1035
1036 if marker == "+" {
1037 break;
1038 }
1039
1040 break;
1041 }
1042
1043 last_applied_commit
1044}
1045
1046async fn commit_all_with_retry(
1054 repo_path: PathBuf,
1055 commit_message: String,
1056 message_strategy: SingleCommitMessageStrategy,
1057 no_verify: bool,
1058 amend_existing_commit: bool,
1059) -> Result<(), GitError> {
1060 spawn_blocking(move || {
1061 stage_all_sync(&repo_path)?;
1062
1063 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1064 let output = run_commit_command(
1065 &repo_path,
1066 &commit_message,
1067 message_strategy,
1068 no_verify,
1069 amend_existing_commit,
1070 )?;
1071
1072 if output.status.success() {
1073 return Ok(());
1074 }
1075
1076 let stderr = String::from_utf8_lossy(&output.stderr);
1077 let stdout = String::from_utf8_lossy(&output.stdout);
1078 if is_nothing_to_commit_output(&stdout, &stderr) {
1079 return Err(nothing_to_commit_error());
1080 }
1081
1082 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1083 reset_empty_amend_sync(&repo_path)?;
1084
1085 return Err(nothing_to_commit_error());
1086 }
1087
1088 if is_hook_modified_error(&stdout, &stderr) {
1089 stage_all_sync(&repo_path)?;
1090
1091 continue;
1092 }
1093
1094 let detail = command_output_detail(&output.stdout, &output.stderr);
1095
1096 return Err(GitError::CommandFailed {
1097 command: "git commit".to_string(),
1098 stderr: detail,
1099 });
1100 }
1101
1102 Err(GitError::CommandFailed {
1103 command: "git commit".to_string(),
1104 stderr: format!(
1105 "Failed to commit: commit hooks kept modifying files after \
1106 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1107 ),
1108 })
1109 })
1110 .await?
1111}
1112
1113fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1116 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1117 .iter()
1118 .find(|config_file| repo_path.join(config_file).is_file())
1119 else {
1120 return Ok(());
1121 };
1122 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1123
1124 if is_executable_hook(&hook_path) {
1125 return Ok(());
1126 }
1127
1128 Err(GitError::PreCommitHookMissing {
1129 config_file: (*config_file).to_string(),
1130 })
1131}
1132
1133fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1135 let hooks_path_output =
1136 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1137 let hooks_path = if hooks_path_output.status.success() {
1138 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1139 } else if hooks_path_output.status.code() == Some(1) {
1140 let default_hook_path = run_git_command_sync(
1141 repo_path,
1142 &["rev-parse", "--git-path", "hooks/pre-commit"],
1143 "Failed to resolve Git pre-commit hook path",
1144 )?;
1145
1146 return Ok(resolve_repo_path(
1147 repo_path,
1148 PathBuf::from(default_hook_path.trim()),
1149 ));
1150 } else {
1151 return Err(GitError::CommandFailed {
1152 command: "git config --path --get core.hooksPath".to_string(),
1153 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1154 });
1155 };
1156
1157 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1158}
1159
1160fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1161 if path.is_absolute() {
1162 return path;
1163 }
1164
1165 repo_path.join(path)
1166}
1167
1168#[cfg(unix)]
1169fn is_executable_hook(hook_path: &Path) -> bool {
1170 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1171}
1172
1173#[cfg(not(unix))]
1174fn is_executable_hook(hook_path: &Path) -> bool {
1175 hook_path.is_file()
1176}
1177
1178fn nothing_to_commit_error() -> GitError {
1180 GitError::CommandFailed {
1181 command: "git commit".to_string(),
1182 stderr: "Nothing to commit: no changes detected".to_string(),
1183 }
1184}
1185
1186fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1189 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1190
1191 combined.contains("nothing to commit")
1192}
1193
1194fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1197 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1198 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1199
1200 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1201}
1202
1203fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1206 run_git_command_sync(
1207 repo_path,
1208 &["reset", "HEAD^"],
1209 "Git reset after empty amend failed",
1210 )?;
1211
1212 Ok(())
1213}
1214
1215fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1219 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1220
1221 if !output.status.success() {
1222 let detail = command_output_detail(&output.stdout, &output.stderr);
1223
1224 return Err(GitError::CommandFailed {
1225 command: "git add -A".to_string(),
1226 stderr: format!("Failed to stage changes: {detail}"),
1227 });
1228 }
1229
1230 Ok(())
1231}
1232
1233fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1235 if !has_head_commit_sync(repo_path)? {
1236 return Ok(None);
1237 }
1238
1239 let output = run_git_command_sync(
1240 repo_path,
1241 &["log", "-1", "--pretty=%B"],
1242 "Failed to read HEAD commit message",
1243 )?;
1244
1245 Ok(Some(output.trim().to_string()))
1246}
1247
1248fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1250 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1251
1252 if output.status.success() {
1253 return Ok(true);
1254 }
1255
1256 let detail = command_output_detail(&output.stdout, &output.stderr);
1257 let normalized_detail = detail.to_ascii_lowercase();
1258 if normalized_detail.contains("needed a single revision")
1259 || normalized_detail.contains("unknown revision")
1260 || normalized_detail.contains("does not have any commits yet")
1261 {
1262 return Ok(false);
1263 }
1264
1265 Err(GitError::CommandFailed {
1266 command: "git rev-parse --verify HEAD".to_string(),
1267 stderr: detail,
1268 })
1269}
1270
1271fn run_commit_command(
1275 repo_path: &Path,
1276 commit_message: &str,
1277 message_strategy: SingleCommitMessageStrategy,
1278 no_verify: bool,
1279 amend_existing_commit: bool,
1280) -> Result<Output, GitError> {
1281 let mut args = vec!["commit"];
1282 if amend_existing_commit {
1283 args.push("--amend");
1284 match message_strategy {
1285 SingleCommitMessageStrategy::Replace => {
1286 args.push("-m");
1287 args.push(commit_message);
1288 }
1289 SingleCommitMessageStrategy::Reuse => {
1290 args.push("--no-edit");
1291 }
1292 }
1293 } else {
1294 args.push("-m");
1295 args.push(commit_message);
1296 }
1297
1298 if no_verify {
1299 args.push("--no-verify");
1300 }
1301
1302 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1303}
1304
1305fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1307 let combined = format!(
1308 "{stdout}
1309{stderr}"
1310 )
1311 .to_ascii_lowercase();
1312
1313 combined.contains("files were modified by this hook")
1314}
1315
1316pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1318 let normalized_detail = detail.to_ascii_lowercase();
1319
1320 normalized_detail.contains("has no upstream branch")
1321 || normalized_detail.contains("no upstream branch")
1322 || normalized_detail.contains("set-upstream")
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327 use std::fs;
1328 #[cfg(unix)]
1329 use std::os::unix::fs::PermissionsExt;
1330 use std::path::Path;
1331 use std::process::{Command, Output};
1332
1333 use tempfile::tempdir;
1334
1335 use super::*;
1336
1337 fn run_git_command(repo_path: &Path, args: &[&str]) {
1339 let output = git_command_output(repo_path, args);
1340
1341 assert!(
1342 output.status.success(),
1343 "git command {:?} failed: {}",
1344 args,
1345 String::from_utf8_lossy(&output.stderr)
1346 );
1347 }
1348
1349 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1351 Command::new("git")
1352 .args(args)
1353 .current_dir(repo_path)
1354 .output()
1355 .expect("failed to run git command")
1356 }
1357
1358 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1360 let output = git_command_output(repo_path, args);
1361
1362 assert!(
1363 output.status.success(),
1364 "git command {:?} failed: {}",
1365 args,
1366 String::from_utf8_lossy(&output.stderr)
1367 );
1368
1369 String::from_utf8(output.stdout)
1370 .expect("git stdout should be valid utf-8")
1371 .trim()
1372 .to_string()
1373 }
1374
1375 fn setup_test_git_repo(repo_path: &Path) {
1377 run_git_command(repo_path, &["init", "-b", "main"]);
1378 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1379 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1380 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1381 run_git_command(repo_path, &["add", "README.md"]);
1382 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1383 }
1384
1385 #[tokio::test]
1386 async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1387 let temp_dir = tempdir().expect("failed to create temp dir");
1389 setup_test_git_repo(temp_dir.path());
1390 fs::write(temp_dir.path().join("README.md"), "staged change\n")
1391 .expect("failed to write staged change");
1392 run_git_command(temp_dir.path(), &["add", "README.md"]);
1393 fs::write(
1394 temp_dir.path().join("README.md"),
1395 "staged change\nunstaged change\n",
1396 )
1397 .expect("failed to write unstaged change");
1398 fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1399 .expect("failed to write untracked file");
1400 let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1401 let status_before = git_command_output(
1402 temp_dir.path(),
1403 &["status", "--porcelain=v1", "--untracked-files=all"],
1404 )
1405 .stdout;
1406
1407 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1409
1410 let diff_output = result.expect("diff should succeed");
1412 let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1413 let status_after = git_command_output(
1414 temp_dir.path(),
1415 &["status", "--porcelain=v1", "--untracked-files=all"],
1416 )
1417 .stdout;
1418 assert!(diff_output.contains("staged change"));
1419 assert!(diff_output.contains("unstaged change"));
1420 assert!(diff_output.contains("untracked change"));
1421 assert_eq!(cached_diff_after, cached_diff_before);
1422 assert_eq!(status_after, status_before);
1423 }
1424
1425 #[test]
1426 fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
1427 let temp_dir = tempdir().expect("failed to create temp dir");
1429 let path_without_parent = Path::new("/");
1430 let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
1431 let missing_index = temp_dir.path().join("missing-index");
1432
1433 let parent_error = copy_git_index_to_temp(path_without_parent);
1435 let create_error = copy_git_index_to_temp(&missing_parent_index);
1436 let copy_error = copy_git_index_to_temp(&missing_index);
1437
1438 assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
1440 assert!(matches!(
1441 create_error,
1442 Err(GitError::CommandFailed { ref command, .. })
1443 if command == "create temporary git index"
1444 ));
1445 assert!(matches!(
1446 copy_error,
1447 Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
1448 ));
1449 }
1450
1451 #[test]
1452 fn run_git_command_with_index_sync_maps_process_and_command_failures() {
1453 let temp_dir = tempdir().expect("failed to create temp dir");
1455 let index_path = temp_dir.path().join("index");
1456 let missing_repo_path = temp_dir.path().join("missing-repository");
1457 fs::write(&index_path, []).expect("failed to create temporary index");
1458
1459 let process_error = run_git_command_with_index_sync(
1461 &missing_repo_path,
1462 &["status"],
1463 &index_path,
1464 "Expected process failure",
1465 );
1466 let command_error = run_git_command_with_index_sync(
1467 temp_dir.path(),
1468 &["definitely-not-a-git-command"],
1469 &index_path,
1470 "Expected git failure",
1471 );
1472
1473 assert!(matches!(
1475 process_error,
1476 Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
1477 ));
1478 assert!(matches!(
1479 command_error,
1480 Err(GitError::CommandFailed {
1481 ref command,
1482 ref stderr,
1483 }) if command == "git definitely-not-a-git-command"
1484 && stderr.starts_with("Expected git failure:")
1485 ));
1486 }
1487
1488 #[cfg(unix)]
1489 fn write_executable_pre_commit_hook(hook_path: &Path) {
1490 fs::create_dir_all(
1491 hook_path
1492 .parent()
1493 .expect("pre-commit hook should have a parent directory"),
1494 )
1495 .expect("failed to create hooks directory");
1496 fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1497 let mut permissions = fs::metadata(hook_path)
1498 .expect("failed to read pre-commit hook metadata")
1499 .permissions();
1500 permissions.set_mode(0o755);
1501 fs::set_permissions(hook_path, permissions)
1502 .expect("failed to make pre-commit hook executable");
1503 }
1504
1505 #[test]
1506 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
1507 let temp_dir = tempdir().expect("failed to create temp dir");
1509 setup_test_git_repo(temp_dir.path());
1510
1511 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1513
1514 assert!(result.is_ok());
1516 }
1517
1518 #[test]
1519 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
1520 let temp_dir = tempdir().expect("failed to create temp dir");
1522 setup_test_git_repo(temp_dir.path());
1523 fs::write(
1524 temp_dir.path().join(".pre-commit-config.yaml"),
1525 "repos: []\n",
1526 )
1527 .expect("failed to write pre-commit configuration");
1528
1529 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1531
1532 assert!(matches!(
1534 result,
1535 Err(GitError::PreCommitHookMissing { ref config_file })
1536 if config_file == ".pre-commit-config.yaml"
1537 ));
1538 }
1539
1540 #[cfg(unix)]
1541 #[test]
1542 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
1543 let temp_dir = tempdir().expect("failed to create temp dir");
1545 setup_test_git_repo(temp_dir.path());
1546 fs::write(
1547 temp_dir.path().join(".pre-commit-config.yaml"),
1548 "repos: []\n",
1549 )
1550 .expect("failed to write pre-commit configuration");
1551 let hook_path = temp_dir.path().join(git_command_stdout(
1552 temp_dir.path(),
1553 &["rev-parse", "--git-path", "hooks/pre-commit"],
1554 ));
1555 write_executable_pre_commit_hook(&hook_path);
1556
1557 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1559
1560 assert!(result.is_ok());
1562 }
1563
1564 #[cfg(unix)]
1565 #[test]
1566 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
1567 let temp_dir = tempdir().expect("failed to create temp dir");
1569 setup_test_git_repo(temp_dir.path());
1570 fs::write(
1571 temp_dir.path().join(".pre-commit-config.yaml"),
1572 "repos: []\n",
1573 )
1574 .expect("failed to write pre-commit configuration");
1575 run_git_command(
1576 temp_dir.path(),
1577 &["config", "core.hooksPath", ".custom-hooks"],
1578 );
1579 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
1580
1581 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1583
1584 assert!(result.is_ok());
1586 }
1587
1588 #[cfg(unix)]
1589 #[test]
1590 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
1591 let temp_dir = tempdir().expect("failed to create temp dir");
1593 setup_test_git_repo(temp_dir.path());
1594 fs::write(
1595 temp_dir.path().join(".pre-commit-config.yaml"),
1596 "repos: []\n",
1597 )
1598 .expect("failed to write pre-commit configuration");
1599 let hook_path = temp_dir.path().join(git_command_stdout(
1600 temp_dir.path(),
1601 &["rev-parse", "--git-path", "hooks/pre-commit"],
1602 ));
1603 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1604 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
1605 .expect("failed to set mismatched execute permissions");
1606
1607 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1609
1610 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
1612 }
1613
1614 #[tokio::test]
1615 async fn commit_all_allows_configured_validation_without_hook() {
1616 let temp_dir = tempdir().expect("failed to create temp dir");
1618 setup_test_git_repo(temp_dir.path());
1619 fs::write(
1620 temp_dir.path().join(".pre-commit-config.yaml"),
1621 "repos: []\n",
1622 )
1623 .expect("failed to write pre-commit configuration");
1624 fs::write(temp_dir.path().join("README.md"), "changed\n")
1625 .expect("failed to write worktree change");
1626
1627 let result = commit_all(
1629 temp_dir.path().to_path_buf(),
1630 "Change README".to_string(),
1631 false,
1632 )
1633 .await;
1634
1635 assert!(result.is_ok());
1637 assert_eq!(
1638 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
1639 "Change README"
1640 );
1641 }
1642
1643 #[test]
1644 fn current_branch_name_returns_error_for_detached_head() {
1645 let temp_dir = tempdir().expect("failed to create temp dir");
1647 setup_test_git_repo(temp_dir.path());
1648 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1649
1650 let result = current_branch_name(temp_dir.path());
1652
1653 let error = result.expect_err("detached HEAD should fail");
1655 assert!(error.to_string().contains("detached HEAD"));
1656 }
1657
1658 #[test]
1659 fn primary_upstream_reference_uses_first_non_empty_line() {
1660 let temp_dir = tempdir().expect("failed to create temp dir");
1662 let remote_dir = tempdir().expect("failed to create remote temp dir");
1663 setup_test_git_repo(temp_dir.path());
1664 run_git_command(remote_dir.path(), &["init", "--bare"]);
1665 let remote_path = remote_dir.path().to_string_lossy().to_string();
1666 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1667 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1668 run_git_command(
1669 temp_dir.path(),
1670 &[
1671 "config",
1672 "--replace-all",
1673 "branch.main.merge",
1674 "refs/heads/main",
1675 ],
1676 );
1677 run_git_command(
1678 temp_dir.path(),
1679 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1680 );
1681
1682 let upstream_reference =
1684 primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1685
1686 assert_eq!(upstream_reference, "origin/main");
1688 }
1689
1690 #[test]
1691 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1692 let output = "\
1694main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1695 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1696
1697 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1699
1700 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1702 assert_eq!(
1703 branch_tracking_statuses.get("wt/1234abcd"),
1704 Some(&Some((3, 1)))
1705 );
1706 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1707 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1708 }
1709
1710 #[tokio::test]
1711 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1712 let temp_dir = tempdir().expect("failed to create temp dir");
1714 let remote_dir = tempdir().expect("failed to create remote temp dir");
1715 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1716 let contributor_clone_path = contributor_dir.path().join("clone");
1717 setup_test_git_repo(temp_dir.path());
1718 run_git_command(remote_dir.path(), &["init", "--bare"]);
1719 let remote_path = remote_dir.path().to_string_lossy().to_string();
1720 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1721 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1722 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1723 fs::write(temp_dir.path().join("README.md"), "local change\n")
1724 .expect("failed to write local change");
1725 run_git_command(temp_dir.path(), &["add", "README.md"]);
1726 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1727 run_git_command(
1728 contributor_dir.path(),
1729 &["clone", &remote_path, &contributor_clone_path_text],
1730 );
1731 run_git_command(
1732 &contributor_clone_path,
1733 &["config", "user.name", "Contributor User"],
1734 );
1735 run_git_command(
1736 &contributor_clone_path,
1737 &["config", "user.email", "contributor@example.com"],
1738 );
1739 run_git_command(
1740 &contributor_clone_path,
1741 &["checkout", "-B", "main", "origin/main"],
1742 );
1743 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1744 .expect("failed to write remote change");
1745 run_git_command(&contributor_clone_path, &["add", "README.md"]);
1746 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1747 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1748
1749 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1751
1752 assert!(matches!(
1754 result,
1755 Ok(PullRebaseResult::Conflict { ref detail })
1756 if {
1757 let normalized_detail = detail.to_ascii_lowercase();
1758
1759 (normalized_detail.contains("conflict")
1760 || normalized_detail.contains("could not apply"))
1761 && !detail.is_empty()
1762 }
1763 ));
1764 }
1765
1766 #[tokio::test]
1767 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1768 let temp_dir = tempdir().expect("failed to create temp dir");
1770 let remote_dir = tempdir().expect("failed to create remote temp dir");
1771 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1772 let contributor_clone_path = contributor_dir.path().join("clone");
1773 setup_test_git_repo(temp_dir.path());
1774 run_git_command(remote_dir.path(), &["init", "--bare"]);
1775 let remote_path = remote_dir.path().to_string_lossy().to_string();
1776 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1777 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1778 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1779 run_git_command(
1780 contributor_dir.path(),
1781 &["clone", &remote_path, &contributor_clone_path_text],
1782 );
1783 run_git_command(
1784 &contributor_clone_path,
1785 &["config", "user.name", "Contributor User"],
1786 );
1787 run_git_command(
1788 &contributor_clone_path,
1789 &["config", "user.email", "contributor@example.com"],
1790 );
1791 run_git_command(
1792 &contributor_clone_path,
1793 &["checkout", "-B", "main", "origin/main"],
1794 );
1795 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1796 .expect("failed to write remote file");
1797 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1798 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1799 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1800 fs::write(temp_dir.path().join("local.txt"), "local change")
1801 .expect("failed to write local file");
1802 run_git_command(temp_dir.path(), &["add", "local.txt"]);
1803 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1804
1805 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
1807
1808 let error = result
1810 .expect_err("non-fast-forward push should fail")
1811 .to_string();
1812 assert!(error.contains("git push"));
1813 assert!(
1814 error.contains("stale info")
1815 || error.contains("rejected")
1816 || error.contains("fetch first")
1817 );
1818 }
1819
1820 #[tokio::test]
1821 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
1822 let temp_dir = tempdir().expect("failed to create temp dir");
1824 let remote_dir = tempdir().expect("failed to create remote temp dir");
1825 setup_test_git_repo(temp_dir.path());
1826 run_git_command(remote_dir.path(), &["init", "--bare"]);
1827 let remote_path = remote_dir.path().to_string_lossy().to_string();
1828 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1829 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1830 fs::write(
1831 temp_dir.path().join("README.md"),
1832 "first published version\n",
1833 )
1834 .expect("failed to write first version");
1835 run_git_command(temp_dir.path(), &["add", "README.md"]);
1836 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
1837 push_current_branch(temp_dir.path().to_path_buf())
1838 .await
1839 .expect("initial push should succeed");
1840 fs::write(
1841 temp_dir.path().join("README.md"),
1842 "rewritten published version\n",
1843 )
1844 .expect("failed to rewrite published version");
1845 run_git_command(temp_dir.path(), &["add", "README.md"]);
1846 run_git_command(
1847 temp_dir.path(),
1848 &["commit", "--amend", "-m", "Rewrite published branch change"],
1849 );
1850
1851 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
1853 .await
1854 .expect("force-with-lease push should update rewritten history");
1855 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
1856 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
1857
1858 assert_eq!(upstream_reference, "origin/main");
1860 assert_eq!(local_head, remote_head);
1861 }
1862
1863 #[tokio::test]
1864 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
1865 let temp_dir = tempdir().expect("failed to create temp dir");
1867 let remote_dir = tempdir().expect("failed to create remote temp dir");
1868 setup_test_git_repo(temp_dir.path());
1869 run_git_command(remote_dir.path(), &["init", "--bare"]);
1870 let remote_path = remote_dir.path().to_string_lossy().to_string();
1871 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1872
1873 let upstream_reference = push_current_branch_to_remote_branch(
1875 temp_dir.path().to_path_buf(),
1876 "review/custom-branch".to_string(),
1877 )
1878 .await
1879 .expect("failed to push current branch to custom remote branch");
1880
1881 assert_eq!(upstream_reference, "origin/review/custom-branch");
1883 }
1884
1885 #[tokio::test]
1886 async fn current_upstream_reference_returns_origin_main() {
1887 let temp_dir = tempdir().expect("failed to create temp dir");
1889 let remote_dir = tempdir().expect("failed to create remote temp dir");
1890 setup_test_git_repo(temp_dir.path());
1891 run_git_command(remote_dir.path(), &["init", "--bare"]);
1892 let remote_path = remote_dir.path().to_string_lossy().to_string();
1893 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1894 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1895
1896 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
1898 .await
1899 .expect("failed to resolve upstream reference");
1900
1901 assert_eq!(upstream_reference, "origin/main");
1903 }
1904
1905 #[tokio::test]
1906 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
1907 let temp_dir = tempdir().expect("failed to create temp dir");
1909 setup_test_git_repo(temp_dir.path());
1910 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1911 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1912 .expect("failed to write session file");
1913 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1914 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1915 run_git_command(temp_dir.path(), &["checkout", "main"]);
1916 fs::write(temp_dir.path().join("main.txt"), "main change\n")
1917 .expect("failed to write main file");
1918 run_git_command(temp_dir.path(), &["add", "main.txt"]);
1919 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
1920
1921 let status = get_ref_ahead_behind(
1923 temp_dir.path().to_path_buf(),
1924 "wt/1234abcd".to_string(),
1925 "main".to_string(),
1926 )
1927 .await
1928 .expect("failed to compare branch refs");
1929
1930 assert_eq!(status, (1, 1));
1932 }
1933
1934 #[tokio::test]
1935 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
1936 let temp_dir = tempdir().expect("failed to create temp dir");
1938 let remote_dir = tempdir().expect("failed to create remote temp dir");
1939 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1940 let contributor_clone_path = contributor_dir.path().join("clone");
1941 setup_test_git_repo(temp_dir.path());
1942 run_git_command(remote_dir.path(), &["init", "--bare"]);
1943 let remote_path = remote_dir.path().to_string_lossy().to_string();
1944 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1945 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1946 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1947 run_git_command(
1948 contributor_dir.path(),
1949 &["clone", &remote_path, &contributor_clone_path_text],
1950 );
1951 run_git_command(
1952 &contributor_clone_path,
1953 &["config", "user.name", "Contributor User"],
1954 );
1955 run_git_command(
1956 &contributor_clone_path,
1957 &["config", "user.email", "contributor@example.com"],
1958 );
1959 run_git_command(
1960 &contributor_clone_path,
1961 &["checkout", "-B", "main", "origin/main"],
1962 );
1963 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1964 .expect("failed to write remote file");
1965 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1966 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1967 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1968 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1969 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1970 .expect("failed to write session file");
1971 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1972 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1973 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
1974 fs::write(
1975 temp_dir.path().join("session.txt"),
1976 "session change\nmore local\n",
1977 )
1978 .expect("failed to extend session file");
1979 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1980 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
1981 run_git_command(temp_dir.path(), &["fetch"]);
1982
1983 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
1985 .await
1986 .expect("failed to read branch tracking statuses");
1987
1988 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
1990 assert_eq!(
1991 branch_tracking_statuses.get("wt/1234abcd"),
1992 Some(&Some((1, 0)))
1993 );
1994 }
1995
1996 #[tokio::test]
1997 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
2002 let temp_dir = tempdir().expect("failed to create temp dir");
2004 setup_test_git_repo(temp_dir.path());
2005 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
2006 fs::write(temp_dir.path().join("session.txt"), "session work\n")
2007 .expect("failed to write session file");
2008 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2009 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
2010 fs::remove_file(temp_dir.path().join("session.txt"))
2011 .expect("failed to remove session file");
2012
2013 let result = commit_all_preserving_single_commit(
2017 temp_dir.path().to_path_buf(),
2018 "main".to_string(),
2019 "Session commit".to_string(),
2020 SingleCommitMessageStrategy::Replace,
2021 true,
2022 )
2023 .await;
2024
2025 let error = result.expect_err("amend-would-be-empty should fail");
2027 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
2028 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
2029 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
2030
2031 assert!(
2032 error.to_string().contains("Nothing to commit"),
2033 "expected 'Nothing to commit' sentinel but got: {error}"
2034 );
2035 assert_eq!(commit_count, "1");
2036 assert_eq!(head_message, "Initial commit");
2037 assert!(status.is_empty());
2038 }
2039}