1use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use processkit::ProcessRunner;
25pub use processkit::{Error, ProcessResult, Result};
29
30mod parse;
31pub use parse::{
32 Branch, ChangeKind, Commit, DiffLine, DiffStat, FileDiff, Hunk, StatusEntry, Worktree,
33};
34
35pub const BINARY: &str = "git";
37
38#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub enum DiffSpec {
44 WorkingTree,
47 Rev(String),
49}
50
51#[derive(Debug, Clone)]
56#[non_exhaustive]
57pub struct WorktreeAdd {
58 pub path: PathBuf,
60 pub new_branch: Option<String>,
63 pub commitish: Option<String>,
65 pub no_checkout: bool,
68}
69
70impl WorktreeAdd {
71 pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
74 Self {
75 path: path.into(),
76 new_branch: None,
77 commitish: Some(commitish.into()),
78 no_checkout: false,
79 }
80 }
81
82 pub fn create_branch(
85 path: impl Into<PathBuf>,
86 name: impl Into<String>,
87 commitish: impl Into<String>,
88 ) -> Self {
89 Self {
90 path: path.into(),
91 new_branch: Some(name.into()),
92 commitish: Some(commitish.into()),
93 no_checkout: false,
94 }
95 }
96
97 pub fn no_checkout(mut self) -> Self {
100 self.no_checkout = true;
101 self
102 }
103}
104
105#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub struct GitPush {
112 pub remote: String,
114 pub refspec: String,
116 pub set_upstream: bool,
118}
119
120impl GitPush {
121 pub fn branch(name: impl Into<String>) -> Self {
123 Self {
124 remote: "origin".to_string(),
125 refspec: name.into(),
126 set_upstream: false,
127 }
128 }
129
130 pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
133 Self {
134 remote: "origin".to_string(),
135 refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
136 set_upstream: false,
137 }
138 }
139
140 pub fn remote(mut self, remote: impl Into<String>) -> Self {
142 self.remote = remote.into();
143 self
144 }
145
146 pub fn set_upstream(mut self) -> Self {
148 self.set_upstream = true;
149 self
150 }
151}
152
153#[cfg_attr(feature = "mock", mockall::automock)]
156#[async_trait::async_trait]
157pub trait GitApi: Send + Sync {
158 async fn run(&self, args: &[String]) -> Result<String>;
161 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
164 async fn version(&self) -> Result<String>;
166 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
168 async fn status_text(&self, dir: &Path) -> Result<String>;
171 async fn current_branch(&self, dir: &Path) -> Result<String>;
173 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
175 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
177 async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
179 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
181 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
184 async fn init(&self, dir: &Path) -> Result<()>;
186 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
188 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
190 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
192 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
194 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
196 async fn commit_paths(
199 &self,
200 dir: &Path,
201 paths: &[PathBuf],
202 message: &str,
203 amend: bool,
204 ) -> Result<()>;
205 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
208 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
211 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
215
216 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
221 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
223 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
226 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
229 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
231 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
236 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
238 async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
241 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
244
245 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
249 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
252 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
254 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
256 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
258 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
260 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
263 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
266 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
268
269 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
273 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
276 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
278
279 async fn fetch(&self, dir: &Path) -> Result<()>;
284 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
288 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
290 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
292 async fn merge_commit(
295 &self,
296 dir: &Path,
297 branch: &str,
298 no_ff: bool,
299 message: Option<String>,
300 ) -> Result<()>;
301 async fn merge_no_commit(
304 &self,
305 dir: &Path,
306 branch: &str,
307 squash: bool,
308 no_ff: bool,
309 ) -> Result<()>;
310 async fn merge_abort(&self, dir: &Path) -> Result<()>;
312 async fn merge_continue(&self, dir: &Path) -> Result<()>;
314 async fn reset_merge(&self, dir: &Path) -> Result<()>;
316 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
318 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
321 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
323 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
326 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
329 async fn stash_pop(&self, dir: &Path) -> Result<()>;
331
332 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
336 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
338 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
340 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
342 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
344}
345
346processkit::cli_client!(
347 pub struct Git => BINARY
350);
351
352#[async_trait::async_trait]
353impl<R: ProcessRunner> GitApi for Git<R> {
354 async fn run(&self, args: &[String]) -> Result<String> {
355 self.core.text(self.core.command(args)).await
356 }
357
358 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
359 self.core.capture(self.core.command(args)).await
360 }
361
362 async fn version(&self) -> Result<String> {
363 self.core.text(self.core.command(["--version"])).await
364 }
365
366 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
367 self.core
368 .parse(
369 self.core
370 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
371 parse::parse_porcelain,
372 )
373 .await
374 }
375
376 async fn status_text(&self, dir: &Path) -> Result<String> {
377 self.core
378 .text(self.core.command_in(dir, ["status", "--porcelain=v1"]))
379 .await
380 }
381
382 async fn current_branch(&self, dir: &Path) -> Result<String> {
383 self.core
384 .text(
385 self.core
386 .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
387 )
388 .await
389 }
390
391 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
392 self.core
393 .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
394 .await
395 }
396
397 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
398 let n = format!("-n{max}");
399 self.core
400 .parse(
401 self.core.command_in(
402 dir,
403 [
404 "log",
405 n.as_str(),
406 "-z",
407 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
408 ],
409 ),
410 parse::parse_log,
411 )
412 .await
413 }
414
415 async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
416 let n = format!("-n{max}");
417 self.core
418 .parse(
419 self.core.command_in(
420 dir,
421 [
422 "log",
423 range,
424 n.as_str(),
425 "-z",
426 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
427 ],
428 ),
429 parse::parse_log,
430 )
431 .await
432 }
433
434 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
435 self.core
436 .text(self.core.command_in(dir, ["rev-parse", rev]))
437 .await
438 }
439
440 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
441 self.core
442 .text(self.core.command_in(dir, ["rev-parse", "--short", rev]))
443 .await
444 }
445
446 async fn init(&self, dir: &Path) -> Result<()> {
447 self.core.unit(self.core.command_in(dir, ["init"])).await
448 }
449
450 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
451 let mut command = self.core.command_in(dir, ["add", "--"]);
453 for path in paths {
454 command = command.arg(path);
455 }
456 self.core.unit(command).await
457 }
458
459 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
460 self.core
461 .unit(self.core.command_in(dir, ["commit", "-m", message]))
462 .await
463 }
464
465 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
466 self.core
467 .unit(self.core.command_in(dir, ["branch", name]))
468 .await
469 }
470
471 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
472 self.core
473 .unit(self.core.command_in(dir, ["checkout", reference]))
474 .await
475 }
476
477 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
478 self.core
479 .unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
480 .await
481 }
482
483 async fn commit_paths(
484 &self,
485 dir: &Path,
486 paths: &[PathBuf],
487 message: &str,
488 amend: bool,
489 ) -> Result<()> {
490 let mut command = self.core.command_in(dir, ["commit"]);
493 if amend {
494 command = command.arg("--amend");
495 }
496 command = command.arg("-m").arg(message).arg("--only").arg("--");
497 for path in paths {
498 command = command.arg(path);
499 }
500 self.core.unit(command).await
501 }
502
503 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
504 self.core
505 .text(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
506 .await
507 }
508
509 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
510 Ok(!self
514 .core
515 .probe(
516 self.core
517 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
518 )
519 .await?)
520 }
521
522 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
523 self.core
526 .probe(self.core.command_in(dir, ["diff", "--quiet"]))
527 .await
528 }
529
530 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
531 Ok(PathBuf::from(
532 self.core
533 .text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
534 .await?,
535 ))
536 }
537
538 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
539 Ok(PathBuf::from(
540 self.core
541 .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
542 .await?,
543 ))
544 }
545
546 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
547 let spec = format!("{rev}^{{commit}}");
549 self.core
550 .text(
551 self.core
552 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
553 )
554 .await
555 }
556
557 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
558 let res = self
562 .core
563 .capture(
564 self.core
565 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
566 )
567 .await?;
568 if res.code() == Some(0) {
569 let out = res.stdout().trim();
572 Ok(Some(
573 out.strip_prefix("refs/remotes/origin/")
574 .unwrap_or(out)
575 .to_string(),
576 ))
577 } else {
578 Ok(None)
579 }
580 }
581
582 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
583 let refname = format!("refs/heads/{name}");
584 self.core
586 .probe(
587 self.core
588 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
589 )
590 .await
591 }
592
593 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
594 let refname = format!("refs/heads/{name}");
604 let cmd = self
605 .core
606 .command_in(dir, ["ls-remote", "origin", refname.as_str()])
607 .env("GIT_TERMINAL_PROMPT", "0")
608 .timeout(Duration::from_secs(10));
609 let res = self.core.capture(cmd).await?;
610 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
611 }
612
613 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
614 self.core
615 .text(self.core.command_in(dir, ["remote", "get-url", remote]))
616 .await
617 }
618
619 async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
620 match self
623 .core
624 .capture(self.core.command_in(
625 dir,
626 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
627 ))
628 .await?
629 {
630 res if res.code() == Some(0) => {
631 let name = res.stdout().trim();
632 Ok((!name.is_empty()).then(|| name.to_string()))
633 }
634 _ => Ok(None),
635 }
636 }
637
638 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
639 let cmd = self
642 .core
643 .command_in(dir, ["ls-remote", "--heads", remote])
644 .env("GIT_TERMINAL_PROMPT", "0");
645 self.core.parse(cmd, parse::parse_ls_remote_heads).await
646 }
647
648 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
649 let out = self
650 .core
651 .text(self.core.command_in(dir, ["branch", "--merged", target]))
652 .await?;
653 Ok(out
657 .lines()
658 .filter_map(|line| line.get(2..))
659 .any(|b| b == branch))
660 }
661
662 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
663 let flag = format!("--set-upstream-to={upstream}");
664 self.core
665 .unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
666 .await
667 }
668
669 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
670 let flag = if force { "-D" } else { "-d" };
671 self.core
672 .unit(self.core.command_in(dir, ["branch", flag, name]))
673 .await
674 }
675
676 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
677 self.core
678 .unit(self.core.command_in(dir, ["branch", "-m", old, new]))
679 .await
680 }
681
682 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
683 self.core
684 .try_parse(
685 self.core.command_in(dir, ["rev-list", "--count", range]),
686 |s| {
687 s.trim().parse::<usize>().map_err(|e| Error::Parse {
688 program: BINARY.to_string(),
689 message: e.to_string(),
690 })
691 },
692 )
693 .await
694 }
695
696 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
697 self.core
699 .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
700 .await
701 }
702
703 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
704 self.core
705 .parse(
706 self.core.command_in(dir, ["diff", "--shortstat", range]),
707 parse::parse_shortstat,
708 )
709 .await
710 }
711
712 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
713 let target = match spec {
717 DiffSpec::WorkingTree => {
718 if self.is_unborn(dir).await? {
722 EMPTY_TREE.to_string()
723 } else {
724 "HEAD".to_string()
725 }
726 }
727 DiffSpec::Rev(rev) => rev,
728 };
729 self.core
730 .text(self.core.command_in(
731 dir,
732 ["diff", target.as_str(), "--no-color", "--no-ext-diff", "-M"],
733 ))
734 .await
735 }
736
737 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
738 let text = self.diff_text(dir, spec).await?;
739 Ok(parse::parse_diff(&text))
740 }
741
742 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
743 self.core
745 .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
746 .await
747 }
748
749 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
750 let git_dir = self.resolved_git_dir(dir).await?;
751 Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
752 }
753
754 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
755 Ok(self
756 .resolved_git_dir(dir)
757 .await?
758 .join("MERGE_HEAD")
759 .exists())
760 }
761
762 async fn fetch(&self, dir: &Path) -> Result<()> {
763 let cmd = self
769 .core
770 .command_in(dir, ["fetch", "--quiet"])
771 .env("GIT_TERMINAL_PROMPT", "0")
772 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
773 self.core.unit(cmd).await
774 }
775
776 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
777 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
778 let cmd = self
779 .core
780 .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
781 .env("GIT_TERMINAL_PROMPT", "0")
782 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
783 self.core.unit(cmd).await
784 }
785
786 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
787 let mut args: Vec<&str> = vec!["push"];
788 if spec.set_upstream {
789 args.push("-u");
790 }
791 args.push(spec.remote.as_str());
792 args.push(spec.refspec.as_str());
793 let cmd = self
794 .core
795 .command_in(dir, args)
796 .env("GIT_TERMINAL_PROMPT", "0");
797 self.core.unit(cmd).await
798 }
799
800 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
801 self.core
802 .unit(self.core.command_in(dir, ["merge", "--squash", branch]))
803 .await
804 }
805
806 async fn merge_commit(
807 &self,
808 dir: &Path,
809 branch: &str,
810 no_ff: bool,
811 message: Option<String>,
812 ) -> Result<()> {
813 let mut args: Vec<&str> = vec!["merge"];
814 if no_ff {
815 args.push("--no-ff");
816 }
817 if let Some(msg) = message.as_deref() {
818 args.push("-m");
819 args.push(msg);
820 } else {
821 args.push("--no-edit");
824 }
825 args.push(branch);
826 self.core.unit(self.core.command_in(dir, args)).await
827 }
828
829 async fn merge_no_commit(
830 &self,
831 dir: &Path,
832 branch: &str,
833 squash: bool,
834 no_ff: bool,
835 ) -> Result<()> {
836 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
837 if squash {
840 args.push("--squash");
841 } else if no_ff {
842 args.push("--no-ff");
843 }
844 args.push(branch);
845 self.core.unit(self.core.command_in(dir, args)).await
846 }
847
848 async fn merge_abort(&self, dir: &Path) -> Result<()> {
849 self.core
850 .unit(self.core.command_in(dir, ["merge", "--abort"]))
851 .await
852 }
853
854 async fn merge_continue(&self, dir: &Path) -> Result<()> {
855 self.core
858 .unit(no_editor(
859 self.core.command_in(dir, ["commit", "--no-edit"]),
860 ))
861 .await
862 }
863
864 async fn reset_merge(&self, dir: &Path) -> Result<()> {
865 self.core
866 .unit(self.core.command_in(dir, ["reset", "--merge"]))
867 .await
868 }
869
870 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
871 self.core
872 .unit(self.core.command_in(dir, ["reset", "--hard", rev]))
873 .await
874 }
875
876 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
877 self.core
880 .unit(no_editor(self.core.command_in(dir, ["rebase", onto])))
881 .await
882 }
883
884 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
885 self.core
886 .unit(self.core.command_in(dir, ["rebase", "--abort"]))
887 .await
888 }
889
890 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
891 self.core
892 .unit(no_editor(
893 self.core.command_in(dir, ["rebase", "--continue"]),
894 ))
895 .await
896 }
897
898 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
899 let mut command = self.core.command_in(dir, ["stash", "push"]);
900 if include_untracked {
901 command = command.arg("--include-untracked");
902 }
903 self.core.unit(command).await
904 }
905
906 async fn stash_pop(&self, dir: &Path) -> Result<()> {
907 self.core
908 .unit(self.core.command_in(dir, ["stash", "pop"]))
909 .await
910 }
911
912 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
913 self.core
914 .parse(
915 self.core
916 .command_in(dir, ["worktree", "list", "--porcelain"]),
917 parse::parse_worktree_porcelain,
918 )
919 .await
920 }
921
922 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
923 let mut command = self.core.command_in(dir, ["worktree", "add"]);
924 if let Some(name) = spec.new_branch.as_deref() {
925 command = command.arg("-b").arg(name);
926 }
927 if spec.no_checkout {
928 command = command.arg("--no-checkout");
929 }
930 command = command.arg(&spec.path);
931 if let Some(commitish) = spec.commitish.as_deref() {
932 command = command.arg(commitish);
933 }
934 self.core.unit(command).await
935 }
936
937 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
938 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
939 if force {
940 command = command.arg("--force");
941 }
942 command = command.arg(path);
943 self.core.unit(command).await
944 }
945
946 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
947 let command = self
948 .core
949 .command_in(dir, ["worktree", "move"])
950 .arg(from)
951 .arg(to);
952 self.core.unit(command).await
953 }
954
955 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
956 self.core
957 .unit(self.core.command_in(dir, ["worktree", "prune"]))
958 .await
959 }
960}
961
962const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
972
973const FETCH_ATTEMPTS: u32 = 3;
975const FETCH_BACKOFF: Duration = Duration::from_millis(500);
977
978const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
980const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
982const TRANSIENT_FETCH_MARKERS: &[&str] = &[
984 "could not resolve host",
985 "couldn't resolve host",
986 "temporary failure in name resolution",
987 "connection timed out",
988 "connection refused",
989 "operation timed out",
990 "timed out",
991 "network is unreachable",
992 "failed to connect",
993 "could not read from remote repository",
994 "the remote end hung up",
995 "early eof",
996 "rpc failed",
997];
998
999fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
1001 let Error::Exit { stdout, stderr, .. } = err else {
1002 return false;
1003 };
1004 let out = stdout.to_ascii_lowercase();
1005 let errt = stderr.to_ascii_lowercase();
1006 markers.iter().any(|m| out.contains(m) || errt.contains(m))
1007}
1008
1009pub fn is_merge_conflict(err: &Error) -> bool {
1011 exit_output_matches(err, CONFLICT_MARKERS)
1012}
1013
1014pub fn is_nothing_to_commit(err: &Error) -> bool {
1017 exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
1018}
1019
1020pub fn is_transient_fetch_error(err: &Error) -> bool {
1023 matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
1026}
1027
1028fn no_editor(cmd: processkit::Command) -> processkit::Command {
1032 cmd.env("GIT_EDITOR", "true")
1033 .env("GIT_SEQUENCE_EDITOR", "true")
1034}
1035
1036impl<R: ProcessRunner> Git<R> {
1037 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1042 self.core.text(self.core.command(args)).await
1043 }
1044
1045 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1048 self.core.capture(self.core.command(args)).await
1049 }
1050
1051 pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
1056 GitAt { git: self, dir }
1057 }
1058
1059 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
1062 let git_dir = PathBuf::from(
1063 self.core
1064 .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1065 .await?,
1066 );
1067 Ok(if git_dir.is_absolute() {
1068 git_dir
1069 } else {
1070 dir.join(git_dir)
1071 })
1072 }
1073}
1074
1075pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
1080 git: &'a Git<R>,
1081 dir: &'a Path,
1082}
1083
1084impl<R: ProcessRunner> Clone for GitAt<'_, R> {
1089 fn clone(&self) -> Self {
1090 *self
1091 }
1092}
1093impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
1094
1095macro_rules! git_at_forwarders {
1098 (
1099 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1100 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1101 ) => {
1102 impl<'a, R: ProcessRunner> GitAt<'a, R> {
1103 $(
1104 #[doc = concat!("Bound form of [`Git`]'s `", stringify!($bn), "`.")]
1105 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1106 self.git.$bn($($ba),*).await
1107 }
1108 )*
1109 $(
1110 #[doc = concat!("Bound form of [`Git`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1111 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1112 self.git.$dn(self.dir, $($da),*).await
1113 }
1114 )*
1115 }
1116 };
1117}
1118
1119git_at_forwarders! {
1120 bare {
1121 fn run(args: &[String]) -> Result<String>;
1122 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1123 fn run_args(args: &[&str]) -> Result<String>;
1124 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1125 fn version() -> Result<String>;
1126 }
1127 dir {
1128 fn status() -> Result<Vec<StatusEntry>>;
1129 fn status_text() -> Result<String>;
1130 fn current_branch() -> Result<String>;
1131 fn branches() -> Result<Vec<Branch>>;
1132 fn log(max: usize) -> Result<Vec<Commit>>;
1133 fn log_range(range: &str, max: usize) -> Result<Vec<Commit>>;
1134 fn rev_parse(rev: &str) -> Result<String>;
1135 fn rev_parse_short(rev: &str) -> Result<String>;
1136 fn init() -> Result<()>;
1137 fn add(paths: &[PathBuf]) -> Result<()>;
1138 fn commit(message: &str) -> Result<()>;
1139 fn create_branch(name: &str) -> Result<()>;
1140 fn checkout(reference: &str) -> Result<()>;
1141 fn checkout_detach(commit: &str) -> Result<()>;
1142 fn commit_paths(paths: &[PathBuf], message: &str, amend: bool) -> Result<()>;
1143 fn last_commit_message() -> Result<String>;
1144 fn is_unborn() -> Result<bool>;
1145 fn diff_is_empty() -> Result<bool>;
1146 fn common_dir() -> Result<PathBuf>;
1147 fn git_dir() -> Result<PathBuf>;
1148 fn resolve_commit(rev: &str) -> Result<String>;
1149 fn remote_head_branch() -> Result<Option<String>>;
1150 fn branch_exists(name: &str) -> Result<bool>;
1151 fn remote_branch_exists(name: &str) -> Result<bool>;
1152 fn remote_url(remote: &str) -> Result<String>;
1153 fn upstream() -> Result<Option<String>>;
1154 fn remote_branches(remote: &str) -> Result<Vec<String>>;
1155 fn is_merged(branch: &str, target: &str) -> Result<bool>;
1156 fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
1157 fn delete_branch(name: &str, force: bool) -> Result<()>;
1158 fn rename_branch(old: &str, new: &str) -> Result<()>;
1159 fn rev_list_count(range: &str) -> Result<usize>;
1160 fn diff_range_is_empty(range: &str) -> Result<bool>;
1161 fn diff_stat(range: &str) -> Result<DiffStat>;
1162 fn diff_text(spec: DiffSpec) -> Result<String>;
1163 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
1164 fn staged_is_empty() -> Result<bool>;
1165 fn is_rebase_in_progress() -> Result<bool>;
1166 fn is_merge_in_progress() -> Result<bool>;
1167 fn fetch() -> Result<()>;
1168 fn fetch_remote_branch(branch: &str) -> Result<()>;
1169 fn push(spec: GitPush) -> Result<()>;
1170 fn merge_squash(branch: &str) -> Result<()>;
1171 fn merge_commit(branch: &str, no_ff: bool, message: Option<String>) -> Result<()>;
1172 fn merge_no_commit(branch: &str, squash: bool, no_ff: bool) -> Result<()>;
1173 fn merge_abort() -> Result<()>;
1174 fn merge_continue() -> Result<()>;
1175 fn reset_merge() -> Result<()>;
1176 fn reset_hard(rev: &str) -> Result<()>;
1177 fn rebase(onto: &str) -> Result<()>;
1178 fn rebase_abort() -> Result<()>;
1179 fn rebase_continue() -> Result<()>;
1180 fn stash_push(include_untracked: bool) -> Result<()>;
1181 fn stash_pop() -> Result<()>;
1182 fn worktree_list() -> Result<Vec<Worktree>>;
1183 fn worktree_add(spec: WorktreeAdd) -> Result<()>;
1184 fn worktree_remove(path: &Path, force: bool) -> Result<()>;
1185 fn worktree_move(from: &Path, to: &Path) -> Result<()>;
1186 fn worktree_prune() -> Result<()>;
1187 }
1188}
1189
1190pub mod blocking {
1194 use std::path::Path;
1195 use std::process::Command;
1196
1197 pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
1199 let mut cmd = Command::new(super::BINARY);
1200 cmd.current_dir(dir).args(["worktree", "remove"]);
1201 if force {
1202 cmd.arg("--force");
1203 }
1204 cmd.arg(path);
1205 let status = cmd.status()?;
1206 if status.success() {
1207 Ok(())
1208 } else {
1209 Err(std::io::Error::other(format!(
1210 "`git worktree remove` exited with {status}"
1211 )))
1212 }
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219 use processkit::{RecordingRunner, Reply, ScriptedRunner};
1220
1221 #[test]
1222 fn binary_name_is_git() {
1223 assert_eq!(BINARY, "git");
1224 }
1225
1226 #[allow(dead_code)]
1230 fn bound_view_is_copy_for_default_runner() {
1231 fn assert_copy<T: Copy>() {}
1232 assert_copy::<GitAt<'static, processkit::JobRunner>>();
1233 }
1234
1235 #[tokio::test]
1239 async fn bound_view_matches_dir_taking_calls() {
1240 let dir = Path::new("/repo");
1241 let rec = RecordingRunner::replying(Reply::ok(""));
1242 let git = Git::with_runner(&rec);
1243
1244 git.merge_commit(dir, "feat", true, None).await.unwrap();
1246 git.at(dir).merge_commit("feat", true, None).await.unwrap();
1247 git.worktree_remove(dir, Path::new("/wt"), true)
1249 .await
1250 .unwrap();
1251 git.at(dir)
1252 .worktree_remove(Path::new("/wt"), true)
1253 .await
1254 .unwrap();
1255
1256 let calls = rec.calls();
1257 assert_eq!(calls[0].args_str(), calls[1].args_str());
1258 assert_eq!(calls[2].args_str(), calls[3].args_str());
1259 assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
1261 assert_eq!(calls[3].cwd.as_deref(), Some(dir.as_os_str()));
1262 }
1263
1264 #[tokio::test]
1267 async fn status_parses_scripted_output() {
1268 let git =
1270 Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
1271 let entries = git.status(Path::new(".")).await.expect("status");
1272 assert_eq!(entries.len(), 2);
1273 assert_eq!(entries[0].code, " M");
1274 assert_eq!(entries[1].path, "b.rs");
1275 }
1276
1277 #[tokio::test]
1278 async fn rev_parse_short_builds_short_flag() {
1279 let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
1280 let git = Git::with_runner(&rec);
1281 let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
1282 assert_eq!(out, "a1b2c3d");
1283 assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
1284 }
1285
1286 #[tokio::test]
1288 async fn nonzero_exit_is_structured_error() {
1289 let git = Git::with_runner(
1290 ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
1291 );
1292 match git.status(Path::new(".")).await.unwrap_err() {
1293 Error::Exit { code, stderr, .. } => {
1294 assert_eq!(code, 128);
1295 assert!(stderr.contains("not a git repository"), "{stderr}");
1296 }
1297 other => panic!("expected Exit, got {other:?}"),
1298 }
1299 }
1300
1301 #[tokio::test]
1304 async fn diff_is_empty_maps_exit_codes() {
1305 let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
1306 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
1307
1308 let dirty =
1309 Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
1310 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
1311
1312 let broken = Git::with_runner(
1313 ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
1314 );
1315 assert!(matches!(
1316 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
1317 Error::Exit { code: 128, .. }
1318 ));
1319 }
1320
1321 #[tokio::test]
1324 async fn add_inserts_pathspec_separator() {
1325 let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
1326 git.add(Path::new("."), &[PathBuf::from("f.rs")])
1327 .await
1328 .expect("add should build `add -- <paths>`");
1329 }
1330
1331 #[tokio::test]
1332 async fn worktree_list_parses_porcelain() {
1333 let git = Git::with_runner(ScriptedRunner::new().on(
1334 ["worktree", "list"],
1335 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
1336 ));
1337 let wts = git.worktree_list(Path::new(".")).await.expect("list");
1338 assert_eq!(wts.len(), 1);
1339 assert_eq!(wts[0].branch.as_deref(), Some("main"));
1340 assert_eq!(wts[0].head.as_deref(), Some("abc"));
1341 }
1342
1343 #[tokio::test]
1346 async fn worktree_add_builds_branch_path_and_base() {
1347 let rec = RecordingRunner::replying(Reply::ok(""));
1348 let git = Git::with_runner(&rec);
1349 git.worktree_add(
1350 Path::new("/repo"),
1351 WorktreeAdd::create_branch("/wt", "feature", "main"),
1352 )
1353 .await
1354 .expect("worktree add");
1355 assert_eq!(
1356 rec.only_call().args_str(),
1357 ["worktree", "add", "-b", "feature", "/wt", "main"]
1358 );
1359 }
1360
1361 #[tokio::test]
1362 async fn worktree_remove_passes_force_then_path() {
1363 let rec = RecordingRunner::replying(Reply::ok(""));
1364 let git = Git::with_runner(&rec);
1365 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
1366 .await
1367 .expect("remove");
1368 assert_eq!(
1369 rec.only_call().args_str(),
1370 ["worktree", "remove", "--force", "/wt"]
1371 );
1372 }
1373
1374 #[tokio::test]
1376 async fn worktree_add_no_checkout_inserts_flag() {
1377 let rec = RecordingRunner::replying(Reply::ok(""));
1378 let git = Git::with_runner(&rec);
1379 git.worktree_add(
1380 Path::new("/repo"),
1381 WorktreeAdd::checkout("/wt", "main").no_checkout(),
1382 )
1383 .await
1384 .expect("worktree add");
1385 assert_eq!(
1386 rec.only_call().args_str(),
1387 ["worktree", "add", "--no-checkout", "/wt", "main"]
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn checkout_detach_builds_args() {
1393 let rec = RecordingRunner::replying(Reply::ok(""));
1394 let git = Git::with_runner(&rec);
1395 git.checkout_detach(Path::new("."), "abc123")
1396 .await
1397 .expect("detach");
1398 assert_eq!(
1399 rec.only_call().args_str(),
1400 ["checkout", "--detach", "abc123"]
1401 );
1402 }
1403
1404 #[tokio::test]
1406 async fn commit_paths_builds_only_amend_args() {
1407 let rec = RecordingRunner::replying(Reply::ok(""));
1408 let git = Git::with_runner(&rec);
1409 git.commit_paths(
1410 Path::new("."),
1411 &[PathBuf::from("a.rs"), PathBuf::from("b.rs")],
1412 "msg",
1413 true,
1414 )
1415 .await
1416 .expect("commit_paths");
1417 assert_eq!(
1418 rec.only_call().args_str(),
1419 [
1420 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
1421 ]
1422 );
1423 }
1424
1425 #[tokio::test]
1428 async fn is_unborn_maps_exit_codes() {
1429 let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
1430 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
1431 let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
1432 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
1433 let broken =
1434 Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
1435 assert!(matches!(
1436 broken.is_unborn(Path::new(".")).await.unwrap_err(),
1437 Error::Exit { code: 128, .. }
1438 ));
1439 }
1440
1441 #[tokio::test]
1442 async fn log_range_builds_range_and_format() {
1443 let rec = RecordingRunner::replying(Reply::ok(""));
1444 let git = Git::with_runner(&rec);
1445 git.log_range(Path::new("."), "main..HEAD", 5)
1446 .await
1447 .expect("log_range");
1448 assert_eq!(
1449 rec.only_call().args_str(),
1450 [
1451 "log",
1452 "main..HEAD",
1453 "-n5",
1454 "-z",
1455 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
1456 ]
1457 );
1458 }
1459
1460 #[tokio::test]
1461 async fn stash_push_adds_include_untracked() {
1462 let rec = RecordingRunner::replying(Reply::ok(""));
1463 let git = Git::with_runner(&rec);
1464 git.stash_push(Path::new("."), true).await.expect("stash");
1465 assert_eq!(
1466 rec.only_call().args_str(),
1467 ["stash", "push", "--include-untracked"]
1468 );
1469 }
1470
1471 #[tokio::test]
1474 async fn diff_text_builds_working_tree_args() {
1475 let rec = RecordingRunner::replying(Reply::ok(""));
1478 let git = Git::with_runner(&rec);
1479 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
1480 .await
1481 .expect("diff_text");
1482 assert_eq!(
1483 rec.calls().last().unwrap().args_str(),
1484 ["diff", "HEAD", "--no-color", "--no-ext-diff", "-M"]
1485 );
1486 }
1487
1488 #[tokio::test]
1492 async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
1493 let git = Git::with_runner(
1494 ScriptedRunner::new()
1495 .on(["rev-parse"], Reply::fail(1, "")) .on(["diff", EMPTY_TREE], Reply::ok("EMPTY")),
1497 );
1498 let out = git
1499 .diff_text(Path::new("."), DiffSpec::WorkingTree)
1500 .await
1501 .expect("diff_text");
1502 assert_eq!(out, "EMPTY");
1503 }
1504
1505 #[tokio::test]
1508 async fn diff_parses_scripted_output() {
1509 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
1510 let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
1511 let files = git
1512 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
1513 .await
1514 .expect("diff");
1515 assert_eq!(files.len(), 1);
1516 assert_eq!(files[0].path, "m");
1517 assert_eq!(files[0].change, ChangeKind::Modified);
1518 }
1519
1520 #[tokio::test]
1521 async fn branch_exists_maps_exit_codes() {
1522 let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
1523 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
1524 let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
1525 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
1526 }
1527
1528 #[tokio::test]
1531 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
1532 let simple = Git::with_runner(
1533 ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
1534 );
1535 assert_eq!(
1536 simple
1537 .remote_head_branch(Path::new("."))
1538 .await
1539 .unwrap()
1540 .as_deref(),
1541 Some("main")
1542 );
1543
1544 let slashed = Git::with_runner(ScriptedRunner::new().on(
1545 ["symbolic-ref"],
1546 Reply::ok("refs/remotes/origin/release/v2\n"),
1547 ));
1548 assert_eq!(
1549 slashed
1550 .remote_head_branch(Path::new("."))
1551 .await
1552 .unwrap()
1553 .as_deref(),
1554 Some("release/v2")
1555 );
1556
1557 let unset =
1558 Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
1559 assert!(
1560 unset
1561 .remote_head_branch(Path::new("."))
1562 .await
1563 .unwrap()
1564 .is_none()
1565 );
1566 }
1567
1568 #[tokio::test]
1571 async fn remote_branch_exists_sets_env_and_reads_stdout() {
1572 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
1573 let git = Git::with_runner(&rec);
1574 assert!(
1575 git.remote_branch_exists(Path::new("/repo"), "main")
1576 .await
1577 .unwrap()
1578 );
1579 let call = rec.only_call();
1580 assert!(call.envs.iter().any(|(k, v)| {
1581 k.to_str() == Some("GIT_TERMINAL_PROMPT")
1582 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
1583 }));
1584 assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
1586
1587 let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
1588 assert!(
1589 !empty
1590 .remote_branch_exists(Path::new("."), "x")
1591 .await
1592 .unwrap()
1593 );
1594 }
1595
1596 #[tokio::test]
1597 async fn diff_stat_parses_counts() {
1598 let git = Git::with_runner(ScriptedRunner::new().on(
1599 ["diff", "--shortstat"],
1600 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
1601 ));
1602 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
1603 assert_eq!(
1604 (stat.files_changed, stat.insertions, stat.deletions),
1605 (2, 5, 1)
1606 );
1607 }
1608
1609 #[tokio::test]
1610 async fn status_text_returns_raw_porcelain() {
1611 let git = Git::with_runner(ScriptedRunner::new().on(
1612 ["status", "--porcelain=v1"],
1613 Reply::ok(" M a.rs\n?? b.rs\n"),
1614 ));
1615 let text = git.status_text(Path::new(".")).await.expect("status_text");
1616 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
1617 }
1618
1619 #[tokio::test]
1620 async fn run_args_forwards_str_slices() {
1621 let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
1622 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
1623 }
1624
1625 #[test]
1628 fn classifies_merge_conflict() {
1629 let on_stdout = Error::Exit {
1630 program: "git".into(),
1631 code: 1,
1632 stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
1633 stderr: String::new(),
1634 };
1635 let on_stderr = Error::Exit {
1636 program: "git".into(),
1637 code: 1,
1638 stdout: String::new(),
1639 stderr: "Automatic merge failed; fix conflicts and then commit".into(),
1640 };
1641 let unrelated = Error::Exit {
1642 program: "git".into(),
1643 code: 128,
1644 stdout: String::new(),
1645 stderr: "fatal: not a git repository".into(),
1646 };
1647 assert!(is_merge_conflict(&on_stdout));
1648 assert!(is_merge_conflict(&on_stderr));
1649 assert!(!is_merge_conflict(&unrelated));
1650 assert!(!is_nothing_to_commit(&on_stdout));
1651 }
1652
1653 #[test]
1654 fn classifies_nothing_to_commit_and_transient_fetch() {
1655 let nothing = Error::Exit {
1656 program: "git".into(),
1657 code: 1,
1658 stdout: "nothing to commit, working tree clean".into(),
1659 stderr: String::new(),
1660 };
1661 assert!(is_nothing_to_commit(¬hing));
1662
1663 let dns = Error::Exit {
1664 program: "git".into(),
1665 code: 128,
1666 stdout: String::new(),
1667 stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
1668 };
1669 assert!(is_transient_fetch_error(&dns));
1670 assert!(!is_transient_fetch_error(¬hing));
1671
1672 let timeout = Error::Timeout {
1674 program: "git".into(),
1675 timeout: Duration::from_secs(10),
1676 };
1677 assert!(is_transient_fetch_error(&timeout));
1678 }
1679
1680 #[tokio::test]
1681 async fn merge_commit_builds_no_ff_and_message() {
1682 let rec = RecordingRunner::replying(Reply::ok(""));
1683 let git = Git::with_runner(&rec);
1684 git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
1685 .await
1686 .unwrap();
1687 assert_eq!(
1688 rec.only_call().args_str(),
1689 ["merge", "--no-ff", "-m", "merge it", "feature"]
1690 );
1691 }
1692
1693 #[tokio::test]
1695 async fn merge_commit_without_message_uses_no_edit() {
1696 let rec = RecordingRunner::replying(Reply::ok(""));
1697 let git = Git::with_runner(&rec);
1698 git.merge_commit(Path::new("/r"), "feature", false, None)
1699 .await
1700 .unwrap();
1701 assert_eq!(
1702 rec.only_call().args_str(),
1703 ["merge", "--no-edit", "feature"]
1704 );
1705 }
1706
1707 #[tokio::test]
1709 async fn rebase_suppresses_editor() {
1710 let rec = RecordingRunner::replying(Reply::ok(""));
1711 let git = Git::with_runner(&rec);
1712 git.rebase(Path::new("/r"), "main").await.unwrap();
1713 let call = rec.only_call();
1714 assert_eq!(call.args_str(), ["rebase", "main"]);
1715 assert!(call.envs.iter().any(|(k, v)| {
1716 k.to_str() == Some("GIT_EDITOR")
1717 && v.as_deref().and_then(|o| o.to_str()) == Some("true")
1718 }));
1719 }
1720
1721 #[tokio::test]
1722 async fn push_builds_set_upstream_remote_refspec() {
1723 let rec = RecordingRunner::replying(Reply::ok(""));
1724 let git = Git::with_runner(&rec);
1725 git.push(
1726 Path::new("/r"),
1727 GitPush::refspec("feat", "feature").set_upstream(),
1728 )
1729 .await
1730 .unwrap();
1731 assert_eq!(
1732 rec.only_call().args_str(),
1733 ["push", "-u", "origin", "feat:feature"]
1734 );
1735 }
1736
1737 #[tokio::test]
1738 async fn upstream_maps_unset_to_none() {
1739 let set =
1740 Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("origin/main\n")));
1741 assert_eq!(
1742 set.upstream(Path::new(".")).await.unwrap().as_deref(),
1743 Some("origin/main")
1744 );
1745 let unset = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "")));
1746 assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
1747 }
1748
1749 #[tokio::test]
1750 async fn set_upstream_builds_branch_flag() {
1751 let rec = RecordingRunner::replying(Reply::ok(""));
1752 let git = Git::with_runner(&rec);
1753 git.set_upstream(Path::new("/r"), "feat", "origin/feature")
1754 .await
1755 .unwrap();
1756 assert_eq!(
1757 rec.only_call().args_str(),
1758 ["branch", "--set-upstream-to=origin/feature", "feat"]
1759 );
1760 }
1761
1762 #[tokio::test]
1763 async fn remote_branches_parses_ls_remote() {
1764 let git = Git::with_runner(ScriptedRunner::new().on(
1765 ["ls-remote"],
1766 Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
1767 ));
1768 let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
1769 assert_eq!(branches, ["main", "feat/x"]);
1770 }
1771
1772 #[tokio::test]
1773 async fn delete_branch_force_uses_capital_d() {
1774 let rec = RecordingRunner::replying(Reply::ok(""));
1775 let git = Git::with_runner(&rec);
1776 git.delete_branch(Path::new("/r"), "old", true)
1777 .await
1778 .unwrap();
1779 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
1780 }
1781
1782 #[tokio::test]
1785 async fn is_merged_strips_branch_markers() {
1786 let git = Git::with_runner(ScriptedRunner::new().on(
1787 ["branch", "--merged"],
1788 Reply::ok(" main\n* feature\n+ wt-branch\n"),
1789 ));
1790 for name in ["main", "feature", "wt-branch"] {
1791 assert!(
1792 git.is_merged(Path::new("."), name, "main").await.unwrap(),
1793 "{name} should be reported merged"
1794 );
1795 }
1796 assert!(
1797 !git.is_merged(Path::new("."), "absent", "main")
1798 .await
1799 .unwrap()
1800 );
1801 }
1802
1803 #[tokio::test]
1806 async fn fetch_disables_terminal_prompt() {
1807 let rec = RecordingRunner::replying(Reply::ok(""));
1808 let git = Git::with_runner(&rec);
1809 git.fetch(Path::new("/r")).await.unwrap();
1810 let call = rec.only_call();
1811 assert_eq!(call.args_str(), ["fetch", "--quiet"]);
1812 assert!(call.envs.iter().any(|(k, v)| {
1813 k.to_str() == Some("GIT_TERMINAL_PROMPT")
1814 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
1815 }));
1816 }
1817
1818 #[tokio::test]
1820 async fn fetch_retries_transient_failures() {
1821 let rec = RecordingRunner::replying(Reply::fail(
1822 128,
1823 "fatal: unable to access: Could not resolve host: example.com",
1824 ));
1825 let git = Git::with_runner(&rec);
1826 assert!(git.fetch(Path::new("/r")).await.is_err());
1827 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
1828 }
1829
1830 #[tokio::test]
1832 async fn fetch_does_not_retry_permanent_failures() {
1833 let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
1834 let git = Git::with_runner(&rec);
1835 assert!(git.fetch(Path::new("/r")).await.is_err());
1836 assert_eq!(rec.calls().len(), 1);
1837 }
1838
1839 #[cfg(feature = "mock")]
1842 #[tokio::test]
1843 async fn consumer_mocks_the_interface() {
1844 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
1845 git.current_branch(Path::new(".")).await.unwrap() == want
1846 }
1847 let mut mock = MockGitApi::new();
1848 mock.expect_current_branch()
1849 .returning(|_| Ok("main".to_string()));
1850 assert!(on_branch(&mock, "main").await);
1851 }
1852}