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 diff_output(repo_path, base_branch, false).await
298}
299
300pub(crate) async fn diff_changed_files(
303 repo_path: PathBuf,
304 base_branch: String,
305) -> Result<Vec<String>, GitError> {
306 let output = diff_output(repo_path, base_branch, true).await?;
307
308 Ok(output
309 .lines()
310 .map(str::trim)
311 .filter(|path| !path.is_empty())
312 .map(str::to_string)
313 .collect())
314}
315
316async fn diff_output(
319 repo_path: PathBuf,
320 base_branch: String,
321 name_only: bool,
322) -> Result<String, GitError> {
323 spawn_blocking(move || -> Result<String, GitError> {
324 let index_path = run_git_command_sync(
325 &repo_path,
326 &["rev-parse", "--git-path", "index"],
327 "Git index path resolution failed",
328 )?;
329 let index_path = PathBuf::from(index_path.trim());
330 let index_path = if index_path.is_absolute() {
331 index_path
332 } else {
333 repo_path.join(index_path)
334 };
335 let temporary_index = copy_git_index_to_temp(&index_path)?;
336
337 run_git_command_with_index_sync(
338 &repo_path,
339 &["add", "-A", "--intent-to-add"],
340 &temporary_index,
341 "Git add --intent-to-add failed",
342 )?;
343
344 let merge_base_output =
345 run_git_command_output_sync(&repo_path, &["merge-base", "HEAD", &base_branch])?;
346
347 let diff_target = if merge_base_output.status.success() {
348 resolve_diff_target(
349 &repo_path,
350 &base_branch,
351 String::from_utf8_lossy(&merge_base_output.stdout).trim(),
352 )?
353 } else {
354 base_branch
355 };
356
357 let args = if name_only {
358 vec!["diff", "--name-only", diff_target.as_str()]
359 } else {
360 vec!["diff", diff_target.as_str()]
361 };
362
363 run_git_command_with_index_sync(&repo_path, &args, &temporary_index, "Git diff failed")
364 })
365 .await?
366}
367
368pub(crate) async fn read_worktree_file(
377 repo_path: PathBuf,
378 relative_path: String,
379) -> Result<WorktreeFileContent, GitError> {
380 spawn_blocking(move || read_worktree_file_sync(&repo_path, &relative_path)).await?
381}
382
383fn read_worktree_file_sync(
385 repo_path: &Path,
386 relative_path: &str,
387) -> Result<WorktreeFileContent, GitError> {
388 let relative_file_path = Path::new(relative_path);
389 if relative_path.is_empty()
390 || relative_file_path
391 .components()
392 .any(|component| !matches!(component, Component::Normal(_)))
393 {
394 return Err(GitError::OutputParse(format!(
395 "Unsafe worktree file path: {relative_path}"
396 )));
397 }
398
399 let canonical_repo_path = std::fs::canonicalize(repo_path)?;
400 let candidate_path = repo_path.join(relative_file_path);
401 let canonical_file_path = match std::fs::canonicalize(candidate_path) {
402 Ok(path) => path,
403 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
404 return Ok(WorktreeFileContent::Missing);
405 }
406 Err(error) => return Err(error.into()),
407 };
408 if !canonical_file_path.starts_with(canonical_repo_path) {
409 return Err(GitError::OutputParse(format!(
410 "Worktree file resolves outside repository: {relative_path}"
411 )));
412 }
413
414 let file = std::fs::File::open(canonical_file_path)?;
415 let mut bytes = Vec::with_capacity(MAX_WORKTREE_FILE_BYTE_COUNT.min(8192));
416 file.take((MAX_WORKTREE_FILE_BYTE_COUNT as u64).saturating_add(1))
417 .read_to_end(&mut bytes)?;
418
419 Ok(worktree_file_content(bytes))
420}
421
422fn worktree_file_content(bytes: Vec<u8>) -> WorktreeFileContent {
424 if bytes.len() > MAX_WORKTREE_FILE_BYTE_COUNT {
425 return WorktreeFileContent::TooLarge;
426 }
427
428 match String::from_utf8(bytes) {
429 Ok(content) => WorktreeFileContent::Text(content),
430 Err(_) => WorktreeFileContent::Binary,
431 }
432}
433
434fn copy_git_index_to_temp(index_path: &Path) -> Result<tempfile::TempPath, GitError> {
437 let index_parent = index_path.parent().ok_or_else(|| {
438 GitError::OutputParse(format!(
439 "Git index path has no parent: {}",
440 index_path.display()
441 ))
442 })?;
443 let temporary_index =
444 tempfile::NamedTempFile::new_in(index_parent).map_err(|error| GitError::CommandFailed {
445 command: "create temporary git index".to_string(),
446 stderr: error.to_string(),
447 })?;
448 std::fs::copy(index_path, temporary_index.path()).map_err(|error| GitError::CommandFailed {
449 command: "copy git index".to_string(),
450 stderr: error.to_string(),
451 })?;
452
453 Ok(temporary_index.into_temp_path())
454}
455
456fn run_git_command_with_index_sync(
459 repo_path: &Path,
460 args: &[&str],
461 index_path: &Path,
462 error_context: &str,
463) -> Result<String, GitError> {
464 let output = run_git_command_output_with_env_sync(
465 repo_path,
466 args,
467 &[("GIT_INDEX_FILE", index_path.as_os_str())],
468 )?;
469 if !output.status.success() {
470 return Err(GitError::CommandFailed {
471 command: format!("git {}", args.join(" ")),
472 stderr: format!(
473 "{error_context}: {}",
474 command_output_detail(&output.stdout, &output.stderr)
475 ),
476 });
477 }
478
479 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
480}
481
482pub(crate) async fn is_worktree_clean(repo_path: PathBuf) -> Result<bool, GitError> {
493 let status_output = worktree_status(repo_path).await?;
494
495 Ok(status_output.trim().is_empty())
496}
497
498pub(crate) async fn 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=all".to_string(),
518 ],
519 "Git status --porcelain=v1 failed".to_string(),
520 )
521 .await
522}
523
524pub(crate) async fn tracked_worktree_status(repo_path: PathBuf) -> Result<String, GitError> {
539 run_git_command(
540 repo_path,
541 vec![
542 "status".to_string(),
543 "--porcelain=v1".to_string(),
544 "--untracked-files=no".to_string(),
545 ],
546 "Git tracked status --porcelain=v1 failed".to_string(),
547 )
548 .await
549}
550
551pub(crate) async fn pull_rebase(repo_path: PathBuf) -> Result<PullRebaseResult, GitError> {
567 spawn_blocking(move || {
568 let pull_arguments = pull_rebase_arguments(&repo_path)
569 .unwrap_or_else(|_| vec!["pull".to_string(), "--rebase".to_string()]);
570 let pull_argument_refs: Vec<&str> = pull_arguments.iter().map(String::as_str).collect();
571 let output = run_git_command_with_index_lock_retry(
572 &repo_path,
573 &pull_argument_refs,
574 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
575 )?;
576
577 if output.status.success() {
578 return Ok(PullRebaseResult::Completed);
579 }
580
581 let detail = command_output_detail(&output.stdout, &output.stderr);
582 if is_rebase_conflict(&detail) {
583 return Ok(PullRebaseResult::Conflict { detail });
584 }
585
586 Err(GitError::CommandFailed {
587 command: "git pull --rebase".to_string(),
588 stderr: detail,
589 })
590 })
591 .await?
592}
593
594fn pull_rebase_arguments(repo_path: &Path) -> Result<Vec<String>, GitError> {
599 let upstream_reference = primary_upstream_reference(repo_path)?;
600
601 if let Some((remote_name, branch_name)) = upstream_reference.split_once('/') {
602 return Ok(vec![
603 "pull".to_string(),
604 "--rebase".to_string(),
605 remote_name.to_string(),
606 branch_name.to_string(),
607 ]);
608 }
609
610 let remote_name = current_branch_remote_name(repo_path)?;
611
612 Ok(vec![
613 "pull".to_string(),
614 "--rebase".to_string(),
615 remote_name,
616 upstream_reference,
617 ])
618}
619
620fn primary_upstream_reference(repo_path: &Path) -> Result<String, GitError> {
626 let upstream_reference = upstream_reference_name(repo_path)?;
627 let Some(primary_reference) = upstream_reference
628 .lines()
629 .map(str::trim)
630 .find(|line| !line.is_empty())
631 else {
632 return Err(GitError::OutputParse(
633 "Failed to resolve upstream branch: empty output".to_string(),
634 ));
635 };
636
637 Ok(primary_reference.to_string())
638}
639
640fn upstream_reference_name(repo_path: &Path) -> Result<String, GitError> {
642 let upstream_reference = run_git_command_sync(
643 repo_path,
644 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
645 "Failed to resolve upstream branch",
646 )?;
647 let upstream_reference = upstream_reference.trim().to_string();
648 if upstream_reference.is_empty() {
649 return Err(GitError::OutputParse(
650 "Failed to resolve upstream branch: empty output".to_string(),
651 ));
652 }
653
654 Ok(upstream_reference)
655}
656
657fn current_branch_remote_name(repo_path: &Path) -> Result<String, GitError> {
662 let current_branch_name = current_branch_name(repo_path)?;
663 let remote_config_key = format!("branch.{current_branch_name}.remote");
664 let remote_name = run_git_command_sync(
665 repo_path,
666 &["config", "--get", &remote_config_key],
667 &format!("Failed to resolve current branch remote `{remote_config_key}`"),
668 )?;
669 let remote_name = remote_name.trim().to_string();
670 if remote_name.is_empty() {
671 return Err(GitError::OutputParse(format!(
672 "Failed to resolve current branch remote `{remote_config_key}`: empty output"
673 )));
674 }
675
676 Ok(remote_name)
677}
678
679fn current_branch_name(repo_path: &Path) -> Result<String, GitError> {
681 let branch_name = run_git_command_sync(
682 repo_path,
683 &["rev-parse", "--abbrev-ref", "HEAD"],
684 "Failed to resolve current branch name",
685 )?;
686 let branch_name = branch_name.trim().to_string();
687 if branch_name.is_empty() {
688 return Err(GitError::OutputParse(
689 "Failed to resolve current branch name: empty output".to_string(),
690 ));
691 }
692
693 if branch_name == "HEAD" {
694 return Err(GitError::OutputParse(
695 "Failed to resolve current branch name: detached HEAD".to_string(),
696 ));
697 }
698
699 Ok(branch_name)
700}
701
702pub(crate) async fn push_current_branch(repo_path: PathBuf) -> Result<String, GitError> {
719 spawn_blocking(move || -> Result<String, GitError> {
720 let push_output = run_git_command_output_sync(&repo_path, &["push", "--force-with-lease"])?;
721
722 if push_output.status.success() {
723 return primary_upstream_reference(&repo_path);
724 }
725
726 let push_detail = command_output_detail(&push_output.stdout, &push_output.stderr);
727 if !is_no_upstream_error(&push_detail) {
728 return Err(GitError::CommandFailed {
729 command: "git push".to_string(),
730 stderr: push_detail,
731 });
732 }
733
734 run_git_command_sync(
735 &repo_path,
736 &[
737 "push",
738 "--force-with-lease",
739 "--set-upstream",
740 "origin",
741 "HEAD",
742 ],
743 "Git push failed",
744 )?;
745
746 primary_upstream_reference(&repo_path)
747 })
748 .await?
749}
750
751pub(crate) async fn remote_branch_exists(
764 repo_path: PathBuf,
765 remote_branch_name: String,
766) -> Result<bool, GitError> {
767 spawn_blocking(move || -> Result<bool, GitError> {
768 let remote_name =
769 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
770 let output = run_git_command_output_sync(
771 &repo_path,
772 &["ls-remote", "--heads", &remote_name, &remote_branch_name],
773 )?;
774
775 if !output.status.success() {
776 let detail = command_output_detail(&output.stdout, &output.stderr);
777
778 return Err(GitError::CommandFailed {
779 command: "git ls-remote".to_string(),
780 stderr: detail,
781 });
782 }
783
784 let stdout = String::from_utf8_lossy(&output.stdout);
785
786 Ok(!stdout.trim().is_empty())
787 })
788 .await?
789}
790
791pub(crate) async fn push_current_branch_to_remote_branch(
808 repo_path: PathBuf,
809 remote_branch_name: String,
810) -> Result<String, GitError> {
811 spawn_blocking(move || -> Result<String, GitError> {
812 let remote_name =
813 current_branch_remote_name(&repo_path).unwrap_or_else(|_| "origin".to_string());
814 let push_refspec = format!("HEAD:{remote_branch_name}");
815
816 run_git_command_sync(
817 &repo_path,
818 &[
819 "push",
820 "--force-with-lease",
821 "--set-upstream",
822 &remote_name,
823 &push_refspec,
824 ],
825 "Git push failed",
826 )?;
827
828 Ok(format!("{remote_name}/{remote_branch_name}"))
829 })
830 .await?
831}
832
833pub(crate) async fn current_upstream_reference(repo_path: PathBuf) -> Result<String, GitError> {
845 spawn_blocking(move || primary_upstream_reference(&repo_path)).await?
846}
847
848pub(crate) async fn fetch_remote(repo_path: PathBuf) -> Result<(), GitError> {
859 run_git_command(
860 repo_path,
861 vec!["fetch".to_string()],
862 "Git fetch failed".to_string(),
863 )
864 .await?;
865
866 Ok(())
867}
868
869pub(crate) async fn get_ahead_behind(repo_path: PathBuf) -> Result<(u32, u32), GitError> {
881 get_ref_ahead_behind(repo_path, "HEAD".to_string(), "@{u}".to_string()).await
882}
883
884pub(crate) async fn get_ref_ahead_behind(
894 repo_path: PathBuf,
895 left_ref: String,
896 right_ref: String,
897) -> Result<(u32, u32), GitError> {
898 let rev_list_output = run_git_command(
899 repo_path,
900 vec![
901 "rev-list".to_string(),
902 "--left-right".to_string(),
903 "--count".to_string(),
904 format!("{left_ref}...{right_ref}"),
905 ],
906 "Git rev-list failed".to_string(),
907 )
908 .await?;
909
910 parse_ahead_behind_counts(&rev_list_output)
911}
912
913fn parse_ahead_behind_counts(rev_list_output: &str) -> Result<(u32, u32), GitError> {
916 let parts: Vec<&str> = rev_list_output.split_whitespace().collect();
917 if parts.len() >= 2 {
918 let ahead = parts[0].parse().unwrap_or(0);
919 let behind = parts[1].parse().unwrap_or(0);
920
921 return Ok((ahead, behind));
922 }
923
924 Err(GitError::OutputParse(
925 "Unexpected output format from git rev-list".to_string(),
926 ))
927}
928
929pub(crate) async fn branch_tracking_statuses(
938 repo_path: PathBuf,
939) -> Result<BranchTrackingMap, GitError> {
940 let git_output = run_git_command(
941 repo_path,
942 vec![
943 "for-each-ref".to_string(),
944 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)".to_string(),
945 "refs/heads".to_string(),
946 ],
947 "Git for-each-ref failed".to_string(),
948 )
949 .await?;
950
951 Ok(parse_branch_tracking_statuses(&git_output))
952}
953
954pub(crate) async fn list_upstream_commit_titles(
965 repo_path: PathBuf,
966) -> Result<Vec<String>, GitError> {
967 let git_output = run_git_command(
968 repo_path,
969 vec![
970 "log".to_string(),
971 "--reverse".to_string(),
972 "--pretty=%s".to_string(),
973 "HEAD..@{u}".to_string(),
974 ],
975 "Git log failed".to_string(),
976 )
977 .await?;
978
979 Ok(parse_commit_titles(&git_output))
980}
981
982pub(crate) async fn list_local_commit_titles(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
993 let git_output = run_git_command(
994 repo_path,
995 vec![
996 "log".to_string(),
997 "--reverse".to_string(),
998 "--pretty=%s".to_string(),
999 "@{u}..HEAD".to_string(),
1000 ],
1001 "Git log failed".to_string(),
1002 )
1003 .await?;
1004
1005 Ok(parse_commit_titles(&git_output))
1006}
1007
1008pub(crate) async fn has_commits_since(
1014 repo_path: PathBuf,
1015 base_branch: String,
1016) -> Result<bool, GitError> {
1017 spawn_blocking(move || -> Result<bool, GitError> {
1018 let rev_list_output = run_git_command_sync(
1019 &repo_path,
1020 &["rev-list", "--count", &format!("{base_branch}..HEAD")],
1021 "Failed to count commits since base branch",
1022 )?;
1023 let commit_count = rev_list_output.trim().parse::<u32>().map_err(|error| {
1024 GitError::OutputParse(format!(
1025 "Failed to parse commit count since base branch `{base_branch}`: {error}"
1026 ))
1027 })?;
1028
1029 Ok(commit_count > 0)
1030 })
1031 .await?
1032}
1033
1034fn parse_commit_titles(output: &str) -> Vec<String> {
1036 output
1037 .lines()
1038 .map(str::trim)
1039 .filter(|title| !title.is_empty())
1040 .map(ToString::to_string)
1041 .collect()
1042}
1043
1044fn parse_branch_tracking_statuses(output: &str) -> BranchTrackingMap {
1046 let mut branch_tracking_statuses = HashMap::new();
1047
1048 for line in output
1049 .lines()
1050 .map(str::trim)
1051 .filter(|line| !line.is_empty())
1052 {
1053 let mut parts = line.splitn(3, '\t');
1054 let Some(branch_name) = parts
1055 .next()
1056 .map(str::trim)
1057 .filter(|value| !value.is_empty())
1058 else {
1059 continue;
1060 };
1061 let upstream_ref = parts.next().map(str::trim).unwrap_or_default();
1062 let track = parts.next().map(str::trim).unwrap_or_default();
1063
1064 let status = if upstream_ref.is_empty() {
1065 None
1066 } else {
1067 parse_branch_tracking_counts(track)
1068 };
1069 branch_tracking_statuses.insert(branch_name.to_string(), status);
1070 }
1071
1072 branch_tracking_statuses
1073}
1074
1075fn parse_branch_tracking_counts(track: &str) -> Option<(u32, u32)> {
1077 let normalized_track = track.trim();
1078 if normalized_track.is_empty() || normalized_track == "gone" {
1079 return None;
1080 }
1081
1082 let mut ahead = 0;
1083 let mut behind = 0;
1084
1085 for part in normalized_track.split(',').map(str::trim) {
1086 if let Some(count) = part.strip_prefix("ahead ") {
1087 ahead = count.parse().ok()?;
1088 } else if let Some(count) = part.strip_prefix("behind ") {
1089 behind = count.parse().ok()?;
1090 }
1091 }
1092
1093 Some((ahead, behind))
1094}
1095
1096fn resolve_diff_target(
1102 repo_path: &Path,
1103 base_branch: &str,
1104 merge_base: &str,
1105) -> Result<String, GitError> {
1106 let cherry_output = run_git_command_output_sync(repo_path, &["cherry", base_branch, "HEAD"])?;
1107 if !cherry_output.status.success() {
1108 return Ok(merge_base.to_string());
1109 }
1110
1111 let cherry_stdout = String::from_utf8_lossy(&cherry_output.stdout);
1112 let Some(last_leading_applied_commit) = last_leading_applied_commit(&cherry_stdout) else {
1113 return Ok(merge_base.to_string());
1114 };
1115
1116 Ok(last_leading_applied_commit.to_string())
1117}
1118
1119fn last_leading_applied_commit(cherry_output: &str) -> Option<&str> {
1126 let mut last_applied_commit = None;
1127
1128 for line in cherry_output.lines() {
1129 let trimmed_line = line.trim();
1130 if trimmed_line.is_empty() {
1131 continue;
1132 }
1133
1134 let mut parts = trimmed_line.split_whitespace();
1135 let marker = parts.next()?;
1136 let commit_hash = parts.next()?;
1137
1138 if marker == "-" {
1139 last_applied_commit = Some(commit_hash);
1140
1141 continue;
1142 }
1143
1144 if marker == "+" {
1145 break;
1146 }
1147
1148 break;
1149 }
1150
1151 last_applied_commit
1152}
1153
1154async fn commit_all_with_retry(
1162 repo_path: PathBuf,
1163 commit_message: String,
1164 message_strategy: SingleCommitMessageStrategy,
1165 no_verify: bool,
1166 amend_existing_commit: bool,
1167) -> Result<(), GitError> {
1168 spawn_blocking(move || {
1169 stage_all_sync(&repo_path)?;
1170
1171 for _ in 0..COMMIT_ALL_HOOK_RETRY_ATTEMPTS {
1172 let output = run_commit_command(
1173 &repo_path,
1174 &commit_message,
1175 message_strategy,
1176 no_verify,
1177 amend_existing_commit,
1178 )?;
1179
1180 if output.status.success() {
1181 return Ok(());
1182 }
1183
1184 let stderr = String::from_utf8_lossy(&output.stderr);
1185 let stdout = String::from_utf8_lossy(&output.stdout);
1186 if is_nothing_to_commit_output(&stdout, &stderr) {
1187 return Err(nothing_to_commit_error());
1188 }
1189
1190 if amend_existing_commit && is_empty_amend_output(&stdout, &stderr) {
1191 reset_empty_amend_sync(&repo_path)?;
1192
1193 return Err(nothing_to_commit_error());
1194 }
1195
1196 if is_hook_modified_error(&stdout, &stderr) {
1197 stage_all_sync(&repo_path)?;
1198
1199 continue;
1200 }
1201
1202 let detail = command_output_detail(&output.stdout, &output.stderr);
1203
1204 return Err(GitError::CommandFailed {
1205 command: "git commit".to_string(),
1206 stderr: detail,
1207 });
1208 }
1209
1210 Err(GitError::CommandFailed {
1211 command: "git commit".to_string(),
1212 stderr: format!(
1213 "Failed to commit: commit hooks kept modifying files after \
1214 {COMMIT_ALL_HOOK_RETRY_ATTEMPTS} attempts"
1215 ),
1216 })
1217 })
1218 .await?
1219}
1220
1221fn ensure_pre_commit_hook_ready(repo_path: &Path) -> Result<(), GitError> {
1224 let Some(config_file) = PRE_COMMIT_CONFIG_FILES
1225 .iter()
1226 .find(|config_file| repo_path.join(config_file).is_file())
1227 else {
1228 return Ok(());
1229 };
1230 let hook_path = resolve_pre_commit_hook_path(repo_path)?;
1231
1232 if is_executable_hook(&hook_path) {
1233 return Ok(());
1234 }
1235
1236 Err(GitError::PreCommitHookMissing {
1237 config_file: (*config_file).to_string(),
1238 })
1239}
1240
1241fn resolve_pre_commit_hook_path(repo_path: &Path) -> Result<PathBuf, GitError> {
1243 let hooks_path_output =
1244 run_git_command_output_sync(repo_path, &["config", "--path", "--get", "core.hooksPath"])?;
1245 let hooks_path = if hooks_path_output.status.success() {
1246 PathBuf::from(String::from_utf8_lossy(&hooks_path_output.stdout).trim())
1247 } else if hooks_path_output.status.code() == Some(1) {
1248 let default_hook_path = run_git_command_sync(
1249 repo_path,
1250 &["rev-parse", "--git-path", "hooks/pre-commit"],
1251 "Failed to resolve Git pre-commit hook path",
1252 )?;
1253
1254 return Ok(resolve_repo_path(
1255 repo_path,
1256 PathBuf::from(default_hook_path.trim()),
1257 ));
1258 } else {
1259 return Err(GitError::CommandFailed {
1260 command: "git config --path --get core.hooksPath".to_string(),
1261 stderr: command_output_detail(&hooks_path_output.stdout, &hooks_path_output.stderr),
1262 });
1263 };
1264
1265 Ok(resolve_repo_path(repo_path, hooks_path).join("pre-commit"))
1266}
1267
1268fn resolve_repo_path(repo_path: &Path, path: PathBuf) -> PathBuf {
1269 if path.is_absolute() {
1270 return path;
1271 }
1272
1273 repo_path.join(path)
1274}
1275
1276#[cfg(unix)]
1277fn is_executable_hook(hook_path: &Path) -> bool {
1278 hook_path.is_file() && rustix_fs::access(hook_path, Access::EXEC_OK).is_ok()
1279}
1280
1281#[cfg(not(unix))]
1282fn is_executable_hook(hook_path: &Path) -> bool {
1283 hook_path.is_file()
1284}
1285
1286fn nothing_to_commit_error() -> GitError {
1288 GitError::CommandFailed {
1289 command: "git commit".to_string(),
1290 stderr: "Nothing to commit: no changes detected".to_string(),
1291 }
1292}
1293
1294fn is_nothing_to_commit_output(stdout: &str, stderr: &str) -> bool {
1297 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1298
1299 combined.contains("nothing to commit")
1300}
1301
1302fn is_empty_amend_output(stdout: &str, stderr: &str) -> bool {
1305 let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase();
1306 let normalized = combined.split_whitespace().collect::<Vec<_>>().join(" ");
1307
1308 normalized.contains("would make it empty") && normalized.contains("allow-empty")
1309}
1310
1311fn reset_empty_amend_sync(repo_path: &Path) -> Result<(), GitError> {
1314 run_git_command_sync(
1315 repo_path,
1316 &["reset", "HEAD^"],
1317 "Git reset after empty amend failed",
1318 )?;
1319
1320 Ok(())
1321}
1322
1323fn stage_all_sync(repo_path: &Path) -> Result<(), GitError> {
1327 let output = run_git_command_with_index_lock_retry(repo_path, &["add", "-A"], &[])?;
1328
1329 if !output.status.success() {
1330 let detail = command_output_detail(&output.stdout, &output.stderr);
1331
1332 return Err(GitError::CommandFailed {
1333 command: "git add -A".to_string(),
1334 stderr: format!("Failed to stage changes: {detail}"),
1335 });
1336 }
1337
1338 Ok(())
1339}
1340
1341fn head_commit_message_sync(repo_path: &Path) -> Result<Option<String>, GitError> {
1343 if !has_head_commit_sync(repo_path)? {
1344 return Ok(None);
1345 }
1346
1347 let output = run_git_command_sync(
1348 repo_path,
1349 &["log", "-1", "--pretty=%B"],
1350 "Failed to read HEAD commit message",
1351 )?;
1352
1353 Ok(Some(output.trim().to_string()))
1354}
1355
1356fn has_head_commit_sync(repo_path: &Path) -> Result<bool, GitError> {
1358 let output = run_git_command_output_sync(repo_path, &["rev-parse", "--verify", "HEAD"])?;
1359
1360 if output.status.success() {
1361 return Ok(true);
1362 }
1363
1364 let detail = command_output_detail(&output.stdout, &output.stderr);
1365 let normalized_detail = detail.to_ascii_lowercase();
1366 if normalized_detail.contains("needed a single revision")
1367 || normalized_detail.contains("unknown revision")
1368 || normalized_detail.contains("does not have any commits yet")
1369 {
1370 return Ok(false);
1371 }
1372
1373 Err(GitError::CommandFailed {
1374 command: "git rev-parse --verify HEAD".to_string(),
1375 stderr: detail,
1376 })
1377}
1378
1379fn run_commit_command(
1383 repo_path: &Path,
1384 commit_message: &str,
1385 message_strategy: SingleCommitMessageStrategy,
1386 no_verify: bool,
1387 amend_existing_commit: bool,
1388) -> Result<Output, GitError> {
1389 let mut args = vec!["commit"];
1390 if amend_existing_commit {
1391 args.push("--amend");
1392 match message_strategy {
1393 SingleCommitMessageStrategy::Replace => {
1394 args.push("-m");
1395 args.push(commit_message);
1396 }
1397 SingleCommitMessageStrategy::Reuse => {
1398 args.push("--no-edit");
1399 }
1400 }
1401 } else {
1402 args.push("-m");
1403 args.push(commit_message);
1404 }
1405
1406 if no_verify {
1407 args.push("--no-verify");
1408 }
1409
1410 run_git_command_with_index_lock_retry(repo_path, &args, &[])
1411}
1412
1413fn is_hook_modified_error(stdout: &str, stderr: &str) -> bool {
1415 let combined = format!(
1416 "{stdout}
1417{stderr}"
1418 )
1419 .to_ascii_lowercase();
1420
1421 combined.contains("files were modified by this hook")
1422}
1423
1424pub(super) fn is_no_upstream_error(detail: &str) -> bool {
1426 let normalized_detail = detail.to_ascii_lowercase();
1427
1428 normalized_detail.contains("has no upstream branch")
1429 || normalized_detail.contains("no upstream branch")
1430 || normalized_detail.contains("set-upstream")
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use std::fs;
1436 #[cfg(unix)]
1437 use std::os::unix::fs::PermissionsExt;
1438 use std::path::Path;
1439 use std::process::{Command, Output};
1440
1441 use tempfile::tempdir;
1442
1443 use super::*;
1444
1445 fn run_git_command(repo_path: &Path, args: &[&str]) {
1447 let output = git_command_output(repo_path, args);
1448
1449 assert!(
1450 output.status.success(),
1451 "git command {:?} failed: {}",
1452 args,
1453 String::from_utf8_lossy(&output.stderr)
1454 );
1455 }
1456
1457 fn git_command_output(repo_path: &Path, args: &[&str]) -> Output {
1459 Command::new("git")
1460 .args(args)
1461 .current_dir(repo_path)
1462 .output()
1463 .expect("failed to run git command")
1464 }
1465
1466 fn git_command_stdout(repo_path: &Path, args: &[&str]) -> String {
1468 let output = git_command_output(repo_path, args);
1469
1470 assert!(
1471 output.status.success(),
1472 "git command {:?} failed: {}",
1473 args,
1474 String::from_utf8_lossy(&output.stderr)
1475 );
1476
1477 String::from_utf8(output.stdout)
1478 .expect("git stdout should be valid utf-8")
1479 .trim()
1480 .to_string()
1481 }
1482
1483 fn setup_test_git_repo(repo_path: &Path) {
1485 run_git_command(repo_path, &["init", "-b", "main"]);
1486 run_git_command(repo_path, &["config", "user.name", "Test User"]);
1487 run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
1488 fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
1489 run_git_command(repo_path, &["add", "README.md"]);
1490 run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
1491 }
1492
1493 #[tokio::test]
1494 async fn diff_preserves_staged_changes_and_includes_untracked_files() {
1495 let temp_dir = tempdir().expect("failed to create temp dir");
1497 setup_test_git_repo(temp_dir.path());
1498 fs::write(temp_dir.path().join("README.md"), "staged change\n")
1499 .expect("failed to write staged change");
1500 run_git_command(temp_dir.path(), &["add", "README.md"]);
1501 fs::write(
1502 temp_dir.path().join("README.md"),
1503 "staged change\nunstaged change\n",
1504 )
1505 .expect("failed to write unstaged change");
1506 fs::write(temp_dir.path().join("new.txt"), "untracked change\n")
1507 .expect("failed to write untracked file");
1508 let cached_diff_before = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1509 let status_before = git_command_output(
1510 temp_dir.path(),
1511 &["status", "--porcelain=v1", "--untracked-files=all"],
1512 )
1513 .stdout;
1514
1515 let result = diff(temp_dir.path().to_path_buf(), "main".to_string()).await;
1517 let changed_files =
1518 diff_changed_files(temp_dir.path().to_path_buf(), "main".to_string()).await;
1519
1520 let diff_output = result.expect("diff should succeed");
1522 let cached_diff_after = git_command_output(temp_dir.path(), &["diff", "--cached"]).stdout;
1523 let status_after = git_command_output(
1524 temp_dir.path(),
1525 &["status", "--porcelain=v1", "--untracked-files=all"],
1526 )
1527 .stdout;
1528 assert!(diff_output.contains("staged change"));
1529 assert!(diff_output.contains("unstaged change"));
1530 assert!(diff_output.contains("untracked change"));
1531 assert_eq!(
1532 changed_files.expect("changed files should load"),
1533 vec!["README.md".to_string(), "new.txt".to_string()]
1534 );
1535 assert_eq!(cached_diff_after, cached_diff_before);
1536 assert_eq!(status_after, status_before);
1537 }
1538
1539 #[tokio::test]
1540 async fn read_worktree_file_returns_text_for_safe_nested_path() {
1541 let temp_dir = tempdir().expect("failed to create temp dir");
1543 let docs_dir = temp_dir.path().join("docs");
1544 fs::create_dir(&docs_dir).expect("failed to create docs directory");
1545 fs::write(docs_dir.join("README.md"), "# Preview\n")
1546 .expect("failed to write markdown file");
1547
1548 let result =
1550 read_worktree_file(temp_dir.path().to_path_buf(), "docs/README.md".to_string()).await;
1551
1552 assert_eq!(
1554 result.expect("worktree read should succeed"),
1555 WorktreeFileContent::Text("# Preview\n".to_string())
1556 );
1557 }
1558
1559 #[tokio::test]
1560 async fn read_worktree_file_classifies_missing_binary_and_oversize_files() {
1561 let temp_dir = tempdir().expect("failed to create temp dir");
1563 fs::write(temp_dir.path().join("binary.md"), [0xff, 0xfe])
1564 .expect("failed to write binary file");
1565 fs::write(
1566 temp_dir.path().join("large.md"),
1567 vec![b'a'; MAX_WORKTREE_FILE_BYTE_COUNT + 1],
1568 )
1569 .expect("failed to write oversize file");
1570
1571 let missing =
1573 read_worktree_file(temp_dir.path().to_path_buf(), "missing.md".to_string()).await;
1574 let binary =
1575 read_worktree_file(temp_dir.path().to_path_buf(), "binary.md".to_string()).await;
1576 let too_large =
1577 read_worktree_file(temp_dir.path().to_path_buf(), "large.md".to_string()).await;
1578
1579 assert_eq!(
1581 missing.expect("missing read should succeed"),
1582 WorktreeFileContent::Missing
1583 );
1584 assert_eq!(
1585 binary.expect("binary read should succeed"),
1586 WorktreeFileContent::Binary
1587 );
1588 assert_eq!(
1589 too_large.expect("oversize read should succeed"),
1590 WorktreeFileContent::TooLarge
1591 );
1592 }
1593
1594 #[tokio::test]
1595 async fn read_worktree_file_rejects_unsafe_relative_paths() {
1596 let temp_dir = tempdir().expect("failed to create temp dir");
1598 let absolute_path = temp_dir.path().join("README.md");
1599
1600 let empty = read_worktree_file(temp_dir.path().to_path_buf(), String::new()).await;
1602 let parent =
1603 read_worktree_file(temp_dir.path().to_path_buf(), "../README.md".to_string()).await;
1604 let absolute = read_worktree_file(
1605 temp_dir.path().to_path_buf(),
1606 absolute_path.to_string_lossy().into_owned(),
1607 )
1608 .await;
1609
1610 for result in [empty, parent, absolute] {
1612 assert!(
1613 matches!(result, Err(GitError::OutputParse(message)) if message.contains("Unsafe worktree file path"))
1614 );
1615 }
1616 }
1617
1618 #[cfg(unix)]
1619 #[tokio::test]
1620 async fn read_worktree_file_rejects_symlinks_outside_repository() {
1621 let temp_dir = tempdir().expect("failed to create temp dir");
1623 let outside_dir = tempdir().expect("failed to create outside temp dir");
1624 let outside_file = outside_dir.path().join("outside.md");
1625 fs::write(&outside_file, "outside").expect("failed to write outside file");
1626 std::os::unix::fs::symlink(&outside_file, temp_dir.path().join("link.md"))
1627 .expect("failed to create outside symlink");
1628
1629 let result = read_worktree_file(temp_dir.path().to_path_buf(), "link.md".to_string()).await;
1631
1632 assert!(
1634 matches!(result, Err(GitError::OutputParse(message)) if message.contains("resolves outside repository"))
1635 );
1636 }
1637
1638 #[cfg(unix)]
1639 #[tokio::test]
1640 async fn read_worktree_file_maps_non_missing_path_resolution_errors() {
1641 let temp_dir = tempdir().expect("failed to create temp dir");
1643 std::os::unix::fs::symlink("loop.md", temp_dir.path().join("loop.md"))
1644 .expect("failed to create symlink loop");
1645
1646 let result = read_worktree_file(temp_dir.path().to_path_buf(), "loop.md".to_string()).await;
1648
1649 assert!(matches!(result, Err(GitError::Io(_))));
1651 }
1652
1653 #[test]
1654 fn copy_git_index_to_temp_maps_path_create_and_copy_failures() {
1655 let temp_dir = tempdir().expect("failed to create temp dir");
1657 let path_without_parent = Path::new("/");
1658 let missing_parent_index = temp_dir.path().join("missing-parent").join("index");
1659 let missing_index = temp_dir.path().join("missing-index");
1660
1661 let parent_error = copy_git_index_to_temp(path_without_parent);
1663 let create_error = copy_git_index_to_temp(&missing_parent_index);
1664 let copy_error = copy_git_index_to_temp(&missing_index);
1665
1666 assert!(matches!(parent_error, Err(GitError::OutputParse(_))));
1668 assert!(matches!(
1669 create_error,
1670 Err(GitError::CommandFailed { ref command, .. })
1671 if command == "create temporary git index"
1672 ));
1673 assert!(matches!(
1674 copy_error,
1675 Err(GitError::CommandFailed { ref command, .. }) if command == "copy git index"
1676 ));
1677 }
1678
1679 #[test]
1680 fn run_git_command_with_index_sync_maps_process_and_command_failures() {
1681 let temp_dir = tempdir().expect("failed to create temp dir");
1683 let index_path = temp_dir.path().join("index");
1684 let missing_repo_path = temp_dir.path().join("missing-repository");
1685 fs::write(&index_path, []).expect("failed to create temporary index");
1686
1687 let process_error = run_git_command_with_index_sync(
1689 &missing_repo_path,
1690 &["status"],
1691 &index_path,
1692 "Expected process failure",
1693 );
1694 let command_error = run_git_command_with_index_sync(
1695 temp_dir.path(),
1696 &["definitely-not-a-git-command"],
1697 &index_path,
1698 "Expected git failure",
1699 );
1700
1701 assert!(matches!(
1703 process_error,
1704 Err(GitError::CommandFailed { ref command, .. }) if command == "git status"
1705 ));
1706 assert!(matches!(
1707 command_error,
1708 Err(GitError::CommandFailed {
1709 ref command,
1710 ref stderr,
1711 }) if command == "git definitely-not-a-git-command"
1712 && stderr.starts_with("Expected git failure:")
1713 ));
1714 }
1715
1716 #[cfg(unix)]
1717 fn write_executable_pre_commit_hook(hook_path: &Path) {
1718 fs::create_dir_all(
1719 hook_path
1720 .parent()
1721 .expect("pre-commit hook should have a parent directory"),
1722 )
1723 .expect("failed to create hooks directory");
1724 fs::write(hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1725 let mut permissions = fs::metadata(hook_path)
1726 .expect("failed to read pre-commit hook metadata")
1727 .permissions();
1728 permissions.set_mode(0o755);
1729 fs::set_permissions(hook_path, permissions)
1730 .expect("failed to make pre-commit hook executable");
1731 }
1732
1733 #[test]
1734 fn ensure_pre_commit_hook_ready_allows_repositories_without_configuration() {
1735 let temp_dir = tempdir().expect("failed to create temp dir");
1737 setup_test_git_repo(temp_dir.path());
1738
1739 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1741
1742 assert!(result.is_ok());
1744 }
1745
1746 #[test]
1747 fn ensure_pre_commit_hook_ready_rejects_missing_hook() {
1748 let temp_dir = tempdir().expect("failed to create temp dir");
1750 setup_test_git_repo(temp_dir.path());
1751 fs::write(
1752 temp_dir.path().join(".pre-commit-config.yaml"),
1753 "repos: []\n",
1754 )
1755 .expect("failed to write pre-commit configuration");
1756
1757 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1759
1760 assert!(matches!(
1762 result,
1763 Err(GitError::PreCommitHookMissing { ref config_file })
1764 if config_file == ".pre-commit-config.yaml"
1765 ));
1766 }
1767
1768 #[cfg(unix)]
1769 #[test]
1770 fn ensure_pre_commit_hook_ready_accepts_default_executable_hook() {
1771 let temp_dir = tempdir().expect("failed to create temp dir");
1773 setup_test_git_repo(temp_dir.path());
1774 fs::write(
1775 temp_dir.path().join(".pre-commit-config.yaml"),
1776 "repos: []\n",
1777 )
1778 .expect("failed to write pre-commit configuration");
1779 let hook_path = temp_dir.path().join(git_command_stdout(
1780 temp_dir.path(),
1781 &["rev-parse", "--git-path", "hooks/pre-commit"],
1782 ));
1783 write_executable_pre_commit_hook(&hook_path);
1784
1785 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1787
1788 assert!(result.is_ok());
1790 }
1791
1792 #[cfg(unix)]
1793 #[test]
1794 fn ensure_pre_commit_hook_ready_accepts_custom_executable_hook() {
1795 let temp_dir = tempdir().expect("failed to create temp dir");
1797 setup_test_git_repo(temp_dir.path());
1798 fs::write(
1799 temp_dir.path().join(".pre-commit-config.yaml"),
1800 "repos: []\n",
1801 )
1802 .expect("failed to write pre-commit configuration");
1803 run_git_command(
1804 temp_dir.path(),
1805 &["config", "core.hooksPath", ".custom-hooks"],
1806 );
1807 write_executable_pre_commit_hook(&temp_dir.path().join(".custom-hooks").join("pre-commit"));
1808
1809 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1811
1812 assert!(result.is_ok());
1814 }
1815
1816 #[cfg(unix)]
1817 #[test]
1818 fn ensure_pre_commit_hook_ready_rejects_hook_inaccessible_to_owner() {
1819 let temp_dir = tempdir().expect("failed to create temp dir");
1821 setup_test_git_repo(temp_dir.path());
1822 fs::write(
1823 temp_dir.path().join(".pre-commit-config.yaml"),
1824 "repos: []\n",
1825 )
1826 .expect("failed to write pre-commit configuration");
1827 let hook_path = temp_dir.path().join(git_command_stdout(
1828 temp_dir.path(),
1829 &["rev-parse", "--git-path", "hooks/pre-commit"],
1830 ));
1831 fs::write(&hook_path, "#!/bin/sh\nexit 0\n").expect("failed to write pre-commit hook");
1832 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o011))
1833 .expect("failed to set mismatched execute permissions");
1834
1835 let result = ensure_pre_commit_hook_ready(temp_dir.path());
1837
1838 assert!(matches!(result, Err(GitError::PreCommitHookMissing { .. })));
1840 }
1841
1842 #[tokio::test]
1843 async fn commit_all_allows_configured_validation_without_hook() {
1844 let temp_dir = tempdir().expect("failed to create temp dir");
1846 setup_test_git_repo(temp_dir.path());
1847 fs::write(
1848 temp_dir.path().join(".pre-commit-config.yaml"),
1849 "repos: []\n",
1850 )
1851 .expect("failed to write pre-commit configuration");
1852 fs::write(temp_dir.path().join("README.md"), "changed\n")
1853 .expect("failed to write worktree change");
1854
1855 let result = commit_all(
1857 temp_dir.path().to_path_buf(),
1858 "Change README".to_string(),
1859 false,
1860 )
1861 .await;
1862
1863 assert!(result.is_ok());
1865 assert_eq!(
1866 git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%s"]),
1867 "Change README"
1868 );
1869 }
1870
1871 #[test]
1872 fn current_branch_name_returns_error_for_detached_head() {
1873 let temp_dir = tempdir().expect("failed to create temp dir");
1875 setup_test_git_repo(temp_dir.path());
1876 run_git_command(temp_dir.path(), &["checkout", "--detach"]);
1877
1878 let result = current_branch_name(temp_dir.path());
1880
1881 let error = result.expect_err("detached HEAD should fail");
1883 assert!(error.to_string().contains("detached HEAD"));
1884 }
1885
1886 #[test]
1887 fn primary_upstream_reference_uses_first_non_empty_line() {
1888 let temp_dir = tempdir().expect("failed to create temp dir");
1890 let remote_dir = tempdir().expect("failed to create remote temp dir");
1891 setup_test_git_repo(temp_dir.path());
1892 run_git_command(remote_dir.path(), &["init", "--bare"]);
1893 let remote_path = remote_dir.path().to_string_lossy().to_string();
1894 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1895 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1896 run_git_command(
1897 temp_dir.path(),
1898 &[
1899 "config",
1900 "--replace-all",
1901 "branch.main.merge",
1902 "refs/heads/main",
1903 ],
1904 );
1905 run_git_command(
1906 temp_dir.path(),
1907 &["config", "--add", "branch.main.merge", "refs/heads/feature"],
1908 );
1909
1910 let upstream_reference =
1912 primary_upstream_reference(temp_dir.path()).expect("failed to resolve upstream");
1913
1914 assert_eq!(upstream_reference, "origin/main");
1916 }
1917
1918 #[test]
1919 fn parse_branch_tracking_statuses_reads_repo_wide_branch_snapshot() {
1920 let output = "\
1922main\torigin/main\tbehind 2\nwt/1234abcd\torigin/wt/1234abcd\tahead 3, behind \
1923 1\nfeature/local\t\t\nfeature/gone\torigin/feature/gone\tgone\n";
1924
1925 let branch_tracking_statuses = parse_branch_tracking_statuses(output);
1927
1928 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 2))));
1930 assert_eq!(
1931 branch_tracking_statuses.get("wt/1234abcd"),
1932 Some(&Some((3, 1)))
1933 );
1934 assert_eq!(branch_tracking_statuses.get("feature/local"), Some(&None));
1935 assert_eq!(branch_tracking_statuses.get("feature/gone"), Some(&None));
1936 }
1937
1938 #[tokio::test]
1939 async fn pull_rebase_returns_conflict_detail_for_conflicting_remote_change() {
1940 let temp_dir = tempdir().expect("failed to create temp dir");
1942 let remote_dir = tempdir().expect("failed to create remote temp dir");
1943 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
1944 let contributor_clone_path = contributor_dir.path().join("clone");
1945 setup_test_git_repo(temp_dir.path());
1946 run_git_command(remote_dir.path(), &["init", "--bare"]);
1947 let remote_path = remote_dir.path().to_string_lossy().to_string();
1948 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
1949 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
1950 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
1951 fs::write(temp_dir.path().join("README.md"), "local change\n")
1952 .expect("failed to write local change");
1953 run_git_command(temp_dir.path(), &["add", "README.md"]);
1954 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
1955 run_git_command(
1956 contributor_dir.path(),
1957 &["clone", &remote_path, &contributor_clone_path_text],
1958 );
1959 run_git_command(
1960 &contributor_clone_path,
1961 &["config", "user.name", "Contributor User"],
1962 );
1963 run_git_command(
1964 &contributor_clone_path,
1965 &["config", "user.email", "contributor@example.com"],
1966 );
1967 run_git_command(
1968 &contributor_clone_path,
1969 &["checkout", "-B", "main", "origin/main"],
1970 );
1971 fs::write(contributor_clone_path.join("README.md"), "remote change\n")
1972 .expect("failed to write remote change");
1973 run_git_command(&contributor_clone_path, &["add", "README.md"]);
1974 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
1975 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
1976
1977 let result = pull_rebase(temp_dir.path().to_path_buf()).await;
1979
1980 assert!(matches!(
1982 result,
1983 Ok(PullRebaseResult::Conflict { ref detail })
1984 if {
1985 let normalized_detail = detail.to_ascii_lowercase();
1986
1987 (normalized_detail.contains("conflict")
1988 || normalized_detail.contains("could not apply"))
1989 && !detail.is_empty()
1990 }
1991 ));
1992 }
1993
1994 #[tokio::test]
1995 async fn push_current_branch_returns_rejected_error_for_non_fast_forward_push() {
1996 let temp_dir = tempdir().expect("failed to create temp dir");
1998 let remote_dir = tempdir().expect("failed to create remote temp dir");
1999 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2000 let contributor_clone_path = contributor_dir.path().join("clone");
2001 setup_test_git_repo(temp_dir.path());
2002 run_git_command(remote_dir.path(), &["init", "--bare"]);
2003 let remote_path = remote_dir.path().to_string_lossy().to_string();
2004 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2005 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2006 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2007 run_git_command(
2008 contributor_dir.path(),
2009 &["clone", &remote_path, &contributor_clone_path_text],
2010 );
2011 run_git_command(
2012 &contributor_clone_path,
2013 &["config", "user.name", "Contributor User"],
2014 );
2015 run_git_command(
2016 &contributor_clone_path,
2017 &["config", "user.email", "contributor@example.com"],
2018 );
2019 run_git_command(
2020 &contributor_clone_path,
2021 &["checkout", "-B", "main", "origin/main"],
2022 );
2023 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2024 .expect("failed to write remote file");
2025 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2026 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2027 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2028 fs::write(temp_dir.path().join("local.txt"), "local change")
2029 .expect("failed to write local file");
2030 run_git_command(temp_dir.path(), &["add", "local.txt"]);
2031 run_git_command(temp_dir.path(), &["commit", "-m", "Local change"]);
2032
2033 let result = push_current_branch(temp_dir.path().to_path_buf()).await;
2035
2036 let error = result
2038 .expect_err("non-fast-forward push should fail")
2039 .to_string();
2040 assert!(error.contains("git push"));
2041 assert!(
2042 error.contains("stale info")
2043 || error.contains("rejected")
2044 || error.contains("fetch first")
2045 );
2046 }
2047
2048 #[tokio::test]
2049 async fn push_current_branch_force_with_lease_updates_rewritten_history() {
2050 let temp_dir = tempdir().expect("failed to create temp dir");
2052 let remote_dir = tempdir().expect("failed to create remote temp dir");
2053 setup_test_git_repo(temp_dir.path());
2054 run_git_command(remote_dir.path(), &["init", "--bare"]);
2055 let remote_path = remote_dir.path().to_string_lossy().to_string();
2056 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2057 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2058 fs::write(
2059 temp_dir.path().join("README.md"),
2060 "first published version\n",
2061 )
2062 .expect("failed to write first version");
2063 run_git_command(temp_dir.path(), &["add", "README.md"]);
2064 run_git_command(temp_dir.path(), &["commit", "-m", "Publish branch change"]);
2065 push_current_branch(temp_dir.path().to_path_buf())
2066 .await
2067 .expect("initial push should succeed");
2068 fs::write(
2069 temp_dir.path().join("README.md"),
2070 "rewritten published version\n",
2071 )
2072 .expect("failed to rewrite published version");
2073 run_git_command(temp_dir.path(), &["add", "README.md"]);
2074 run_git_command(
2075 temp_dir.path(),
2076 &["commit", "--amend", "-m", "Rewrite published branch change"],
2077 );
2078
2079 let upstream_reference = push_current_branch(temp_dir.path().to_path_buf())
2081 .await
2082 .expect("force-with-lease push should update rewritten history");
2083 let local_head = git_command_stdout(temp_dir.path(), &["rev-parse", "HEAD"]);
2084 let remote_head = git_command_stdout(remote_dir.path(), &["rev-parse", "refs/heads/main"]);
2085
2086 assert_eq!(upstream_reference, "origin/main");
2088 assert_eq!(local_head, remote_head);
2089 }
2090
2091 #[tokio::test]
2092 async fn push_current_branch_to_remote_branch_returns_custom_upstream_reference() {
2093 let temp_dir = tempdir().expect("failed to create temp dir");
2095 let remote_dir = tempdir().expect("failed to create remote temp dir");
2096 setup_test_git_repo(temp_dir.path());
2097 run_git_command(remote_dir.path(), &["init", "--bare"]);
2098 let remote_path = remote_dir.path().to_string_lossy().to_string();
2099 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2100
2101 let upstream_reference = push_current_branch_to_remote_branch(
2103 temp_dir.path().to_path_buf(),
2104 "review/custom-branch".to_string(),
2105 )
2106 .await
2107 .expect("failed to push current branch to custom remote branch");
2108
2109 assert_eq!(upstream_reference, "origin/review/custom-branch");
2111 }
2112
2113 #[tokio::test]
2114 async fn current_upstream_reference_returns_origin_main() {
2115 let temp_dir = tempdir().expect("failed to create temp dir");
2117 let remote_dir = tempdir().expect("failed to create remote temp dir");
2118 setup_test_git_repo(temp_dir.path());
2119 run_git_command(remote_dir.path(), &["init", "--bare"]);
2120 let remote_path = remote_dir.path().to_string_lossy().to_string();
2121 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2122 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2123
2124 let upstream_reference = current_upstream_reference(temp_dir.path().to_path_buf())
2126 .await
2127 .expect("failed to resolve upstream reference");
2128
2129 assert_eq!(upstream_reference, "origin/main");
2131 }
2132
2133 #[tokio::test]
2134 async fn get_ref_ahead_behind_returns_counts_between_two_local_branches() {
2135 let temp_dir = tempdir().expect("failed to create temp dir");
2137 setup_test_git_repo(temp_dir.path());
2138 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2139 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2140 .expect("failed to write session file");
2141 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2142 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2143 run_git_command(temp_dir.path(), &["checkout", "main"]);
2144 fs::write(temp_dir.path().join("main.txt"), "main change\n")
2145 .expect("failed to write main file");
2146 run_git_command(temp_dir.path(), &["add", "main.txt"]);
2147 run_git_command(temp_dir.path(), &["commit", "-m", "Main change"]);
2148
2149 let status = get_ref_ahead_behind(
2151 temp_dir.path().to_path_buf(),
2152 "wt/1234abcd".to_string(),
2153 "main".to_string(),
2154 )
2155 .await
2156 .expect("failed to compare branch refs");
2157
2158 assert_eq!(status, (1, 1));
2160 }
2161
2162 #[tokio::test]
2163 async fn branch_tracking_statuses_returns_repo_wide_branch_counts() {
2164 let temp_dir = tempdir().expect("failed to create temp dir");
2166 let remote_dir = tempdir().expect("failed to create remote temp dir");
2167 let contributor_dir = tempdir().expect("failed to create contributor temp dir");
2168 let contributor_clone_path = contributor_dir.path().join("clone");
2169 setup_test_git_repo(temp_dir.path());
2170 run_git_command(remote_dir.path(), &["init", "--bare"]);
2171 let remote_path = remote_dir.path().to_string_lossy().to_string();
2172 let contributor_clone_path_text = contributor_clone_path.to_string_lossy().to_string();
2173 run_git_command(temp_dir.path(), &["remote", "add", "origin", &remote_path]);
2174 run_git_command(temp_dir.path(), &["push", "-u", "origin", "main"]);
2175 run_git_command(
2176 contributor_dir.path(),
2177 &["clone", &remote_path, &contributor_clone_path_text],
2178 );
2179 run_git_command(
2180 &contributor_clone_path,
2181 &["config", "user.name", "Contributor User"],
2182 );
2183 run_git_command(
2184 &contributor_clone_path,
2185 &["config", "user.email", "contributor@example.com"],
2186 );
2187 run_git_command(
2188 &contributor_clone_path,
2189 &["checkout", "-B", "main", "origin/main"],
2190 );
2191 fs::write(contributor_clone_path.join("remote.txt"), "remote change")
2192 .expect("failed to write remote file");
2193 run_git_command(&contributor_clone_path, &["add", "remote.txt"]);
2194 run_git_command(&contributor_clone_path, &["commit", "-m", "Remote change"]);
2195 run_git_command(&contributor_clone_path, &["push", "origin", "main"]);
2196 run_git_command(temp_dir.path(), &["checkout", "-b", "wt/1234abcd"]);
2197 fs::write(temp_dir.path().join("session.txt"), "session change\n")
2198 .expect("failed to write session file");
2199 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2200 run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
2201 run_git_command(temp_dir.path(), &["push", "-u", "origin", "wt/1234abcd"]);
2202 fs::write(
2203 temp_dir.path().join("session.txt"),
2204 "session change\nmore local\n",
2205 )
2206 .expect("failed to extend session file");
2207 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2208 run_git_command(temp_dir.path(), &["commit", "-m", "More session work"]);
2209 run_git_command(temp_dir.path(), &["fetch"]);
2210
2211 let branch_tracking_statuses = branch_tracking_statuses(temp_dir.path().to_path_buf())
2213 .await
2214 .expect("failed to read branch tracking statuses");
2215
2216 assert_eq!(branch_tracking_statuses.get("main"), Some(&Some((0, 1))));
2218 assert_eq!(
2219 branch_tracking_statuses.get("wt/1234abcd"),
2220 Some(&Some((1, 0)))
2221 );
2222 }
2223
2224 #[tokio::test]
2225 async fn test_empty_amend_resets_session_commit_and_returns_no_changes() {
2230 let temp_dir = tempdir().expect("failed to create temp dir");
2232 setup_test_git_repo(temp_dir.path());
2233 run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
2234 fs::write(temp_dir.path().join("session.txt"), "session work\n")
2235 .expect("failed to write session file");
2236 run_git_command(temp_dir.path(), &["add", "session.txt"]);
2237 run_git_command(temp_dir.path(), &["commit", "-m", "Session commit"]);
2238 fs::remove_file(temp_dir.path().join("session.txt"))
2239 .expect("failed to remove session file");
2240
2241 let result = commit_all_preserving_single_commit(
2245 temp_dir.path().to_path_buf(),
2246 "main".to_string(),
2247 "Session commit".to_string(),
2248 SingleCommitMessageStrategy::Replace,
2249 true,
2250 )
2251 .await;
2252
2253 let error = result.expect_err("amend-would-be-empty should fail");
2255 let commit_count = git_command_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
2256 let head_message = git_command_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
2257 let status = git_command_stdout(temp_dir.path(), &["status", "--porcelain"]);
2258
2259 assert!(
2260 error.to_string().contains("Nothing to commit"),
2261 "expected 'Nothing to commit' sentinel but got: {error}"
2262 );
2263 assert_eq!(commit_count, "1");
2264 assert_eq!(head_message, "Initial commit");
2265 assert!(status.is_empty());
2266 }
2267}