1use std::collections::HashMap;
2use std::io::Read;
3use std::path::{Component, Path, PathBuf};
4use std::process::Output;
5
6#[cfg(unix)]
7use rustix::fs::{self as rustix_fs, Access};
8use tokio::task::spawn_blocking;
9
10use super::error::GitError;
11use super::rebase::{is_rebase_conflict, run_git_command_with_index_lock_retry};
12use super::repo::{
13 command_output_detail, run_git_command, run_git_command_cancellable,
14 run_git_command_output_sync, run_git_command_output_with_env_sync, run_git_command_sync,
15};
16
17pub type BranchTrackingMap = HashMap<String, Option<(u32, u32)>>;
20
21const COMMIT_ALL_HOOK_RETRY_ATTEMPTS: usize = 5;
22const MAX_WORKTREE_FILE_BYTE_COUNT: usize = 1024 * 1024;
23const PRE_COMMIT_CONFIG_FILES: [&str; 2] = [".pre-commit-config.yaml", ".pre-commit-config.yml"];
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub enum WorktreeFileContent {
28 Text(String),
30 Missing,
32 Binary,
34 TooLarge,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum SingleCommitMessageStrategy {
42 Replace,
44 Reuse,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum PullRebaseResult {
51 Completed,
53 Conflict {
55 detail: String,
57 },
58}
59
60pub(crate) async fn commit_all(
74 repo_path: PathBuf,
75 commit_message: String,
76 no_verify: bool,
77) -> Result<(), GitError> {
78 commit_all_with_retry(
79 repo_path,
80 commit_message,
81 SingleCommitMessageStrategy::Replace,
82 no_verify,
83 false,
84 )
85 .await
86}
87
88pub(crate) async fn commit_all_preserving_single_commit(
110 repo_path: PathBuf,
111 base_branch: String,
112 commit_message: String,
113 message_strategy: SingleCommitMessageStrategy,
114 no_verify: bool,
115) -> Result<(), GitError> {
116 let amend_existing_commit = has_commits_since(repo_path.clone(), base_branch).await?;
117
118 commit_all_with_retry(
119 repo_path,
120 commit_message,
121 message_strategy,
122 no_verify,
123 amend_existing_commit,
124 )
125 .await
126}
127
128pub(crate) async fn stage_all(repo_path: PathBuf) -> Result<(), GitError> {
139 spawn_blocking(move || stage_all_sync(&repo_path)).await?
140}
141
142pub(crate) async fn check_pre_commit_hook_ready(repo_path: PathBuf) -> Result<(), GitError> {
149 spawn_blocking(move || ensure_pre_commit_hook_ready(&repo_path)).await?
150}
151
152pub(crate) async fn head_short_hash(repo_path: PathBuf) -> Result<String, GitError> {
163 let hash = run_git_command(
164 repo_path,
165 vec![
166 "rev-parse".to_string(),
167 "--short".to_string(),
168 "HEAD".to_string(),
169 ],
170 "Failed to resolve HEAD hash".to_string(),
171 )
172 .await?;
173 let hash = hash.trim().to_string();
174 if hash.is_empty() {
175 return Err(GitError::OutputParse(
176 "Failed to resolve HEAD hash: empty output".to_string(),
177 ));
178 }
179
180 Ok(hash)
181}
182
183pub(crate) async fn head_hash(repo_path: PathBuf) -> Result<String, GitError> {
194 let hash = run_git_command(
195 repo_path,
196 vec!["rev-parse".to_string(), "HEAD".to_string()],
197 "Failed to resolve HEAD hash".to_string(),
198 )
199 .await?;
200 let hash = hash.trim().to_string();
201 if hash.is_empty() {
202 return Err(GitError::OutputParse(
203 "Failed to resolve HEAD hash: empty output".to_string(),
204 ));
205 }
206
207 Ok(hash)
208}
209
210pub(crate) async fn ref_hash(repo_path: PathBuf, reference: String) -> Result<String, GitError> {
222 let hash = run_git_command(
223 repo_path,
224 vec![
225 "rev-parse".to_string(),
226 "--verify".to_string(),
227 format!("{reference}^{{commit}}"),
228 ],
229 format!("Failed to resolve `{reference}` hash"),
230 )
231 .await?;
232 let hash = hash.trim().to_string();
233 if hash.is_empty() {
234 return Err(GitError::OutputParse(format!(
235 "Failed to resolve `{reference}` hash: empty output"
236 )));
237 }
238
239 Ok(hash)
240}
241
242pub(crate) async fn head_commit_message(repo_path: PathBuf) -> Result<Option<String>, GitError> {
247 spawn_blocking(move || head_commit_message_sync(&repo_path)).await?
248}
249
250pub(crate) async fn delete_branch(repo_path: PathBuf, branch_name: String) -> Result<(), GitError> {
265 run_git_command_cancellable(
266 repo_path,
267 vec!["branch".to_string(), "-D".to_string(), branch_name],
268 "Git branch deletion failed".to_string(),
269 )
270 .await?;
271
272 Ok(())
273}
274
275pub(crate) async fn diff(repo_path: PathBuf, base_branch: String) -> Result<String, GitError> {
297 spawn_blocking(move || -> Result<String, GitError> {
298 let index_path = run_git_command_sync(
299 &repo_path,
300 &["rev-parse", "--git-path", "index"],
301 "Git index path resolution failed",
302 )?;
303 let index_path = PathBuf::from(index_path.trim());
304 let index_path = if index_path.is_absolute() {
305 index_path
306 } else {
307 repo_path.join(index_path)
308 };
309 let temporary_index = copy_git_index_to_temp(&index_path)?;
310
311 run_git_command_with_index_sync(
312 &repo_path,
313 &["add", "-A", "--intent-to-add"],
314 &temporary_index,
315 "Git add --intent-to-add failed",
316 )?;
317
318 let merge_base_output =
319 run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
320
321 let diff_target = if merge_base_output.status.success() {
322 resolve_diff_target(
323 &repo_path,
324 &base_branch,
325 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
326 )?
327 } else {
328 base_branch
329 };
330
331 run_git_command_with_index_sync(
332 &repo_path,
333 &["diff", diff_target.as_str()],
334 &temporary_index,
335 "Git diff failed",
336 )
337 })
338 .await?
339}
340
341pub(crate) async fn read_worktree_file(
350 repo_path: PathBuf,
351 relative_path: String,
352) -> Result<WorktreeFileContent, GitError> {
353 spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
354}
355
356fn read_worktree_file_sync(
358 repo_path: &Path,
359 relative_path: &str,
360) -> Result<WorktreeFileContent, GitError> {
361 let relative_file_path = Path::new(relative_path);
362 if relative_path.is_empty()
363 || relative_file_path
364 .components()
365 .any(|component| !matches!(component, Component::Normal(_)))
366 {
367 return Err(GitError::OutputParse(format!(
368 "Unsafe worktree file path: {relative_path}"
369 )));
370 }
371
372 let canonical_repo_path = std::fs::canonicalize(repo_path)?;
373 let candidate_path = repo_path.join(relative_file_path);
374 let canonical_file_path = match std::fs::canonicalize(candidate_path) {
375 Ok(path) => path,
376 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
377 return Ok(WorktreeFileContent::Missing);
378 }
379 Err(error) => return Err(error.into()),
380 };
381 if !canonical_file_path.starts_with(canonical_repo_path) {
382 return Err(GitError::OutputParse(format!(
383 "Worktree file resolves outside repository: {relative_path}"
384 )));
385 }
386
387 let file = std::fs::File::open(canonical_file_path)?;
388 let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
389 file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
390 .read_to_end(&mut bytes)?;
391
392 Ok(worktree_file_content(bytes))
393}
394
395fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
397 if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
398 return WorktreeFileContent::TooLarge;
399 }
400
401 match String::from_utf8(bytes) {
402 Ok(content) => WorktreeFileContent::Text(content),
403 Err(_) => WorktreeFileContent::Binary,
404 }
405}
406
407fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
410 let index_parent = index_path.parent().ok_or_else(|| {
411 GitError::OutputParse(format!(
412 "Git index path has no parent: {}",
413 index_path.display()
414 ))
415 })?;
416 let temporary_index =
417 tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
418 command: "create temporary git index".to_string(),
419 stderr: error.to_string(),
420 })?;
421 std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
422 command: "copy git index".to_string(),
423 stderr: error.to_string(),
424 })?;
425
426 Ok(temporary_index.into_temp_path())
427}
428
429fn run_git_command_with_index_sync(
432 repo_path: &Path,
433 args: &[&str],
434 index_path: &Path,
435 error_context: &str,
436) -> Result<String, GitError> {
437 let output = run_git_command_output_with_env_sync(
438 repo_path,
439 args,
440 &[("GIT_INDEX_FILE", index_path.as_os_str())],
441 )?;
442 if !output.status.success() {
443 return Err(GitError::CommandFailed {
444 command: format!("git {}", args.join(" ")),
445 stderr: format!(
446 "{error_context}: {}",
447 command_output_detail(&output.stdout, &output.stderr)
448 ),
449 });
450 }
451
452 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
453}
454
455pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
466 let status_output = worktree_status(repo_path).await?;
467
468 Ok(status_output.trim().is_empty())
469}
470
471pub(crate) async fn worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
485 run_git_command(
486 repo_path,
487 vec![
488 "status".to_string(),
489 "--porcelain=v1".to_string(),
490 "--untracked-files=all".to_string(),
491 ],
492 "Git status --porcelain=v1 failed".to_string(),
493 )
494 .await
495}
496
497pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
512 run_git_command(
513 repo_path,
514 vec![
515 "status".to_string(),
516 "--porcelain=v1".to_string(),
517 "--untracked-files=no".to_string(),
518 ],
519 "Git tracked status --porcelain=v1 failed".to_string(),
520 )
521 .await
522}
523
524pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
540 spawn_blocking(move || {
541 let pull_arguments = pull_rebase_arguments(&repo_path)
542 .unwrap_or_else(|_| vec!["pull".to_string(), "--rebase".to_string()]);
543 let pull_argument_refs: Vec<&str> = pull_arguments.iter().map(String::as_str).collect();
544 let output = run_git_command_with_index_lock_retry(
545 &repo_path,
546 &pull_argument_refs,
547 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
548 )?;
549
550 if output.status.success() {
551 return Ok(PullRebaseResult::Completed);
552 }
553
554 let detail = command_output_detail(&output.stdout, &output.stderr);
555 if is_rebase_conflict(&detail) {
556 return Ok(PullRebaseResult::Conflict { detail });
557 }
558
559 Err(GitError::CommandFailed {
560 command: "git pull --rebase".to_string(),
561 stderr: detail,
562 })
563 })
564 .await?
565}
566
567fn pull_rebase_arguments(repo_path: &Path) -> Result<Vec<String>, GitError> {
572 let upstream_reference = primary_upstream_reference(repo_path)?;
573
574 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
575 return Ok(vec![
576 "pull".to_string(),
577 "--rebase".to_string(),
578 remote_name.to_string(),
579 branch_name.to_string(),
580 ]);
581 }
582
583 let remote_name = current_branch_remote_name(repo_path)?;
584
585 Ok(vec![
586 "pull".to_string(),
587 "--rebase".to_string(),
588 remote_name,
589 upstream_reference,
590 ])
591}
592
593fn primary_upstream_reference(repo_path: &Path) -> Result<String, GitError> {
599 let upstream_reference = upstream_reference_name(repo_path)?;
600 let Some(primary_reference) = upstream_reference
601 .lines()
602 .map(str::trim)
603 .find(|line| !line.is_empty())
604 else {
605 return Err(GitError::OutputParse(
606 "Failed to resolve upstream branch: empty output".to_string(),
607 ));
608 };
609
610 Ok(primary_reference.to_string())
611}
612
613fn upstream_reference_name(repo_path: &Path) -> Result<String, GitError> {
615 let upstream_reference = run_git_command_sync(
616 repo_path,
617 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
618 "Failed to resolve upstream branch",
619 )?;
620 let upstream_reference = upstream_reference.trim().to_string();
621 if upstream_reference.is_empty() {
622 return Err(GitError::OutputParse(
623 "Failed to resolve upstream branch: empty output".to_string(),
624 ));
625 }
626
627 Ok(upstream_reference)
628}
629
630fn current_branch_remote_name(repo_path: &Path) -> Result<String, GitError> {
635 let current_branch_name = current_branch_name(repo_path)?;
636 let remote_config_key = format!("branch.{current_branch_name}.remote");
637 let remote_name = run_git_command_sync(
638 repo_path,
639 &["config", "--get", &remote_config_key],
640 &format!("Failed to resolve current branch remote `{remote_config_key}`"),
641 )?;
642 let remote_name = remote_name.trim().to_string();
643 if remote_name.is_empty() {
644 return Err(GitError::OutputParse(format!(
645 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
646 )));
647 }
648
649 Ok(remote_name)
650}
651
652fn current_branch_name(repo_path: &Path) -> Result<String, GitError> {
654 let branch_name = run_git_command_sync(
655 repo_path,
656 &["rev-parse", "--abbrev-ref", "HEAD"],
657 "Failed to resolve current branch name",
658 )?;
659 let branch_name = branch_name.trim().to_string();
660 if branch_name.is_empty() {
661 return Err(GitError::OutputParse(
662 "Failed to resolve current branch name: empty output".to_string(),
663 ));
664 }
665
666 if branch_name == "HEAD" {
667 return Err(GitError::OutputParse(
668 "Failed to resolve current branch name: detached HEAD".to_string(),
669 ));
670 }
671
672 Ok(branch_name)
673}
674
675pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
692 spawn_blocking(move || -> Result<String, GitError> {
693 let push_output = run_git_command_output_sync(&repo_path, &["push", "--force-with-lease"])?;
694
695 if push_output.status.success() {
696 return primary_upstream_reference(&repo_path);
697 }
698
699 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
700 if !is_no_upstream_error(&push_detail) {
701 return Err(GitError::CommandFailed {
702 command: "git push".to_string(),
703 stderr: push_detail,
704 });
705 }
706
707 run_git_command_sync(
708 &repo_path,
709 &[
710 "push",
711 "--force-with-lease",
712 "--set-upstream",
713 "origin",
714 "HEAD",
715 ],
716 "Git push failed",
717 )?;
718
719 primary_upstream_reference(&repo_path)
720 })
721 .await?
722}
723
724pub(crate) async fn remote_branch_exists(
737 repo_path: PathBuf,
738 remote_branch_name: String,
739) -> Result<bool, GitError> {
740 spawn_blocking(move || -> Result<bool, GitError> {
741 let remote_name =
742 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
743 let output = run_git_command_output_sync(
744 &repo_path,
745 &["ls-remote", "--heads", &remote_name, &remote_branch_name],
746 )?;
747
748 if !output.status.success() {
749 let detail = command_output_detail(&output.stdout, &output.stderr);
750
751 return Err(GitError::CommandFailed {
752 command: "git ls-remote".to_string(),
753 stderr: detail,
754 });
755 }
756
757 let stdout = String::from_utf8_lossy(&output.stdout);
758
759 Ok(!stdout.trim().is_empty())
760 })
761 .await?
762}
763
764pub(crate) async fn push_current_branch_to_remote_branch(
781 repo_path: PathBuf,
782 remote_branch_name: String,
783) -> Result<String, GitError> {
784 spawn_blocking(move || -> Result<String, GitError> {
785 let remote_name =
786 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
787 let push_refspec = format!("HEAD:{remote_branch_name}");
788
789 run_git_command_sync(
790 &repo_path,
791 &[
792 "push",
793 "--force-with-lease",
794 "--set-upstream",
795 &remote_name,
796 &push_refspec,
797 ],
798 "Git push failed",
799 )?;
800
801 Ok(format!("{remote_name}/{remote_branch_name}"))
802 })
803 .await?
804}
805
806pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
818 spawn_blocking(move || primary_upstream_reference(&repo_path)).await?
819}
820
821pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
832 run_git_command(
833 repo_path,
834 vec!["fetch".to_string()],
835 "Git fetch failed".to_string(),
836 )
837 .await?;
838
839 Ok(())
840}
841
842pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
854 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
855}
856
857pub(crate) async fn get_ref_ahead_behind(
867 repo_path: PathBuf,
868 left_ref: String,
869 right_ref: String,
870) -> Result<(u32, u32), GitError> {
871 let rev_list_output = run_git_command(
872 repo_path,
873 vec![
874 "rev-list".to_string(),
875 "--left-right".to_string(),
876 "--count".to_string(),
877 format!("{left_ref}...{right_ref}"),
878 ],
879 "Git rev-list failed".to_string(),
880 )
881 .await?;
882
883 parse_ahead_behind_counts(&rev_list_output)
884}
885
886fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
889 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
890 if parts.len() >= 2 {
891 let ahead = parts[0].parse().unwrap_or(0);
892 let behind = parts[1].parse().unwrap_or(0);
893
894 return Ok((ahead, behind));
895 }
896
897 Err(GitError::OutputParse(
898 "Unexpected output format from git rev-list".to_string(),
899 ))
900}
901
902pub(crate) async fn branch_tracking_statuses(
911 repo_path: PathBuf,
912) -> Result<BranchTrackingMap, GitError> {
913 let git_output = run_git_command(
914 repo_path,
915 vec![
916 "for-each-ref".to_string(),
917 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
918 "refs/heads".to_string(),
919 ],
920 "Git for-each-ref failed".to_string(),
921 )
922 .await?;
923
924 Ok(parse_branch_tracking_statuses(&git_output))
925}
926
927pub(crate) async fn list_upstream_commit_titles(
938 repo_path: PathBuf,
939) -> Result<Vec<String>, GitError> {
940 let git_output = run_git_command(
941 repo_path,
942 vec![
943 "log".to_string(),
944 "--reverse".to_string(),
945 "--pretty=%s".to_string(),
946 "HEAD..@{u}".to_string(),
947 ],
948 "Git log failed".to_string(),
949 )
950 .await?;
951
952 Ok(parse_commit_titles(&git_output))
953}
954
955pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
966 let git_output = run_git_command(
967 repo_path,
968 vec![
969 "log".to_string(),
970 "--reverse".to_string(),
971 "--pretty=%s".to_string(),
972 "@{u}..HEAD".to_string(),
973 ],
974 "Git log failed".to_string(),
975 )
976 .await?;
977
978 Ok(parse_commit_titles(&git_output))
979}
980
981pub(crate) async fn has_commits_since(
987 repo_path: PathBuf,
988 base_branch: String,
989) -> Result<bool, GitError> {
990 spawn_blocking(move || -> Result<bool, GitError> {
991 let rev_list_output = run_git_command_sync(
992 &repo_path,
993 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
994 "Failed to count commits since base branch",
995 )?;
996 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
997 GitError::OutputParse(format!(
998 "Failed to parse commit count since base branch `{base_branch}`: {error}"
999 ))
1000 })?;
1001
1002 Ok(commit_count > 0)
1003 })
1004 .await?
1005}
1006
1007fn parse_commit_titles(output: &str) -> Vec<String> {
1009 output
1010 .lines()
1011 .map(str::trim)
1012 .filter(|title| !title.is_empty())
1013 .map(ToString::to_string)
1014 .collect()
1015}
1016
1017fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1019 let mut branch_tracking_statuses = HashMap::new();
1020
1021 for line in output
1022 .lines()
1023 .map(str::trim)
1024 .filter(|line| !line.is_empty())
1025 {
1026 let mut parts = line.splitn(3, '\t');
1027 let Some(branch_name) = parts
1028 .next()
1029 .map(str::trim)
1030 .filter(|value| !value.is_empty())
1031 else {
1032 continue;
1033 };
1034 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1035 let track = parts.next().map(str::trim).unwrap_or_default();
1036
1037 let status = if upstream_ref.is_empty() {
1038 None
1039 } else {
1040 parse_branch_tracking_counts(track)
1041 };
1042 branch_tracking_statuses.insert(branch_name.to_string(), status);
1043 }
1044
1045 branch_tracking_statuses
1046}
1047
1048fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1050 let normalized_track = track.trim();
1051 if normalized_track.is_empty() || normalized_track == "gone" {
1052 return None;
1053 }
1054
1055 let mut ahead = 0;
1056 let mut behind = 0;
1057
1058 for part in normalized_track.split(',').map(str::trim) {
1059 if let Some(count) = part.strip_prefix("ahead ") {
1060 ahead = count.parse().ok()?;
1061 } else if let Some(count) = part.strip_prefix("behind ") {
1062 behind = count.parse().ok()?;
1063 }
1064 }
1065
1066 Some((ahead, behind))
1067}
1068
1069fn resolve_diff_target(
1075 repo_path: &Path,
1076 base_branch: &str,
1077 merge_base: &str,
1078) -> Result<String, GitError> {
1079 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1080 if !cherry_output.status.success() {
1081 return Ok(merge_base.to_string());
1082 }
1083
1084 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1085 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1086 return Ok(merge_base.to_string());
1087 };
1088
1089 Ok(last_leading_applied_commit.to_string())
1090}
1091
1092fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1099 let mut last_applied_commit = None;
1100
1101 for line in cherry_output.lines() {
1102 let trimmed_line = line.trim();
1103 if trimmed_line.is_empty() {
1104 continue;
1105 }
1106
1107 let mut parts = trimmed_line.split_whitespace();
1108 let marker = parts.next()?;
1109 let commit_hash = parts.next()?;
1110
1111 if marker == "-" {
1112 last_applied_commit = Some(commit_hash);
1113
1114 continue;
1115 }
1116
1117 if marker == "+" {
1118 break;
1119 }
1120
1121 break;
1122 }
1123
1124 last_applied_commit
1125}
1126
1127async fn commit_all_with_retry(
1135 repo_path: PathBuf,
1136 commit_message: String,
1137 message_strategy: SingleCommitMessageStrategy,
1138 no_verify: bool,
1139 amend_existing_commit: bool,
1140) -> Result<(), GitError> {
1141 spawn_blocking(move || {
1142 stage_all_sync(&repo_path)?;
1143
1144 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1145 let output = run_commit_command(
1146 &repo_path,
1147 &commit_message,
1148 message_strategy,
1149 no_verify,
1150 amend_existing_commit,
1151 )?;
1152
1153 if output.status.success() {
1154 return Ok(());
1155 }
1156
1157 let stderr = String::from_utf8_lossy(&output.stderr);
1158 let stdout = String::from_utf8_lossy(&output.stdout);
1159 if is_nothing_to_commit_output(&stdout, &stderr) {
1160 return Err(nothing_to_commit_error());
1161 }
1162
1163 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1164 reset_empty_amend_sync(&repo_path)?;
1165
1166 return Err(nothing_to_commit_error());
1167 }
1168
1169 if is_hook_modified_error(&stdout, &stderr) {
1170 stage_all_sync(&repo_path)?;
1171
1172 continue;
1173 }
1174
1175 let detail = command_output_detail(&output.stdout, &output.stderr);
1176
1177 return Err(GitError::CommandFailed {
1178 command: "git commit".to_string(),
1179 stderr: detail,
1180 });
1181 }
1182
1183 Err(GitError::CommandFailed {
1184 command: "git commit".to_string(),
1185 stderr: format!(
1186 "Failed to commit: commit hooks kept modifying files after \
1187 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1188 ),
1189 })
1190 })
1191 .await?
1192}
1193
1194fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1197 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1198 .iter()
1199 .find(|config_file| repo_path.join(config_file).is_file())
1200 else {
1201 return Ok(());
1202 };
1203 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1204
1205 if is_executable_hook(&hook_path) {
1206 return Ok(());
1207 }
1208
1209 Err(GitError::PreCommitHookMissing {
1210 config_file: (*config_file).to_string(),
1211 })
1212}
1213
1214fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1216 let hooks_path_output =
1217 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1218 let hooks_path = if hooks_path_output.status.success() {
1219 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1220 } else if hooks_path_output.status.code() == Some(1) {
1221 let default_hook_path = run_git_command_sync(
1222 repo_path,
1223 &["rev-parse", "--git-path", "hooks/pre-commit"],
1224 "Failed to resolve Git pre-commit hook path",
1225 )?;
1226
1227 return Ok(resolve_repo_path(
1228 repo_path,
1229 PathBuf::from(default_hook_path.trim()),
1230 ));
1231 } else {
1232 return Err(GitError::CommandFailed {
1233 command: "git config --path --get core.hooksPath".to_string(),
1234 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1235 });
1236 };
1237
1238 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1239}
1240
1241fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1242 if path.is_absolute() {
1243 return path;
1244 }
1245
1246 repo_path.join(path)
1247}
1248
1249#[cfg(unix)]
1250fn is_executable_hook(hook_path: &Path) -> bool {
1251 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1252}
1253
1254#[cfg(not(unix))]
1255fn is_executable_hook(hook_path: &Path) -> bool {
1256 hook_path.is_file()
1257}
1258
1259fn nothing_to_commit_error() -> GitError {
1261 GitError::CommandFailed {
1262 command: "git commit".to_string(),
1263 stderr: "Nothing to commit: no changes detected".to_string(),
1264 }
1265}
1266
1267fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1270 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1271
1272 combined.contains("nothing to commit")
1273}
1274
1275fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1278 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1279 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1280
1281 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1282}
1283
1284fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1287 run_git_command_sync(
1288 repo_path,
1289 &["reset", "HEAD^"],
1290 "Git reset after empty amend failed",
1291 )?;
1292
1293 Ok(())
1294}
1295
1296fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1300 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1301
1302 if !output.status.success() {
1303 let detail = command_output_detail(&output.stdout, &output.stderr);
1304
1305 return Err(GitError::CommandFailed {
1306 command: "git add -A".to_string(),
1307 stderr: format!("Failed to stage changes: {detail}"),
1308 });
1309 }
1310
1311 Ok(())
1312}
1313
1314fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1316 if !has_head_commit_sync(repo_path)? {
1317 return Ok(None);
1318 }
1319
1320 let output = run_git_command_sync(
1321 repo_path,
1322 &["log", "-1", "--pretty=%B"],
1323 "Failed to read HEAD commit message",
1324 )?;
1325
1326 Ok(Some(output.trim().to_string()))
1327}
1328
1329fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1331 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1332
1333 if output.status.success() {
1334 return Ok(true);
1335 }
1336
1337 let detail = command_output_detail(&output.stdout, &output.stderr);
1338 let normalized_detail = detail.to_ascii_lowercase();
1339 if normalized_detail.contains("needed a single revision")
1340 || normalized_detail.contains("unknown revision")
1341 || normalized_detail.contains("does not have any commits yet")
1342 {
1343 return Ok(false);
1344 }
1345
1346 Err(GitError::CommandFailed {
1347 command: "git rev-parse --verify HEAD".to_string(),
1348 stderr: detail,
1349 })
1350}
1351
1352fn run_commit_command(
1356 repo_path: &Path,
1357 commit_message: &str,
1358 message_strategy: SingleCommitMessageStrategy,
1359 no_verify: bool,
1360 amend_existing_commit: bool,
1361) -> Result<Output, GitError> {
1362 let mut args = vec!["commit"];
1363 if amend_existing_commit {
1364 args.push("--amend");
1365 match message_strategy {
1366 SingleCommitMessageStrategy::Replace => {
1367 args.push("-m");
1368 args.push(commit_message);
1369 }
1370 SingleCommitMessageStrategy::Reuse => {
1371 args.push("--no-edit");
1372 }
1373 }
1374 } else {
1375 args.push("-m");
1376 args.push(commit_message);
1377 }
1378
1379 if no_verify {
1380 args.push("--no-verify");
1381 }
1382
1383 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1384}
1385
1386fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1388 let combined = format!(
1389 "{stdout}
1390{stderr}"
1391 )
1392 .to_ascii_lowercase();
1393
1394 combined.contains("files were modified by this hook")
1395}
1396
1397pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1399 let normalized_detail = detail.to_ascii_lowercase();
1400
1401 normalized_detail.contains("has no upstream branch")
1402 || normalized_detail.contains("no upstream branch")
1403 || normalized_detail.contains("set-upstream")
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use std::fs;
1409 #[cfg(unix)]
1410 use std::os::unix::fs::PermissionsExt;
1411 use std::path::Path;
1412 use std::process::{Command, Output};
1413
1414 use tempfile::tempdir;
1415
1416 use super::*;
1417
1418 fn run_git_command(repo_path: &Path, args: &[&str]) {
1420 let output = git_command_output(repo_path, args);
1421
1422 assert!(
1423 output.status.success(),
1424 "git command {:?} failed: {}",
1425 args,
1426 String::from_utf8_lossy(&output.stderr)
1427 );
1428 }
1429
1430 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1432 Command::new("git")
1433 .args(args)
1434 .current_dir(repo_path)
1435 .output()
1436 .expect("failed to run git command")
1437 }
1438
1439 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1441 let output = git_command_output(repo_path, args);
1442
1443 assert!(
1444 output.status.success(),
1445 "git command {:?} failed: {}",
1446 args,
1447 String::from_utf8_lossy(&output.stderr)
1448 );
1449
1450 String::from_utf8(output.stdout)
1451 .expect("git stdout should be valid utf-8")
1452 .trim()
1453 .to_string()
1454 }
1455
1456 fn setup_test_git_repo(repo_path: &Path) {
1458 run_git_command(repo_path, &["init", "-b", "main"]);
1459 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1460 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1461 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1462 run_git_command(repo_path, &["add", "README.md"]);
1463 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1464 }
1465
1466 #[tokio::test]
1467 async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1468 let temp_dir = tempdir().expect("failed to create temp dir");
1470 setup_test_git_repo(temp_dir.path());
1471 fs::write(temp_dir.path().join("README.md"), "staged change\n")
1472 .expect("failed to write staged change");
1473 run_git_command(temp_dir.path(), &["add", "README.md"]);
1474 fs::write(
1475 temp_dir.path().join("README.md"),
1476 "staged change\nunstaged change\n",
1477 )
1478 .expect("failed to write unstaged change");
1479 fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1480 .expect("failed to write untracked file");
1481 let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1482 let status_before = git_command_output(
1483 temp_dir.path(),
1484 &["status", "--porcelain=v1", "--untracked-files=all"],
1485 )
1486 .stdout;
1487
1488 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1490
1491 let diff_output = result.expect("diff should succeed");
1493 let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1494 let status_after = git_command_output(
1495 temp_dir.path(),
1496 &["status", "--porcelain=v1", "--untracked-files=all"],
1497 )
1498 .stdout;
1499 assert!(diff_output.contains("staged change"));
1500 assert!(diff_output.contains("unstaged change"));
1501 assert!(diff_output.contains("untracked change"));
1502 assert_eq!(cached_diff_after, cached_diff_before);
1503 assert_eq!(status_after, status_before);
1504 }
1505
1506 #[tokio::test]
1507 async fn read_worktree_file_returns_text_for_safe_nested_path() {
1508 let temp_dir = tempdir().expect("failed to create temp dir");
1510 let docs_dir = temp_dir.path().join("docs");
1511 fs::create_dir(&docs_dir).expect("failed to create docs directory");
1512 fs::write(docs_dir.join("README.md"), "# Preview\n")
1513 .expect("failed to write markdown file");
1514
1515 let result =
1517 read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1518
1519 assert_eq!(
1521 result.expect("worktree read should succeed"),
1522 WorktreeFileContent::Text("# Preview\n".to_string())
1523 );
1524 }
1525
1526 #[tokio::test]
1527 async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
1528 let temp_dir = tempdir().expect("failed to create temp dir");
1530 fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
1531 .expect("failed to write binary file");
1532 fs::write(
1533 temp_dir.path().join("large.md"),
1534 vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
1535 )
1536 .expect("failed to write oversize file");
1537
1538 let missing =
1540 read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
1541 let binary =
1542 read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
1543 let too_large =
1544 read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
1545
1546 assert_eq!(
1548 missing.expect("missing read should succeed"),
1549 WorktreeFileContent::Missing
1550 );
1551 assert_eq!(
1552 binary.expect("binary read should succeed"),
1553 WorktreeFileContent::Binary
1554 );
1555 assert_eq!(
1556 too_large.expect("oversize read should succeed"),
1557 WorktreeFileContent::TooLarge
1558 );
1559 }
1560
1561 #[tokio::test]
1562 async fn read_worktree_file_rejects_unsafe_relative_paths() {
1563 let temp_dir = tempdir().expect("failed to create temp dir");
1565 let absolute_path = temp_dir.path().join("README.md");
1566
1567 let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
1569 let parent =
1570 read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
1571 let absolute = read_worktree_file(
1572 temp_dir.path().to_path_buf(),
1573 absolute_path.to_string_lossy().into_owned(),
1574 )
1575 .await;
1576
1577 for result in [empty, parent, absolute] {
1579 assert!(
1580 matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
1581 );
1582 }
1583 }
1584
1585 #[cfg(unix)]
1586 #[tokio::test]
1587 async fn read_worktree_file_rejects_symlinks_outside_repository() {
1588 let temp_dir = tempdir().expect("failed to create temp dir");
1590 let outside_dir = tempdir().expect("failed to create outside temp dir");
1591 let outside_file = outside_dir.path().join("outside.md");
1592 fs::write(&outside_file, "outside").expect("failed to write outside file");
1593 std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
1594 .expect("failed to create outside symlink");
1595
1596 let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
1598
1599 assert!(
1601 matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
1602 );
1603 }
1604
1605 #[cfg(unix)]
1606 #[tokio::test]
1607 async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
1608 let temp_dir = tempdir().expect("failed to create temp dir");
1610 std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
1611 .expect("failed to create symlink loop");
1612
1613 let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
1615
1616 assert!(matches!(result, Err(GitError::Io(_))));
1618 }
1619
1620 #[test]
1621 fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
1622 let temp_dir = tempdir().expect("failed to create temp dir");
1624 let path_without_parent = Path::new("/");
1625 let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
1626 let missing_index = temp_dir.path().join("missing-index");
1627
1628 let parent_error = copy_git_index_to_temp(path_without_parent);
1630 let create_error = copy_git_index_to_temp(&missing_parent_index);
1631 let copy_error = copy_git_index_to_temp(&missing_index);
1632
1633 assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
1635 assert!(matches!(
1636 create_error,
1637 Err(GitError::CommandFailed { ref command, .. })
1638 if command == "create temporary git index"
1639 ));
1640 assert!(matches!(
1641 copy_error,
1642 Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
1643 ));
1644 }
1645
1646 #[test]
1647 fn run_git_command_with_index_sync_maps_process_and_command_failures() {
1648 let temp_dir = tempdir().expect("failed to create temp dir");
1650 let index_path = temp_dir.path().join("index");
1651 let missing_repo_path = temp_dir.path().join("missing-repository");
1652 fs::write(&index_path, []).expect("failed to create temporary index");
1653
1654 let process_error = run_git_command_with_index_sync(
1656 &missing_repo_path,
1657 &["status"],
1658 &index_path,
1659 "Expected process failure",
1660 );
1661 let command_error = run_git_command_with_index_sync(
1662 temp_dir.path(),
1663 &["definitely-not-a-git-command"],
1664 &index_path,
1665 "Expected git failure",
1666 );
1667
1668 assert!(matches!(
1670 process_error,
1671 Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
1672 ));
1673 assert!(matches!(
1674 command_error,
1675 Err(GitError::CommandFailed {
1676 ref command,
1677 ref stderr,
1678 }) if command == "git definitely-not-a-git-command"
1679 && stderr.starts_with("Expected git failure:")
1680 ));
1681 }
1682
1683 #[cfg(unix)]
1684 fn write_executable_pre_commit_hook(hook_path: &Path) {
1685 fs::create_dir_all(
1686 hook_path
1687 .parent()
1688 .expect("pre-commit hook should have a parent directory"),
1689 )
1690 .expect("failed to create hooks directory");
1691 fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1692 let mut permissions = fs::metadata(hook_path)
1693 .expect("failed to read pre-commit hook metadata")
1694 .permissions();
1695 permissions.set_mode(0o755);
1696 fs::set_permissions(hook_path, permissions)
1697 .expect("failed to make pre-commit hook executable");
1698 }
1699
1700 #[test]
1701 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
1702 let temp_dir = tempdir().expect("failed to create temp dir");
1704 setup_test_git_repo(temp_dir.path());
1705
1706 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1708
1709 assert!(result.is_ok());
1711 }
1712
1713 #[test]
1714 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
1715 let temp_dir = tempdir().expect("failed to create temp dir");
1717 setup_test_git_repo(temp_dir.path());
1718 fs::write(
1719 temp_dir.path().join(".pre-commit-config.yaml"),
1720 "repos: []\n",
1721 )
1722 .expect("failed to write pre-commit configuration");
1723
1724 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1726
1727 assert!(matches!(
1729 result,
1730 Err(GitError::PreCommitHookMissing { ref config_file })
1731 if config_file == ".pre-commit-config.yaml"
1732 ));
1733 }
1734
1735 #[cfg(unix)]
1736 #[test]
1737 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
1738 let temp_dir = tempdir().expect("failed to create temp dir");
1740 setup_test_git_repo(temp_dir.path());
1741 fs::write(
1742 temp_dir.path().join(".pre-commit-config.yaml"),
1743 "repos: []\n",
1744 )
1745 .expect("failed to write pre-commit configuration");
1746 let hook_path = temp_dir.path().join(git_command_stdout(
1747 temp_dir.path(),
1748 &["rev-parse", "--git-path", "hooks/pre-commit"],
1749 ));
1750 write_executable_pre_commit_hook(&hook_path);
1751
1752 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1754
1755 assert!(result.is_ok());
1757 }
1758
1759 #[cfg(unix)]
1760 #[test]
1761 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
1762 let temp_dir = tempdir().expect("failed to create temp dir");
1764 setup_test_git_repo(temp_dir.path());
1765 fs::write(
1766 temp_dir.path().join(".pre-commit-config.yaml"),
1767 "repos: []\n",
1768 )
1769 .expect("failed to write pre-commit configuration");
1770 run_git_command(
1771 temp_dir.path(),
1772 &["config", "core.hooksPath", ".custom-hooks"],
1773 );
1774 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
1775
1776 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1778
1779 assert!(result.is_ok());
1781 }
1782
1783 #[cfg(unix)]
1784 #[test]
1785 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
1786 let temp_dir = tempdir().expect("failed to create temp dir");
1788 setup_test_git_repo(temp_dir.path());
1789 fs::write(
1790 temp_dir.path().join(".pre-commit-config.yaml"),
1791 "repos: []\n",
1792 )
1793 .expect("failed to write pre-commit configuration");
1794 let hook_path = temp_dir.path().join(git_command_stdout(
1795 temp_dir.path(),
1796 &["rev-parse", "--git-path", "hooks/pre-commit"],
1797 ));
1798 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1799 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
1800 .expect("failed to set mismatched execute permissions");
1801
1802 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1804
1805 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
1807 }
1808
1809 #[tokio::test]
1810 async fn commit_all_allows_configured_validation_without_hook() {
1811 let temp_dir = tempdir().expect("failed to create temp dir");
1813 setup_test_git_repo(temp_dir.path());
1814 fs::write(
1815 temp_dir.path().join(".pre-commit-config.yaml"),
1816 "repos: []\n",
1817 )
1818 .expect("failed to write pre-commit configuration");
1819 fs::write(temp_dir.path().join("README.md"), "changed\n")
1820 .expect("failed to write worktree change");
1821
1822 let result = commit_all(
1824 temp_dir.path().to_path_buf(),
1825 "Change README".to_string(),
1826 false,
1827 )
1828 .await;
1829
1830 assert!(result.is_ok());
1832 assert_eq!(
1833 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
1834 "Change README"
1835 );
1836 }
1837
1838 #[test]
1839 fn current_branch_name_returns_error_for_detached_head() {
1840 let temp_dir = tempdir().expect("failed to create temp dir");
1842 setup_test_git_repo(temp_dir.path());
1843 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1844
1845 let result = current_branch_name(temp_dir.path());
1847
1848 let error = result.expect_err("detached HEAD should fail");
1850 assert!(error.to_string().contains("detached HEAD"));
1851 }
1852
1853 #[test]
1854 fn primary_upstream_reference_uses_first_non_empty_line() {
1855 let temp_dir = tempdir().expect("failed to create temp dir");
1857 let remote_dir = tempdir().expect("failed to create remote temp dir");
1858 setup_test_git_repo(temp_dir.path());
1859 run_git_command(remote_dir.path(), &["init", "--bare"]);
1860 let remote_path = remote_dir.path().to_string_lossy().to_string();
1861 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1862 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1863 run_git_command(
1864 temp_dir.path(),
1865 &[
1866 "config",
1867 "--replace-all",
1868 "branch.main.merge",
1869 "refs/heads/main",
1870 ],
1871 );
1872 run_git_command(
1873 temp_dir.path(),
1874 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1875 );
1876
1877 let upstream_reference =
1879 primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1880
1881 assert_eq!(upstream_reference, "origin/main");
1883 }
1884
1885 #[test]
1886 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1887 let output = "\
1889main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1890 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1891
1892 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1894
1895 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1897 assert_eq!(
1898 branch_tracking_statuses.get("wt/1234abcd"),
1899 Some(&Some((3, 1)))
1900 );
1901 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1902 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1903 }
1904
1905 #[tokio::test]
1906 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1907 let temp_dir = tempdir().expect("failed to create temp dir");
1909 let remote_dir = tempdir().expect("failed to create remote temp dir");
1910 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1911 let contributor_clone_path = contributor_dir.path().join("clone");
1912 setup_test_git_repo(temp_dir.path());
1913 run_git_command(remote_dir.path(), &["init", "--bare"]);
1914 let remote_path = remote_dir.path().to_string_lossy().to_string();
1915 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1916 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1917 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1918 fs::write(temp_dir.path().join("README.md"), "local change\n")
1919 .expect("failed to write local change");
1920 run_git_command(temp_dir.path(), &["add", "README.md"]);
1921 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1922 run_git_command(
1923 contributor_dir.path(),
1924 &["clone", &remote_path, &contributor_clone_path_text],
1925 );
1926 run_git_command(
1927 &contributor_clone_path,
1928 &["config", "user.name", "Contributor User"],
1929 );
1930 run_git_command(
1931 &contributor_clone_path,
1932 &["config", "user.email", "contributor@example.com"],
1933 );
1934 run_git_command(
1935 &contributor_clone_path,
1936 &["checkout", "-B", "main", "origin/main"],
1937 );
1938 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1939 .expect("failed to write remote change");
1940 run_git_command(&contributor_clone_path, &["add", "README.md"]);
1941 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1942 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1943
1944 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1946
1947 assert!(matches!(
1949 result,
1950 Ok(PullRebaseResult::Conflict { ref detail })
1951 if {
1952 let normalized_detail = detail.to_ascii_lowercase();
1953
1954 (normalized_detail.contains("conflict")
1955 || normalized_detail.contains("could not apply"))
1956 && !detail.is_empty()
1957 }
1958 ));
1959 }
1960
1961 #[tokio::test]
1962 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1963 let temp_dir = tempdir().expect("failed to create temp dir");
1965 let remote_dir = tempdir().expect("failed to create remote temp dir");
1966 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1967 let contributor_clone_path = contributor_dir.path().join("clone");
1968 setup_test_git_repo(temp_dir.path());
1969 run_git_command(remote_dir.path(), &["init", "--bare"]);
1970 let remote_path = remote_dir.path().to_string_lossy().to_string();
1971 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1972 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1973 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1974 run_git_command(
1975 contributor_dir.path(),
1976 &["clone", &remote_path, &contributor_clone_path_text],
1977 );
1978 run_git_command(
1979 &contributor_clone_path,
1980 &["config", "user.name", "Contributor User"],
1981 );
1982 run_git_command(
1983 &contributor_clone_path,
1984 &["config", "user.email", "contributor@example.com"],
1985 );
1986 run_git_command(
1987 &contributor_clone_path,
1988 &["checkout", "-B", "main", "origin/main"],
1989 );
1990 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
1991 .expect("failed to write remote file");
1992 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
1993 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1994 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1995 fs::write(temp_dir.path().join("local.txt"), "local change")
1996 .expect("failed to write local file");
1997 run_git_command(temp_dir.path(), &["add", "local.txt"]);
1998 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1999
2000 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2002
2003 let error = result
2005 .expect_err("non-fast-forward push should fail")
2006 .to_string();
2007 assert!(error.contains("git push"));
2008 assert!(
2009 error.contains("stale info")
2010 || error.contains("rejected")
2011 || error.contains("fetch first")
2012 );
2013 }
2014
2015 #[tokio::test]
2016 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
2017 let temp_dir = tempdir().expect("failed to create temp dir");
2019 let remote_dir = tempdir().expect("failed to create remote temp dir");
2020 setup_test_git_repo(temp_dir.path());
2021 run_git_command(remote_dir.path(), &["init", "--bare"]);
2022 let remote_path = remote_dir.path().to_string_lossy().to_string();
2023 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2024 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2025 fs::write(
2026 temp_dir.path().join("README.md"),
2027 "first published version\n",
2028 )
2029 .expect("failed to write first version");
2030 run_git_command(temp_dir.path(), &["add", "README.md"]);
2031 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
2032 push_current_branch(temp_dir.path().to_path_buf())
2033 .await
2034 .expect("initial push should succeed");
2035 fs::write(
2036 temp_dir.path().join("README.md"),
2037 "rewritten published version\n",
2038 )
2039 .expect("failed to rewrite published version");
2040 run_git_command(temp_dir.path(), &["add", "README.md"]);
2041 run_git_command(
2042 temp_dir.path(),
2043 &["commit", "--amend", "-m", "Rewrite published branch change"],
2044 );
2045
2046 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
2048 .await
2049 .expect("force-with-lease push should update rewritten history");
2050 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
2051 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
2052
2053 assert_eq!(upstream_reference, "origin/main");
2055 assert_eq!(local_head, remote_head);
2056 }
2057
2058 #[tokio::test]
2059 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
2060 let temp_dir = tempdir().expect("failed to create temp dir");
2062 let remote_dir = tempdir().expect("failed to create remote temp dir");
2063 setup_test_git_repo(temp_dir.path());
2064 run_git_command(remote_dir.path(), &["init", "--bare"]);
2065 let remote_path = remote_dir.path().to_string_lossy().to_string();
2066 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2067
2068 let upstream_reference = push_current_branch_to_remote_branch(
2070 temp_dir.path().to_path_buf(),
2071 "review/custom-branch".to_string(),
2072 )
2073 .await
2074 .expect("failed to push current branch to custom remote branch");
2075
2076 assert_eq!(upstream_reference, "origin/review/custom-branch");
2078 }
2079
2080 #[tokio::test]
2081 async fn current_upstream_reference_returns_origin_main() {
2082 let temp_dir = tempdir().expect("failed to create temp dir");
2084 let remote_dir = tempdir().expect("failed to create remote temp dir");
2085 setup_test_git_repo(temp_dir.path());
2086 run_git_command(remote_dir.path(), &["init", "--bare"]);
2087 let remote_path = remote_dir.path().to_string_lossy().to_string();
2088 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2089 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2090
2091 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
2093 .await
2094 .expect("failed to resolve upstream reference");
2095
2096 assert_eq!(upstream_reference, "origin/main");
2098 }
2099
2100 #[tokio::test]
2101 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
2102 let temp_dir = tempdir().expect("failed to create temp dir");
2104 setup_test_git_repo(temp_dir.path());
2105 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2106 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2107 .expect("failed to write session file");
2108 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2109 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2110 run_git_command(temp_dir.path(), &["checkout", "main"]);
2111 fs::write(temp_dir.path().join("main.txt"), "main change\n")
2112 .expect("failed to write main file");
2113 run_git_command(temp_dir.path(), &["add", "main.txt"]);
2114 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
2115
2116 let status = get_ref_ahead_behind(
2118 temp_dir.path().to_path_buf(),
2119 "wt/1234abcd".to_string(),
2120 "main".to_string(),
2121 )
2122 .await
2123 .expect("failed to compare branch refs");
2124
2125 assert_eq!(status, (1, 1));
2127 }
2128
2129 #[tokio::test]
2130 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
2131 let temp_dir = tempdir().expect("failed to create temp dir");
2133 let remote_dir = tempdir().expect("failed to create remote temp dir");
2134 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2135 let contributor_clone_path = contributor_dir.path().join("clone");
2136 setup_test_git_repo(temp_dir.path());
2137 run_git_command(remote_dir.path(), &["init", "--bare"]);
2138 let remote_path = remote_dir.path().to_string_lossy().to_string();
2139 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2140 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2141 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2142 run_git_command(
2143 contributor_dir.path(),
2144 &["clone", &remote_path, &contributor_clone_path_text],
2145 );
2146 run_git_command(
2147 &contributor_clone_path,
2148 &["config", "user.name", "Contributor User"],
2149 );
2150 run_git_command(
2151 &contributor_clone_path,
2152 &["config", "user.email", "contributor@example.com"],
2153 );
2154 run_git_command(
2155 &contributor_clone_path,
2156 &["checkout", "-B", "main", "origin/main"],
2157 );
2158 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2159 .expect("failed to write remote file");
2160 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2161 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2162 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2163 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2164 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2165 .expect("failed to write session file");
2166 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2167 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2168 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
2169 fs::write(
2170 temp_dir.path().join("session.txt"),
2171 "session change\nmore local\n",
2172 )
2173 .expect("failed to extend session file");
2174 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2175 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
2176 run_git_command(temp_dir.path(), &["fetch"]);
2177
2178 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
2180 .await
2181 .expect("failed to read branch tracking statuses");
2182
2183 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
2185 assert_eq!(
2186 branch_tracking_statuses.get("wt/1234abcd"),
2187 Some(&Some((1, 0)))
2188 );
2189 }
2190
2191 #[tokio::test]
2192 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
2197 let temp_dir = tempdir().expect("failed to create temp dir");
2199 setup_test_git_repo(temp_dir.path());
2200 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
2201 fs::write(temp_dir.path().join("session.txt"), "session work\n")
2202 .expect("failed to write session file");
2203 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2204 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
2205 fs::remove_file(temp_dir.path().join("session.txt"))
2206 .expect("failed to remove session file");
2207
2208 let result = commit_all_preserving_single_commit(
2212 temp_dir.path().to_path_buf(),
2213 "main".to_string(),
2214 "Session commit".to_string(),
2215 SingleCommitMessageStrategy::Replace,
2216 true,
2217 )
2218 .await;
2219
2220 let error = result.expect_err("amend-would-be-empty should fail");
2222 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
2223 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
2224 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
2225
2226 assert!(
2227 error.to_string().contains("Nothing to commit"),
2228 "expected 'Nothing to commit' sentinel but got: {error}"
2229 );
2230 assert_eq!(commit_count, "1");
2231 assert_eq!(head_message, "Initial commit");
2232 assert!(status.is_empty());
2233 }
2234}