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_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 { detail: String },
40}
41
42pub(crate) async fn commit_all(
56 repo_path: PathBuf,
57 commit_message: String,
58 no_verify: bool,
59) -> Result<(), GitError> {
60 commit_all_with_retry(
61 repo_path,
62 commit_message,
63 SingleCommitMessageStrategy::Replace,
64 no_verify,
65 false,
66 )
67 .await
68}
69
70pub(crate) async fn commit_all_preserving_single_commit(
92 repo_path: PathBuf,
93 base_branch: String,
94 commit_message: String,
95 message_strategy: SingleCommitMessageStrategy,
96 no_verify: bool,
97) -> Result<(), GitError> {
98 let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
99
100 commit_all_with_retry(
101 repo_path,
102 commit_message,
103 message_strategy,
104 no_verify,
105 amend_existing_commit,
106 )
107 .await
108}
109
110pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
121 spawn_blocking(move || stage_all_sync(&repo_path)).await?
122}
123
124pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
131 spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
132}
133
134pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
145 let hash = run_git_command(
146 repo_path,
147 vec![
148 "rev-parse".to_string(),
149 "--short".to_string(),
150 "HEAD".to_string(),
151 ],
152 "Failed to resolve HEAD hash".to_string(),
153 )
154 .await?;
155 let hash = hash.trim().to_string();
156 if hash.is_empty() {
157 return Err(GitError::OutputParse(
158 "Failed to resolve HEAD hash: empty output".to_string(),
159 ));
160 }
161
162 Ok(hash)
163}
164
165pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
176 let hash = run_git_command(
177 repo_path,
178 vec!["rev-parse".to_string(), "HEAD".to_string()],
179 "Failed to resolve HEAD hash".to_string(),
180 )
181 .await?;
182 let hash = hash.trim().to_string();
183 if hash.is_empty() {
184 return Err(GitError::OutputParse(
185 "Failed to resolve HEAD hash: empty output".to_string(),
186 ));
187 }
188
189 Ok(hash)
190}
191
192pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
204 let hash = run_git_command(
205 repo_path,
206 vec![
207 "rev-parse".to_string(),
208 "--verify".to_string(),
209 format!("{reference}^{{commit}}"),
210 ],
211 format!("Failed to resolve `{reference}` hash"),
212 )
213 .await?;
214 let hash = hash.trim().to_string();
215 if hash.is_empty() {
216 return Err(GitError::OutputParse(format!(
217 "Failed to resolve `{reference}` hash: empty output"
218 )));
219 }
220
221 Ok(hash)
222}
223
224pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
229 spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
230}
231
232pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
247 run_git_command_cancellable(
248 repo_path,
249 vec!["branch".to_string(), "-D".to_string(), branch_name],
250 "Git branch deletion failed".to_string(),
251 )
252 .await?;
253
254 Ok(())
255}
256
257pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
278 spawn_blocking(move || -> Result<String, GitError> {
279 run_git_command_sync(
280 &repo_path,
281 &["add", "-A", "--intent-to-add"],
282 "Git add --intent-to-add failed",
283 )?;
284
285 let merge_base_output =
286 run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
287
288 let diff_target = if merge_base_output.status.success() {
289 resolve_diff_target(
290 &repo_path,
291 &base_branch,
292 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
293 )?
294 } else {
295 base_branch
296 };
297
298 let diff_output = run_git_command_sync(
299 &repo_path,
300 &["diff", diff_target.as_str()],
301 "Git diff failed",
302 );
303 let reset_result = run_git_command_sync(&repo_path, &["reset"], "Git reset failed");
304
305 if let Err(diff_error) = diff_output {
306 return match reset_result {
307 Ok(_) => Err(diff_error),
308 Err(reset_error) => Err(GitError::CommandFailed {
309 command: "git diff".to_string(),
310 stderr: format!(
311 "{diff_error} Additionally failed to restore index state: {reset_error}"
312 ),
313 }),
314 };
315 }
316
317 reset_result?;
318
319 diff_output
320 })
321 .await?
322}
323
324pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
335 let status_output = worktree_status(repo_path).await?;
336
337 Ok(status_output.trim().is_empty())
338}
339
340pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
354 run_git_command(
355 repo_path,
356 vec![
357 "status".to_string(),
358 "--porcelain=v1".to_string(),
359 "--untracked-files=all".to_string(),
360 ],
361 "Git status --porcelain=v1 failed".to_string(),
362 )
363 .await
364}
365
366pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
381 run_git_command(
382 repo_path,
383 vec![
384 "status".to_string(),
385 "--porcelain=v1".to_string(),
386 "--untracked-files=no".to_string(),
387 ],
388 "Git tracked status --porcelain=v1 failed".to_string(),
389 )
390 .await
391}
392
393pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
409 spawn_blocking(move || {
410 let pull_arguments = pull_rebase_arguments(&repo_path)
411 .unwrap_or_else(|_| vec!["pull".to_string(), "--rebase".to_string()]);
412 let pull_argument_refs: Vec<&str> = pull_arguments.iter().map(String::as_str).collect();
413 let output = run_git_command_with_index_lock_retry(
414 &repo_path,
415 &pull_argument_refs,
416 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
417 )?;
418
419 if output.status.success() {
420 return Ok(PullRebaseResult::Completed);
421 }
422
423 let detail = command_output_detail(&output.stdout, &output.stderr);
424 if is_rebase_conflict(&detail) {
425 return Ok(PullRebaseResult::Conflict { detail });
426 }
427
428 Err(GitError::CommandFailed {
429 command: "git pull --rebase".to_string(),
430 stderr: detail,
431 })
432 })
433 .await?
434}
435
436fn pull_rebase_arguments(repo_path: &Path) -> Result<Vec<String>, GitError> {
441 let upstream_reference = primary_upstream_reference(repo_path)?;
442
443 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
444 return Ok(vec![
445 "pull".to_string(),
446 "--rebase".to_string(),
447 remote_name.to_string(),
448 branch_name.to_string(),
449 ]);
450 }
451
452 let remote_name = current_branch_remote_name(repo_path)?;
453
454 Ok(vec![
455 "pull".to_string(),
456 "--rebase".to_string(),
457 remote_name,
458 upstream_reference,
459 ])
460}
461
462fn primary_upstream_reference(repo_path: &Path) -> Result<String, GitError> {
468 let upstream_reference = upstream_reference_name(repo_path)?;
469 let Some(primary_reference) = upstream_reference
470 .lines()
471 .map(str::trim)
472 .find(|line| !line.is_empty())
473 else {
474 return Err(GitError::OutputParse(
475 "Failed to resolve upstream branch: empty output".to_string(),
476 ));
477 };
478
479 Ok(primary_reference.to_string())
480}
481
482fn upstream_reference_name(repo_path: &Path) -> Result<String, GitError> {
484 let upstream_reference = run_git_command_sync(
485 repo_path,
486 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
487 "Failed to resolve upstream branch",
488 )?;
489 let upstream_reference = upstream_reference.trim().to_string();
490 if upstream_reference.is_empty() {
491 return Err(GitError::OutputParse(
492 "Failed to resolve upstream branch: empty output".to_string(),
493 ));
494 }
495
496 Ok(upstream_reference)
497}
498
499fn current_branch_remote_name(repo_path: &Path) -> Result<String, GitError> {
504 let current_branch_name = current_branch_name(repo_path)?;
505 let remote_config_key = format!("branch.{current_branch_name}.remote");
506 let remote_name = run_git_command_sync(
507 repo_path,
508 &["config", "--get", &remote_config_key],
509 &format!("Failed to resolve current branch remote `{remote_config_key}`"),
510 )?;
511 let remote_name = remote_name.trim().to_string();
512 if remote_name.is_empty() {
513 return Err(GitError::OutputParse(format!(
514 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
515 )));
516 }
517
518 Ok(remote_name)
519}
520
521fn current_branch_name(repo_path: &Path) -> Result<String, GitError> {
523 let branch_name = run_git_command_sync(
524 repo_path,
525 &["rev-parse", "--abbrev-ref", "HEAD"],
526 "Failed to resolve current branch name",
527 )?;
528 let branch_name = branch_name.trim().to_string();
529 if branch_name.is_empty() {
530 return Err(GitError::OutputParse(
531 "Failed to resolve current branch name: empty output".to_string(),
532 ));
533 }
534
535 if branch_name == "HEAD" {
536 return Err(GitError::OutputParse(
537 "Failed to resolve current branch name: detached HEAD".to_string(),
538 ));
539 }
540
541 Ok(branch_name)
542}
543
544pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
561 spawn_blocking(move || -> Result<String, GitError> {
562 let push_output = run_git_command_output_sync(&repo_path, &["push", "--force-with-lease"])?;
563
564 if push_output.status.success() {
565 return primary_upstream_reference(&repo_path);
566 }
567
568 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
569 if !is_no_upstream_error(&push_detail) {
570 return Err(GitError::CommandFailed {
571 command: "git push".to_string(),
572 stderr: push_detail,
573 });
574 }
575
576 run_git_command_sync(
577 &repo_path,
578 &[
579 "push",
580 "--force-with-lease",
581 "--set-upstream",
582 "origin",
583 "HEAD",
584 ],
585 "Git push failed",
586 )?;
587
588 primary_upstream_reference(&repo_path)
589 })
590 .await?
591}
592
593pub(crate) async fn remote_branch_exists(
606 repo_path: PathBuf,
607 remote_branch_name: String,
608) -> Result<bool, GitError> {
609 spawn_blocking(move || -> Result<bool, GitError> {
610 let remote_name =
611 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
612 let output = run_git_command_output_sync(
613 &repo_path,
614 &["ls-remote", "--heads", &remote_name, &remote_branch_name],
615 )?;
616
617 if !output.status.success() {
618 let detail = command_output_detail(&output.stdout, &output.stderr);
619
620 return Err(GitError::CommandFailed {
621 command: "git ls-remote".to_string(),
622 stderr: detail,
623 });
624 }
625
626 let stdout = String::from_utf8_lossy(&output.stdout);
627
628 Ok(!stdout.trim().is_empty())
629 })
630 .await?
631}
632
633pub(crate) async fn push_current_branch_to_remote_branch(
650 repo_path: PathBuf,
651 remote_branch_name: String,
652) -> Result<String, GitError> {
653 spawn_blocking(move || -> Result<String, GitError> {
654 let remote_name =
655 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
656 let push_refspec = format!("HEAD:{remote_branch_name}");
657
658 run_git_command_sync(
659 &repo_path,
660 &[
661 "push",
662 "--force-with-lease",
663 "--set-upstream",
664 &remote_name,
665 &push_refspec,
666 ],
667 "Git push failed",
668 )?;
669
670 Ok(format!("{remote_name}/{remote_branch_name}"))
671 })
672 .await?
673}
674
675pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
687 spawn_blocking(move || primary_upstream_reference(&repo_path)).await?
688}
689
690pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
701 run_git_command(
702 repo_path,
703 vec!["fetch".to_string()],
704 "Git fetch failed".to_string(),
705 )
706 .await?;
707
708 Ok(())
709}
710
711pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
723 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
724}
725
726pub(crate) async fn get_ref_ahead_behind(
736 repo_path: PathBuf,
737 left_ref: String,
738 right_ref: String,
739) -> Result<(u32, u32), GitError> {
740 let rev_list_output = run_git_command(
741 repo_path,
742 vec![
743 "rev-list".to_string(),
744 "--left-right".to_string(),
745 "--count".to_string(),
746 format!("{left_ref}...{right_ref}"),
747 ],
748 "Git rev-list failed".to_string(),
749 )
750 .await?;
751
752 parse_ahead_behind_counts(&rev_list_output)
753}
754
755fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
758 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
759 if parts.len() >= 2 {
760 let ahead = parts[0].parse().unwrap_or(0);
761 let behind = parts[1].parse().unwrap_or(0);
762
763 return Ok((ahead, behind));
764 }
765
766 Err(GitError::OutputParse(
767 "Unexpected output format from git rev-list".to_string(),
768 ))
769}
770
771pub(crate) async fn branch_tracking_statuses(
780 repo_path: PathBuf,
781) -> Result<BranchTrackingMap, GitError> {
782 let git_output = run_git_command(
783 repo_path,
784 vec![
785 "for-each-ref".to_string(),
786 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
787 "refs/heads".to_string(),
788 ],
789 "Git for-each-ref failed".to_string(),
790 )
791 .await?;
792
793 Ok(parse_branch_tracking_statuses(&git_output))
794}
795
796pub(crate) async fn list_upstream_commit_titles(
807 repo_path: PathBuf,
808) -> Result<Vec<String>, GitError> {
809 let git_output = run_git_command(
810 repo_path,
811 vec![
812 "log".to_string(),
813 "--reverse".to_string(),
814 "--pretty=%s".to_string(),
815 "HEAD..@{u}".to_string(),
816 ],
817 "Git log failed".to_string(),
818 )
819 .await?;
820
821 Ok(parse_commit_titles(&git_output))
822}
823
824pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
835 let git_output = run_git_command(
836 repo_path,
837 vec![
838 "log".to_string(),
839 "--reverse".to_string(),
840 "--pretty=%s".to_string(),
841 "@{u}..HEAD".to_string(),
842 ],
843 "Git log failed".to_string(),
844 )
845 .await?;
846
847 Ok(parse_commit_titles(&git_output))
848}
849
850pub(crate) async fn has_commits_since(
856 repo_path: PathBuf,
857 base_branch: String,
858) -> Result<bool, GitError> {
859 spawn_blocking(move || -> Result<bool, GitError> {
860 let rev_list_output = run_git_command_sync(
861 &repo_path,
862 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
863 "Failed to count commits since base branch",
864 )?;
865 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
866 GitError::OutputParse(format!(
867 "Failed to parse commit count since base branch `{base_branch}`: {error}"
868 ))
869 })?;
870
871 Ok(commit_count > 0)
872 })
873 .await?
874}
875
876fn parse_commit_titles(output: &str) -> Vec<String> {
878 output
879 .lines()
880 .map(str::trim)
881 .filter(|title| !title.is_empty())
882 .map(ToString::to_string)
883 .collect()
884}
885
886fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
888 let mut branch_tracking_statuses = HashMap::new();
889
890 for line in output
891 .lines()
892 .map(str::trim)
893 .filter(|line| !line.is_empty())
894 {
895 let mut parts = line.splitn(3, '\t');
896 let Some(branch_name) = parts
897 .next()
898 .map(str::trim)
899 .filter(|value| !value.is_empty())
900 else {
901 continue;
902 };
903 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
904 let track = parts.next().map(str::trim).unwrap_or_default();
905
906 let status = if upstream_ref.is_empty() {
907 None
908 } else {
909 parse_branch_tracking_counts(track)
910 };
911 branch_tracking_statuses.insert(branch_name.to_string(), status);
912 }
913
914 branch_tracking_statuses
915}
916
917fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
919 let normalized_track = track.trim();
920 if normalized_track.is_empty() || normalized_track == "gone" {
921 return None;
922 }
923
924 let mut ahead = 0;
925 let mut behind = 0;
926
927 for part in normalized_track.split(',').map(str::trim) {
928 if let Some(count) = part.strip_prefix("ahead ") {
929 ahead = count.parse().ok()?;
930 } else if let Some(count) = part.strip_prefix("behind ") {
931 behind = count.parse().ok()?;
932 }
933 }
934
935 Some((ahead, behind))
936}
937
938fn resolve_diff_target(
944 repo_path: &Path,
945 base_branch: &str,
946 merge_base: &str,
947) -> Result<String, GitError> {
948 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
949 if !cherry_output.status.success() {
950 return Ok(merge_base.to_string());
951 }
952
953 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
954 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
955 return Ok(merge_base.to_string());
956 };
957
958 Ok(last_leading_applied_commit.to_string())
959}
960
961fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
968 let mut last_applied_commit = None;
969
970 for line in cherry_output.lines() {
971 let trimmed_line = line.trim();
972 if trimmed_line.is_empty() {
973 continue;
974 }
975
976 let mut parts = trimmed_line.split_whitespace();
977 let marker = parts.next()?;
978 let commit_hash = parts.next()?;
979
980 if marker == "-" {
981 last_applied_commit = Some(commit_hash);
982
983 continue;
984 }
985
986 if marker == "+" {
987 break;
988 }
989
990 break;
991 }
992
993 last_applied_commit
994}
995
996async fn commit_all_with_retry(
1004 repo_path: PathBuf,
1005 commit_message: String,
1006 message_strategy: SingleCommitMessageStrategy,
1007 no_verify: bool,
1008 amend_existing_commit: bool,
1009) -> Result<(), GitError> {
1010 spawn_blocking(move || {
1011 stage_all_sync(&repo_path)?;
1012
1013 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1014 let output = run_commit_command(
1015 &repo_path,
1016 &commit_message,
1017 message_strategy,
1018 no_verify,
1019 amend_existing_commit,
1020 )?;
1021
1022 if output.status.success() {
1023 return Ok(());
1024 }
1025
1026 let stderr = String::from_utf8_lossy(&output.stderr);
1027 let stdout = String::from_utf8_lossy(&output.stdout);
1028 if is_nothing_to_commit_output(&stdout, &stderr) {
1029 return Err(nothing_to_commit_error());
1030 }
1031
1032 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1033 reset_empty_amend_sync(&repo_path)?;
1034
1035 return Err(nothing_to_commit_error());
1036 }
1037
1038 if is_hook_modified_error(&stdout, &stderr) {
1039 stage_all_sync(&repo_path)?;
1040
1041 continue;
1042 }
1043
1044 let detail = command_output_detail(&output.stdout, &output.stderr);
1045
1046 return Err(GitError::CommandFailed {
1047 command: "git commit".to_string(),
1048 stderr: detail,
1049 });
1050 }
1051
1052 Err(GitError::CommandFailed {
1053 command: "git commit".to_string(),
1054 stderr: format!(
1055 "Failed to commit: commit hooks kept modifying files after \
1056 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1057 ),
1058 })
1059 })
1060 .await?
1061}
1062
1063fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1066 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1067 .iter()
1068 .find(|config_file| repo_path.join(config_file).is_file())
1069 else {
1070 return Ok(());
1071 };
1072 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1073
1074 if is_executable_hook(&hook_path) {
1075 return Ok(());
1076 }
1077
1078 Err(GitError::PreCommitHookMissing {
1079 config_file: (*config_file).to_string(),
1080 })
1081}
1082
1083fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1085 let hooks_path_output =
1086 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1087 let hooks_path = if hooks_path_output.status.success() {
1088 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1089 } else if hooks_path_output.status.code() == Some(1) {
1090 let default_hook_path = run_git_command_sync(
1091 repo_path,
1092 &["rev-parse", "--git-path", "hooks/pre-commit"],
1093 "Failed to resolve Git pre-commit hook path",
1094 )?;
1095
1096 return Ok(resolve_repo_path(
1097 repo_path,
1098 PathBuf::from(default_hook_path.trim()),
1099 ));
1100 } else {
1101 return Err(GitError::CommandFailed {
1102 command: "git config --path --get core.hooksPath".to_string(),
1103 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1104 });
1105 };
1106
1107 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1108}
1109
1110fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1111 if path.is_absolute() {
1112 return path;
1113 }
1114
1115 repo_path.join(path)
1116}
1117
1118#[cfg(unix)]
1119fn is_executable_hook(hook_path: &Path) -> bool {
1120 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1121}
1122
1123#[cfg(not(unix))]
1124fn is_executable_hook(hook_path: &Path) -> bool {
1125 hook_path.is_file()
1126}
1127
1128fn nothing_to_commit_error() -> GitError {
1130 GitError::CommandFailed {
1131 command: "git commit".to_string(),
1132 stderr: "Nothing to commit: no changes detected".to_string(),
1133 }
1134}
1135
1136fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1139 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1140
1141 combined.contains("nothing to commit")
1142}
1143
1144fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1147 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1148 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1149
1150 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1151}
1152
1153fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1156 run_git_command_sync(
1157 repo_path,
1158 &["reset", "HEAD^"],
1159 "Git reset after empty amend failed",
1160 )?;
1161
1162 Ok(())
1163}
1164
1165fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1169 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1170
1171 if !output.status.success() {
1172 let detail = command_output_detail(&output.stdout, &output.stderr);
1173
1174 return Err(GitError::CommandFailed {
1175 command: "git add -A".to_string(),
1176 stderr: format!("Failed to stage changes: {detail}"),
1177 });
1178 }
1179
1180 Ok(())
1181}
1182
1183fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1185 if !has_head_commit_sync(repo_path)? {
1186 return Ok(None);
1187 }
1188
1189 let output = run_git_command_sync(
1190 repo_path,
1191 &["log", "-1", "--pretty=%B"],
1192 "Failed to read HEAD commit message",
1193 )?;
1194
1195 Ok(Some(output.trim().to_string()))
1196}
1197
1198fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1200 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1201
1202 if output.status.success() {
1203 return Ok(true);
1204 }
1205
1206 let detail = command_output_detail(&output.stdout, &output.stderr);
1207 let normalized_detail = detail.to_ascii_lowercase();
1208 if normalized_detail.contains("needed a single revision")
1209 || normalized_detail.contains("unknown revision")
1210 || normalized_detail.contains("does not have any commits yet")
1211 {
1212 return Ok(false);
1213 }
1214
1215 Err(GitError::CommandFailed {
1216 command: "git rev-parse --verify HEAD".to_string(),
1217 stderr: detail,
1218 })
1219}
1220
1221fn run_commit_command(
1225 repo_path: &Path,
1226 commit_message: &str,
1227 message_strategy: SingleCommitMessageStrategy,
1228 no_verify: bool,
1229 amend_existing_commit: bool,
1230) -> Result<Output, GitError> {
1231 let mut args = vec!["commit"];
1232 if amend_existing_commit {
1233 args.push("--amend");
1234 match message_strategy {
1235 SingleCommitMessageStrategy::Replace => {
1236 args.push("-m");
1237 args.push(commit_message);
1238 }
1239 SingleCommitMessageStrategy::Reuse => {
1240 args.push("--no-edit");
1241 }
1242 }
1243 } else {
1244 args.push("-m");
1245 args.push(commit_message);
1246 }
1247
1248 if no_verify {
1249 args.push("--no-verify");
1250 }
1251
1252 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1253}
1254
1255fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1257 let combined = format!(
1258 "{stdout}
1259{stderr}"
1260 )
1261 .to_ascii_lowercase();
1262
1263 combined.contains("files were modified by this hook")
1264}
1265
1266pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1268 let normalized_detail = detail.to_ascii_lowercase();
1269
1270 normalized_detail.contains("has no upstream branch")
1271 || normalized_detail.contains("no upstream branch")
1272 || normalized_detail.contains("set-upstream")
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use std::fs;
1278 #[cfg(unix)]
1279 use std::os::unix::fs::PermissionsExt;
1280 use std::path::Path;
1281 use std::process::{Command, Output};
1282
1283 use tempfile::tempdir;
1284
1285 use super::*;
1286
1287 fn run_git_command(repo_path: &Path, args: &[&str]) {
1289 let output = git_command_output(repo_path, args);
1290
1291 assert!(
1292 output.status.success(),
1293 "git command {:?} failed: {}",
1294 args,
1295 String::from_utf8_lossy(&output.stderr)
1296 );
1297 }
1298
1299 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1301 Command::new("git")
1302 .args(args)
1303 .current_dir(repo_path)
1304 .output()
1305 .expect("failed to run git command")
1306 }
1307
1308 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1310 let output = git_command_output(repo_path, args);
1311
1312 assert!(
1313 output.status.success(),
1314 "git command {:?} failed: {}",
1315 args,
1316 String::from_utf8_lossy(&output.stderr)
1317 );
1318
1319 String::from_utf8(output.stdout)
1320 .expect("git stdout should be valid utf-8")
1321 .trim()
1322 .to_string()
1323 }
1324
1325 fn setup_test_git_repo(repo_path: &Path) {
1327 run_git_command(repo_path, &["init", "-b", "main"]);
1328 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1329 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1330 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1331 run_git_command(repo_path, &["add", "README.md"]);
1332 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1333 }
1334
1335 #[cfg(unix)]
1336 fn write_executable_pre_commit_hook(hook_path: &Path) {
1337 fs::create_dir_all(
1338 hook_path
1339 .parent()
1340 .expect("pre-commit hook should have a parent directory"),
1341 )
1342 .expect("failed to create hooks directory");
1343 fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1344 let mut permissions = fs::metadata(hook_path)
1345 .expect("failed to read pre-commit hook metadata")
1346 .permissions();
1347 permissions.set_mode(0o755);
1348 fs::set_permissions(hook_path, permissions)
1349 .expect("failed to make pre-commit hook executable");
1350 }
1351
1352 #[test]
1353 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
1354 let temp_dir = tempdir().expect("failed to create temp dir");
1356 setup_test_git_repo(temp_dir.path());
1357
1358 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1360
1361 assert!(result.is_ok());
1363 }
1364
1365 #[test]
1366 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
1367 let temp_dir = tempdir().expect("failed to create temp dir");
1369 setup_test_git_repo(temp_dir.path());
1370 fs::write(
1371 temp_dir.path().join(".pre-commit-config.yaml"),
1372 "repos: []\n",
1373 )
1374 .expect("failed to write pre-commit configuration");
1375
1376 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1378
1379 assert!(matches!(
1381 result,
1382 Err(GitError::PreCommitHookMissing { ref config_file })
1383 if config_file == ".pre-commit-config.yaml"
1384 ));
1385 }
1386
1387 #[cfg(unix)]
1388 #[test]
1389 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
1390 let temp_dir = tempdir().expect("failed to create temp dir");
1392 setup_test_git_repo(temp_dir.path());
1393 fs::write(
1394 temp_dir.path().join(".pre-commit-config.yaml"),
1395 "repos: []\n",
1396 )
1397 .expect("failed to write pre-commit configuration");
1398 let hook_path = temp_dir.path().join(git_command_stdout(
1399 temp_dir.path(),
1400 &["rev-parse", "--git-path", "hooks/pre-commit"],
1401 ));
1402 write_executable_pre_commit_hook(&hook_path);
1403
1404 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1406
1407 assert!(result.is_ok());
1409 }
1410
1411 #[cfg(unix)]
1412 #[test]
1413 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
1414 let temp_dir = tempdir().expect("failed to create temp dir");
1416 setup_test_git_repo(temp_dir.path());
1417 fs::write(
1418 temp_dir.path().join(".pre-commit-config.yaml"),
1419 "repos: []\n",
1420 )
1421 .expect("failed to write pre-commit configuration");
1422 run_git_command(
1423 temp_dir.path(),
1424 &["config", "core.hooksPath", ".custom-hooks"],
1425 );
1426 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
1427
1428 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1430
1431 assert!(result.is_ok());
1433 }
1434
1435 #[cfg(unix)]
1436 #[test]
1437 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
1438 let temp_dir = tempdir().expect("failed to create temp dir");
1440 setup_test_git_repo(temp_dir.path());
1441 fs::write(
1442 temp_dir.path().join(".pre-commit-config.yaml"),
1443 "repos: []\n",
1444 )
1445 .expect("failed to write pre-commit configuration");
1446 let hook_path = temp_dir.path().join(git_command_stdout(
1447 temp_dir.path(),
1448 &["rev-parse", "--git-path", "hooks/pre-commit"],
1449 ));
1450 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1451 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
1452 .expect("failed to set mismatched execute permissions");
1453
1454 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1456
1457 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
1459 }
1460
1461 #[tokio::test]
1462 async fn commit_all_allows_configured_validation_without_hook() {
1463 let temp_dir = tempdir().expect("failed to create temp dir");
1465 setup_test_git_repo(temp_dir.path());
1466 fs::write(
1467 temp_dir.path().join(".pre-commit-config.yaml"),
1468 "repos: []\n",
1469 )
1470 .expect("failed to write pre-commit configuration");
1471 fs::write(temp_dir.path().join("README.md"), "changed\n")
1472 .expect("failed to write worktree change");
1473
1474 let result = commit_all(
1476 temp_dir.path().to_path_buf(),
1477 "Change README".to_string(),
1478 false,
1479 )
1480 .await;
1481
1482 assert!(result.is_ok());
1484 assert_eq!(
1485 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
1486 "Change README"
1487 );
1488 }
1489
1490 #[test]
1491 fn current_branch_name_returns_error_for_detached_head() {
1492 let temp_dir = tempdir().expect("failed to create temp dir");
1494 setup_test_git_repo(temp_dir.path());
1495 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1496
1497 let result = current_branch_name(temp_dir.path());
1499
1500 let error = result.expect_err("detached HEAD should fail");
1502 assert!(error.to_string().contains("detached HEAD"));
1503 }
1504
1505 #[test]
1506 fn primary_upstream_reference_uses_first_non_empty_line() {
1507 let temp_dir = tempdir().expect("failed to create temp dir");
1509 let remote_dir = tempdir().expect("failed to create remote temp dir");
1510 setup_test_git_repo(temp_dir.path());
1511 run_git_command(remote_dir.path(), &["init", "--bare"]);
1512 let remote_path = remote_dir.path().to_string_lossy().to_string();
1513 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1514 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1515 run_git_command(
1516 temp_dir.path(),
1517 &[
1518 "config",
1519 "--replace-all",
1520 "branch.main.merge",
1521 "refs/heads/main",
1522 ],
1523 );
1524 run_git_command(
1525 temp_dir.path(),
1526 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1527 );
1528
1529 let upstream_reference =
1531 primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1532
1533 assert_eq!(upstream_reference, "origin/main");
1535 }
1536
1537 #[test]
1538 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1539 let output = "\
1541main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1542 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1543
1544 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1546
1547 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1549 assert_eq!(
1550 branch_tracking_statuses.get("wt/1234abcd"),
1551 Some(&Some((3, 1)))
1552 );
1553 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1554 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1555 }
1556
1557 #[tokio::test]
1558 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1559 let temp_dir = tempdir().expect("failed to create temp dir");
1561 let remote_dir = tempdir().expect("failed to create remote temp dir");
1562 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1563 let contributor_clone_path = contributor_dir.path().join("clone");
1564 setup_test_git_repo(temp_dir.path());
1565 run_git_command(remote_dir.path(), &["init", "--bare"]);
1566 let remote_path = remote_dir.path().to_string_lossy().to_string();
1567 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1568 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1569 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1570 fs::write(temp_dir.path().join("README.md"), "local change\n")
1571 .expect("failed to write local change");
1572 run_git_command(temp_dir.path(), &["add", "README.md"]);
1573 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1574 run_git_command(
1575 contributor_dir.path(),
1576 &["clone", &remote_path, &contributor_clone_path_text],
1577 );
1578 run_git_command(
1579 &contributor_clone_path,
1580 &["config", "user.name", "Contributor User"],
1581 );
1582 run_git_command(
1583 &contributor_clone_path,
1584 &["config", "user.email", "contributor@example.com"],
1585 );
1586 run_git_command(
1587 &contributor_clone_path,
1588 &["checkout", "-B", "main", "origin/main"],
1589 );
1590 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1591 .expect("failed to write remote change");
1592 run_git_command(&contributor_clone_path, &["add", "README.md"]);
1593 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1594 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1595
1596 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1598
1599 assert!(matches!(
1601 result,
1602 Ok(PullRebaseResult::Conflict { ref detail })
1603 if {
1604 let normalized_detail = detail.to_ascii_lowercase();
1605
1606 (normalized_detail.contains("conflict")
1607 || normalized_detail.contains("could not apply"))
1608 && !detail.is_empty()
1609 }
1610 ));
1611 }
1612
1613 #[tokio::test]
1614 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1615 let temp_dir = tempdir().expect("failed to create temp dir");
1617 let remote_dir = tempdir().expect("failed to create remote temp dir");
1618 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1619 let contributor_clone_path = contributor_dir.path().join("clone");
1620 setup_test_git_repo(temp_dir.path());
1621 run_git_command(remote_dir.path(), &["init", "--bare"]);
1622 let remote_path = remote_dir.path().to_string_lossy().to_string();
1623 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1624 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1625 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1626 run_git_command(
1627 contributor_dir.path(),
1628 &["clone", &remote_path, &contributor_clone_path_text],
1629 );
1630 run_git_command(
1631 &contributor_clone_path,
1632 &["config", "user.name", "Contributor User"],
1633 );
1634 run_git_command(
1635 &contributor_clone_path,
1636 &["config", "user.email", "contributor@example.com"],
1637 );
1638 run_git_command(
1639 &contributor_clone_path,
1640 &["checkout", "-B", "main", "origin/main"],
1641 );
1642 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1643 .expect("failed to write remote file");
1644 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1645 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1646 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1647 fs::write(temp_dir.path().join("local.txt"), "local change")
1648 .expect("failed to write local file");
1649 run_git_command(temp_dir.path(), &["add", "local.txt"]);
1650 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1651
1652 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
1654
1655 let error = result
1657 .expect_err("non-fast-forward push should fail")
1658 .to_string();
1659 assert!(error.contains("git push"));
1660 assert!(
1661 error.contains("stale info")
1662 || error.contains("rejected")
1663 || error.contains("fetch first")
1664 );
1665 }
1666
1667 #[tokio::test]
1668 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
1669 let temp_dir = tempdir().expect("failed to create temp dir");
1671 let remote_dir = tempdir().expect("failed to create remote temp dir");
1672 setup_test_git_repo(temp_dir.path());
1673 run_git_command(remote_dir.path(), &["init", "--bare"]);
1674 let remote_path = remote_dir.path().to_string_lossy().to_string();
1675 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1676 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1677 fs::write(
1678 temp_dir.path().join("README.md"),
1679 "first published version\n",
1680 )
1681 .expect("failed to write first version");
1682 run_git_command(temp_dir.path(), &["add", "README.md"]);
1683 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
1684 push_current_branch(temp_dir.path().to_path_buf())
1685 .await
1686 .expect("initial push should succeed");
1687 fs::write(
1688 temp_dir.path().join("README.md"),
1689 "rewritten published version\n",
1690 )
1691 .expect("failed to rewrite published version");
1692 run_git_command(temp_dir.path(), &["add", "README.md"]);
1693 run_git_command(
1694 temp_dir.path(),
1695 &["commit", "--amend", "-m", "Rewrite published branch change"],
1696 );
1697
1698 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
1700 .await
1701 .expect("force-with-lease push should update rewritten history");
1702 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
1703 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
1704
1705 assert_eq!(upstream_reference, "origin/main");
1707 assert_eq!(local_head, remote_head);
1708 }
1709
1710 #[tokio::test]
1711 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
1712 let temp_dir = tempdir().expect("failed to create temp dir");
1714 let remote_dir = tempdir().expect("failed to create remote temp dir");
1715 setup_test_git_repo(temp_dir.path());
1716 run_git_command(remote_dir.path(), &["init", "--bare"]);
1717 let remote_path = remote_dir.path().to_string_lossy().to_string();
1718 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1719
1720 let upstream_reference = push_current_branch_to_remote_branch(
1722 temp_dir.path().to_path_buf(),
1723 "review/custom-branch".to_string(),
1724 )
1725 .await
1726 .expect("failed to push current branch to custom remote branch");
1727
1728 assert_eq!(upstream_reference, "origin/review/custom-branch");
1730 }
1731
1732 #[tokio::test]
1733 async fn current_upstream_reference_returns_origin_main() {
1734 let temp_dir = tempdir().expect("failed to create temp dir");
1736 let remote_dir = tempdir().expect("failed to create remote temp dir");
1737 setup_test_git_repo(temp_dir.path());
1738 run_git_command(remote_dir.path(), &["init", "--bare"]);
1739 let remote_path = remote_dir.path().to_string_lossy().to_string();
1740 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1741 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1742
1743 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
1745 .await
1746 .expect("failed to resolve upstream reference");
1747
1748 assert_eq!(upstream_reference, "origin/main");
1750 }
1751
1752 #[tokio::test]
1753 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
1754 let temp_dir = tempdir().expect("failed to create temp dir");
1756 setup_test_git_repo(temp_dir.path());
1757 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1758 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1759 .expect("failed to write session file");
1760 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1761 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1762 run_git_command(temp_dir.path(), &["checkout", "main"]);
1763 fs::write(temp_dir.path().join("main.txt"), "main change\n")
1764 .expect("failed to write main file");
1765 run_git_command(temp_dir.path(), &["add", "main.txt"]);
1766 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
1767
1768 let status = get_ref_ahead_behind(
1770 temp_dir.path().to_path_buf(),
1771 "wt/1234abcd".to_string(),
1772 "main".to_string(),
1773 )
1774 .await
1775 .expect("failed to compare branch refs");
1776
1777 assert_eq!(status, (1, 1));
1779 }
1780
1781 #[tokio::test]
1782 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
1783 let temp_dir = tempdir().expect("failed to create temp dir");
1785 let remote_dir = tempdir().expect("failed to create remote temp dir");
1786 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1787 let contributor_clone_path = contributor_dir.path().join("clone");
1788 setup_test_git_repo(temp_dir.path());
1789 run_git_command(remote_dir.path(), &["init", "--bare"]);
1790 let remote_path = remote_dir.path().to_string_lossy().to_string();
1791 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1792 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1793 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1794 run_git_command(
1795 contributor_dir.path(),
1796 &["clone", &remote_path, &contributor_clone_path_text],
1797 );
1798 run_git_command(
1799 &contributor_clone_path,
1800 &["config", "user.name", "Contributor User"],
1801 );
1802 run_git_command(
1803 &contributor_clone_path,
1804 &["config", "user.email", "contributor@example.com"],
1805 );
1806 run_git_command(
1807 &contributor_clone_path,
1808 &["checkout", "-B", "main", "origin/main"],
1809 );
1810 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1811 .expect("failed to write remote file");
1812 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1813 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1814 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1815 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
1816 fs::write(temp_dir.path().join("session.txt"), "session change\n")
1817 .expect("failed to write session file");
1818 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1819 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
1820 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
1821 fs::write(
1822 temp_dir.path().join("session.txt"),
1823 "session change\nmore local\n",
1824 )
1825 .expect("failed to extend session file");
1826 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1827 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
1828 run_git_command(temp_dir.path(), &["fetch"]);
1829
1830 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
1832 .await
1833 .expect("failed to read branch tracking statuses");
1834
1835 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
1837 assert_eq!(
1838 branch_tracking_statuses.get("wt/1234abcd"),
1839 Some(&Some((1, 0)))
1840 );
1841 }
1842
1843 #[tokio::test]
1844 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
1849 let temp_dir = tempdir().expect("failed to create temp dir");
1851 setup_test_git_repo(temp_dir.path());
1852 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
1853 fs::write(temp_dir.path().join("session.txt"), "session work\n")
1854 .expect("failed to write session file");
1855 run_git_command(temp_dir.path(), &["add", "session.txt"]);
1856 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
1857 fs::remove_file(temp_dir.path().join("session.txt"))
1858 .expect("failed to remove session file");
1859
1860 let result = commit_all_preserving_single_commit(
1864 temp_dir.path().to_path_buf(),
1865 "main".to_string(),
1866 "Session commit".to_string(),
1867 SingleCommitMessageStrategy::Replace,
1868 true,
1869 )
1870 .await;
1871
1872 let error = result.expect_err("amend-would-be-empty should fail");
1874 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
1875 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
1876 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
1877
1878 assert!(
1879 error.to_string().contains("Nothing to commit"),
1880 "expected 'Nothing to commit' sentinel but got: {error}"
1881 );
1882 assert_eq!(commit_count, "1");
1883 assert_eq!(head_message, "Initial commit");
1884 assert!(status.is_empty());
1885 }
1886}