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#[cfg_attr(feature = "mock", mockall::automock)]
108#[async_trait::async_trait]
109pub trait GitApi: Send + Sync {
110 async fn run(&self, args: &[String]) -> Result<String>;
113 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
116 async fn version(&self) -> Result<String>;
118 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
120 async fn status_text(&self, dir: &Path) -> Result<String>;
123 async fn current_branch(&self, dir: &Path) -> Result<String>;
125 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
127 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
129 async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
131 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
133 async fn init(&self, dir: &Path) -> Result<()>;
135 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
137 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
139 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
141 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
143 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
145 async fn commit_paths(
148 &self,
149 dir: &Path,
150 paths: &[PathBuf],
151 message: &str,
152 amend: bool,
153 ) -> Result<()>;
154 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
157 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
160 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
162
163 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
168 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
170 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
173 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
176 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
178 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
182 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
184
185 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
189 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
191 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
193 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
195 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
197 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
200 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
203 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
205
206 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
210 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
213 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
215
216 async fn fetch(&self, dir: &Path) -> Result<()>;
220 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
224 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
226 async fn merge_commit(
228 &self,
229 dir: &Path,
230 branch: &str,
231 no_ff: bool,
232 message: Option<String>,
233 ) -> Result<()>;
234 async fn merge_no_commit(
237 &self,
238 dir: &Path,
239 branch: &str,
240 squash: bool,
241 no_ff: bool,
242 ) -> Result<()>;
243 async fn merge_abort(&self, dir: &Path) -> Result<()>;
245 async fn merge_continue(&self, dir: &Path) -> Result<()>;
247 async fn reset_merge(&self, dir: &Path) -> Result<()>;
249 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
251 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
253 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
255 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
257 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
260 async fn stash_pop(&self, dir: &Path) -> Result<()>;
262
263 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
267 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
269 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
271 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
273 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
275}
276
277processkit::cli_client!(
278 pub struct Git => BINARY
281);
282
283#[async_trait::async_trait]
284impl<R: ProcessRunner> GitApi for Git<R> {
285 async fn run(&self, args: &[String]) -> Result<String> {
286 self.core.text(self.core.command(args)).await
287 }
288
289 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
290 self.core.capture(self.core.command(args)).await
291 }
292
293 async fn version(&self) -> Result<String> {
294 self.core.text(self.core.command(["--version"])).await
295 }
296
297 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
298 self.core
299 .parse(
300 self.core
301 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
302 parse::parse_porcelain,
303 )
304 .await
305 }
306
307 async fn status_text(&self, dir: &Path) -> Result<String> {
308 self.core
309 .text(self.core.command_in(dir, ["status", "--porcelain=v1"]))
310 .await
311 }
312
313 async fn current_branch(&self, dir: &Path) -> Result<String> {
314 self.core
315 .text(
316 self.core
317 .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
318 )
319 .await
320 }
321
322 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
323 self.core
324 .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
325 .await
326 }
327
328 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
329 let n = format!("-n{max}");
330 self.core
331 .parse(
332 self.core.command_in(
333 dir,
334 [
335 "log",
336 n.as_str(),
337 "-z",
338 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
339 ],
340 ),
341 parse::parse_log,
342 )
343 .await
344 }
345
346 async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
347 let n = format!("-n{max}");
348 self.core
349 .parse(
350 self.core.command_in(
351 dir,
352 [
353 "log",
354 range,
355 n.as_str(),
356 "-z",
357 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
358 ],
359 ),
360 parse::parse_log,
361 )
362 .await
363 }
364
365 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
366 self.core
367 .text(self.core.command_in(dir, ["rev-parse", rev]))
368 .await
369 }
370
371 async fn init(&self, dir: &Path) -> Result<()> {
372 self.core.unit(self.core.command_in(dir, ["init"])).await
373 }
374
375 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
376 let mut command = self.core.command_in(dir, ["add", "--"]);
378 for path in paths {
379 command = command.arg(path);
380 }
381 self.core.unit(command).await
382 }
383
384 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
385 self.core
386 .unit(self.core.command_in(dir, ["commit", "-m", message]))
387 .await
388 }
389
390 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
391 self.core
392 .unit(self.core.command_in(dir, ["branch", name]))
393 .await
394 }
395
396 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
397 self.core
398 .unit(self.core.command_in(dir, ["checkout", reference]))
399 .await
400 }
401
402 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
403 self.core
404 .unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
405 .await
406 }
407
408 async fn commit_paths(
409 &self,
410 dir: &Path,
411 paths: &[PathBuf],
412 message: &str,
413 amend: bool,
414 ) -> Result<()> {
415 let mut command = self.core.command_in(dir, ["commit"]);
418 if amend {
419 command = command.arg("--amend");
420 }
421 command = command.arg("-m").arg(message).arg("--only").arg("--");
422 for path in paths {
423 command = command.arg(path);
424 }
425 self.core.unit(command).await
426 }
427
428 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
429 self.core
430 .text(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
431 .await
432 }
433
434 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
435 match self
439 .core
440 .code(
441 self.core
442 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
443 )
444 .await?
445 {
446 0 => Ok(false),
447 1 => Ok(true),
448 other => Err(Error::Exit {
449 program: BINARY.to_string(),
450 code: other,
451 stdout: String::new(),
452 stderr: String::new(),
453 }),
454 }
455 }
456
457 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
458 match self
461 .core
462 .code(self.core.command_in(dir, ["diff", "--quiet"]))
463 .await?
464 {
465 0 => Ok(true),
466 1 => Ok(false),
467 other => Err(Error::Exit {
468 program: BINARY.to_string(),
469 code: other,
470 stdout: String::new(),
471 stderr: String::new(),
472 }),
473 }
474 }
475
476 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
477 Ok(PathBuf::from(
478 self.core
479 .text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
480 .await?,
481 ))
482 }
483
484 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
485 Ok(PathBuf::from(
486 self.core
487 .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
488 .await?,
489 ))
490 }
491
492 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
493 let spec = format!("{rev}^{{commit}}");
495 self.core
496 .text(
497 self.core
498 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
499 )
500 .await
501 }
502
503 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
504 let res = self
508 .core
509 .capture(
510 self.core
511 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
512 )
513 .await?;
514 if res.code() == Some(0) {
515 let out = res.stdout().trim();
518 Ok(Some(
519 out.strip_prefix("refs/remotes/origin/")
520 .unwrap_or(out)
521 .to_string(),
522 ))
523 } else {
524 Ok(None)
525 }
526 }
527
528 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
529 let refname = format!("refs/heads/{name}");
530 match self
531 .core
532 .code(
533 self.core
534 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
535 )
536 .await?
537 {
538 0 => Ok(true),
539 1 => Ok(false),
540 other => Err(Error::Exit {
541 program: BINARY.to_string(),
542 code: other,
543 stdout: String::new(),
544 stderr: String::new(),
545 }),
546 }
547 }
548
549 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
550 let cmd = self
556 .core
557 .command_in(dir, ["ls-remote", "--heads", "origin", name])
558 .env("GIT_TERMINAL_PROMPT", "0")
559 .timeout(Duration::from_secs(10));
560 let res = self.core.capture(cmd).await?;
561 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
562 }
563
564 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
565 self.core
566 .text(self.core.command_in(dir, ["remote", "get-url", remote]))
567 .await
568 }
569
570 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
571 let out = self
572 .core
573 .text(self.core.command_in(dir, ["branch", "--merged", target]))
574 .await?;
575 Ok(out
578 .lines()
579 .map(|line| line.trim_start_matches(['*', '+', ' ']))
580 .any(|b| b == branch))
581 }
582
583 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
584 let flag = if force { "-D" } else { "-d" };
585 self.core
586 .unit(self.core.command_in(dir, ["branch", flag, name]))
587 .await
588 }
589
590 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
591 self.core
592 .unit(self.core.command_in(dir, ["branch", "-m", old, new]))
593 .await
594 }
595
596 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
597 self.core
598 .try_parse(
599 self.core.command_in(dir, ["rev-list", "--count", range]),
600 |s| {
601 s.trim().parse::<usize>().map_err(|e| Error::Parse {
602 program: BINARY.to_string(),
603 message: e.to_string(),
604 })
605 },
606 )
607 .await
608 }
609
610 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
611 match self
612 .core
613 .code(self.core.command_in(dir, ["diff", "--quiet", range]))
614 .await?
615 {
616 0 => Ok(true),
617 1 => Ok(false),
618 other => Err(Error::Exit {
619 program: BINARY.to_string(),
620 code: other,
621 stdout: String::new(),
622 stderr: String::new(),
623 }),
624 }
625 }
626
627 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
628 self.core
629 .parse(
630 self.core.command_in(dir, ["diff", "--shortstat", range]),
631 parse::parse_shortstat,
632 )
633 .await
634 }
635
636 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
637 let target = match spec {
641 DiffSpec::WorkingTree => "HEAD".to_string(),
642 DiffSpec::Rev(rev) => rev,
643 };
644 self.core
645 .text(self.core.command_in(
646 dir,
647 ["diff", target.as_str(), "--no-color", "--no-ext-diff", "-M"],
648 ))
649 .await
650 }
651
652 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
653 let text = self.diff_text(dir, spec).await?;
654 Ok(parse::parse_diff(&text))
655 }
656
657 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
658 match self
659 .core
660 .code(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
661 .await?
662 {
663 0 => Ok(true),
664 1 => Ok(false),
665 other => Err(Error::Exit {
666 program: BINARY.to_string(),
667 code: other,
668 stdout: String::new(),
669 stderr: String::new(),
670 }),
671 }
672 }
673
674 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
675 let git_dir = self.resolved_git_dir(dir).await?;
676 Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
677 }
678
679 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
680 Ok(self
681 .resolved_git_dir(dir)
682 .await?
683 .join("MERGE_HEAD")
684 .exists())
685 }
686
687 async fn fetch(&self, dir: &Path) -> Result<()> {
688 self.core
689 .unit(self.core.command_in(dir, ["fetch", "--quiet"]))
690 .await
691 }
692
693 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
694 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
695 let cmd = self
696 .core
697 .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
698 .env("GIT_TERMINAL_PROMPT", "0");
699 self.core.unit(cmd).await
700 }
701
702 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
703 self.core
704 .unit(self.core.command_in(dir, ["merge", "--squash", branch]))
705 .await
706 }
707
708 async fn merge_commit(
709 &self,
710 dir: &Path,
711 branch: &str,
712 no_ff: bool,
713 message: Option<String>,
714 ) -> Result<()> {
715 let mut args: Vec<&str> = vec!["merge"];
716 if no_ff {
717 args.push("--no-ff");
718 }
719 if let Some(msg) = message.as_deref() {
720 args.push("-m");
721 args.push(msg);
722 }
723 args.push(branch);
724 self.core.unit(self.core.command_in(dir, args)).await
725 }
726
727 async fn merge_no_commit(
728 &self,
729 dir: &Path,
730 branch: &str,
731 squash: bool,
732 no_ff: bool,
733 ) -> Result<()> {
734 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
735 if squash {
736 args.push("--squash");
737 }
738 if no_ff {
739 args.push("--no-ff");
740 }
741 args.push(branch);
742 self.core.unit(self.core.command_in(dir, args)).await
743 }
744
745 async fn merge_abort(&self, dir: &Path) -> Result<()> {
746 self.core
747 .unit(self.core.command_in(dir, ["merge", "--abort"]))
748 .await
749 }
750
751 async fn merge_continue(&self, dir: &Path) -> Result<()> {
752 self.core
753 .unit(self.core.command_in(dir, ["commit", "--no-edit"]))
754 .await
755 }
756
757 async fn reset_merge(&self, dir: &Path) -> Result<()> {
758 self.core
759 .unit(self.core.command_in(dir, ["reset", "--merge"]))
760 .await
761 }
762
763 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
764 self.core
765 .unit(self.core.command_in(dir, ["reset", "--hard", rev]))
766 .await
767 }
768
769 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
770 self.core
771 .unit(self.core.command_in(dir, ["rebase", onto]))
772 .await
773 }
774
775 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
776 self.core
777 .unit(self.core.command_in(dir, ["rebase", "--abort"]))
778 .await
779 }
780
781 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
782 self.core
783 .unit(self.core.command_in(dir, ["rebase", "--continue"]))
784 .await
785 }
786
787 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
788 let mut command = self.core.command_in(dir, ["stash", "push"]);
789 if include_untracked {
790 command = command.arg("--include-untracked");
791 }
792 self.core.unit(command).await
793 }
794
795 async fn stash_pop(&self, dir: &Path) -> Result<()> {
796 self.core
797 .unit(self.core.command_in(dir, ["stash", "pop"]))
798 .await
799 }
800
801 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
802 self.core
803 .parse(
804 self.core
805 .command_in(dir, ["worktree", "list", "--porcelain"]),
806 parse::parse_worktree_porcelain,
807 )
808 .await
809 }
810
811 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
812 let mut command = self.core.command_in(dir, ["worktree", "add"]);
813 if let Some(name) = spec.new_branch.as_deref() {
814 command = command.arg("-b").arg(name);
815 }
816 if spec.no_checkout {
817 command = command.arg("--no-checkout");
818 }
819 command = command.arg(&spec.path);
820 if let Some(commitish) = spec.commitish.as_deref() {
821 command = command.arg(commitish);
822 }
823 self.core.unit(command).await
824 }
825
826 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
827 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
828 if force {
829 command = command.arg("--force");
830 }
831 command = command.arg(path);
832 self.core.unit(command).await
833 }
834
835 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
836 let command = self
837 .core
838 .command_in(dir, ["worktree", "move"])
839 .arg(from)
840 .arg(to);
841 self.core.unit(command).await
842 }
843
844 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
845 self.core
846 .unit(self.core.command_in(dir, ["worktree", "prune"]))
847 .await
848 }
849}
850
851const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
860const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
862const TRANSIENT_FETCH_MARKERS: &[&str] = &[
864 "could not resolve host",
865 "couldn't resolve host",
866 "temporary failure in name resolution",
867 "connection timed out",
868 "connection refused",
869 "operation timed out",
870 "timed out",
871 "network is unreachable",
872 "failed to connect",
873 "could not read from remote repository",
874 "the remote end hung up",
875 "early eof",
876 "rpc failed",
877];
878
879fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
881 let Error::Exit { stdout, stderr, .. } = err else {
882 return false;
883 };
884 let out = stdout.to_ascii_lowercase();
885 let errt = stderr.to_ascii_lowercase();
886 markers.iter().any(|m| out.contains(m) || errt.contains(m))
887}
888
889pub fn is_merge_conflict(err: &Error) -> bool {
891 exit_output_matches(err, CONFLICT_MARKERS)
892}
893
894pub fn is_nothing_to_commit(err: &Error) -> bool {
897 exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
898}
899
900pub fn is_transient_fetch_error(err: &Error) -> bool {
903 matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
906}
907
908impl<R: ProcessRunner> Git<R> {
909 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
914 self.core.text(self.core.command(args)).await
915 }
916
917 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
920 self.core.capture(self.core.command(args)).await
921 }
922
923 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
926 let git_dir = PathBuf::from(
927 self.core
928 .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
929 .await?,
930 );
931 Ok(if git_dir.is_absolute() {
932 git_dir
933 } else {
934 dir.join(git_dir)
935 })
936 }
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use processkit::{RecordingRunner, Reply, ScriptedRunner};
943
944 #[test]
945 fn binary_name_is_git() {
946 assert_eq!(BINARY, "git");
947 }
948
949 #[tokio::test]
952 async fn status_parses_scripted_output() {
953 let git =
955 Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
956 let entries = git.status(Path::new(".")).await.expect("status");
957 assert_eq!(entries.len(), 2);
958 assert_eq!(entries[0].code, " M");
959 assert_eq!(entries[1].path, "b.rs");
960 }
961
962 #[tokio::test]
964 async fn nonzero_exit_is_structured_error() {
965 let git = Git::with_runner(
966 ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
967 );
968 match git.status(Path::new(".")).await.unwrap_err() {
969 Error::Exit { code, stderr, .. } => {
970 assert_eq!(code, 128);
971 assert!(stderr.contains("not a git repository"), "{stderr}");
972 }
973 other => panic!("expected Exit, got {other:?}"),
974 }
975 }
976
977 #[tokio::test]
980 async fn diff_is_empty_maps_exit_codes() {
981 let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
982 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
983
984 let dirty =
985 Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
986 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
987
988 let broken = Git::with_runner(
989 ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
990 );
991 assert!(matches!(
992 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
993 Error::Exit { code: 128, .. }
994 ));
995 }
996
997 #[tokio::test]
1000 async fn add_inserts_pathspec_separator() {
1001 let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
1002 git.add(Path::new("."), &[PathBuf::from("f.rs")])
1003 .await
1004 .expect("add should build `add -- <paths>`");
1005 }
1006
1007 #[tokio::test]
1008 async fn worktree_list_parses_porcelain() {
1009 let git = Git::with_runner(ScriptedRunner::new().on(
1010 ["worktree", "list"],
1011 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
1012 ));
1013 let wts = git.worktree_list(Path::new(".")).await.expect("list");
1014 assert_eq!(wts.len(), 1);
1015 assert_eq!(wts[0].branch.as_deref(), Some("main"));
1016 assert_eq!(wts[0].head.as_deref(), Some("abc"));
1017 }
1018
1019 #[tokio::test]
1022 async fn worktree_add_builds_branch_path_and_base() {
1023 let rec = RecordingRunner::replying(Reply::ok(""));
1024 let git = Git::with_runner(&rec);
1025 git.worktree_add(
1026 Path::new("/repo"),
1027 WorktreeAdd::create_branch("/wt", "feature", "main"),
1028 )
1029 .await
1030 .expect("worktree add");
1031 assert_eq!(
1032 rec.only_call().args_str(),
1033 ["worktree", "add", "-b", "feature", "/wt", "main"]
1034 );
1035 }
1036
1037 #[tokio::test]
1038 async fn worktree_remove_passes_force_then_path() {
1039 let rec = RecordingRunner::replying(Reply::ok(""));
1040 let git = Git::with_runner(&rec);
1041 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
1042 .await
1043 .expect("remove");
1044 assert_eq!(
1045 rec.only_call().args_str(),
1046 ["worktree", "remove", "--force", "/wt"]
1047 );
1048 }
1049
1050 #[tokio::test]
1052 async fn worktree_add_no_checkout_inserts_flag() {
1053 let rec = RecordingRunner::replying(Reply::ok(""));
1054 let git = Git::with_runner(&rec);
1055 git.worktree_add(
1056 Path::new("/repo"),
1057 WorktreeAdd::checkout("/wt", "main").no_checkout(),
1058 )
1059 .await
1060 .expect("worktree add");
1061 assert_eq!(
1062 rec.only_call().args_str(),
1063 ["worktree", "add", "--no-checkout", "/wt", "main"]
1064 );
1065 }
1066
1067 #[tokio::test]
1068 async fn checkout_detach_builds_args() {
1069 let rec = RecordingRunner::replying(Reply::ok(""));
1070 let git = Git::with_runner(&rec);
1071 git.checkout_detach(Path::new("."), "abc123")
1072 .await
1073 .expect("detach");
1074 assert_eq!(
1075 rec.only_call().args_str(),
1076 ["checkout", "--detach", "abc123"]
1077 );
1078 }
1079
1080 #[tokio::test]
1082 async fn commit_paths_builds_only_amend_args() {
1083 let rec = RecordingRunner::replying(Reply::ok(""));
1084 let git = Git::with_runner(&rec);
1085 git.commit_paths(
1086 Path::new("."),
1087 &[PathBuf::from("a.rs"), PathBuf::from("b.rs")],
1088 "msg",
1089 true,
1090 )
1091 .await
1092 .expect("commit_paths");
1093 assert_eq!(
1094 rec.only_call().args_str(),
1095 [
1096 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
1097 ]
1098 );
1099 }
1100
1101 #[tokio::test]
1104 async fn is_unborn_maps_exit_codes() {
1105 let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
1106 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
1107 let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
1108 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
1109 let broken =
1110 Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
1111 assert!(matches!(
1112 broken.is_unborn(Path::new(".")).await.unwrap_err(),
1113 Error::Exit { code: 128, .. }
1114 ));
1115 }
1116
1117 #[tokio::test]
1118 async fn log_range_builds_range_and_format() {
1119 let rec = RecordingRunner::replying(Reply::ok(""));
1120 let git = Git::with_runner(&rec);
1121 git.log_range(Path::new("."), "main..HEAD", 5)
1122 .await
1123 .expect("log_range");
1124 assert_eq!(
1125 rec.only_call().args_str(),
1126 [
1127 "log",
1128 "main..HEAD",
1129 "-n5",
1130 "-z",
1131 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
1132 ]
1133 );
1134 }
1135
1136 #[tokio::test]
1137 async fn stash_push_adds_include_untracked() {
1138 let rec = RecordingRunner::replying(Reply::ok(""));
1139 let git = Git::with_runner(&rec);
1140 git.stash_push(Path::new("."), true).await.expect("stash");
1141 assert_eq!(
1142 rec.only_call().args_str(),
1143 ["stash", "push", "--include-untracked"]
1144 );
1145 }
1146
1147 #[tokio::test]
1150 async fn diff_text_builds_working_tree_args() {
1151 let rec = RecordingRunner::replying(Reply::ok(""));
1152 let git = Git::with_runner(&rec);
1153 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
1154 .await
1155 .expect("diff_text");
1156 assert_eq!(
1157 rec.only_call().args_str(),
1158 ["diff", "HEAD", "--no-color", "--no-ext-diff", "-M"]
1159 );
1160 }
1161
1162 #[tokio::test]
1165 async fn diff_parses_scripted_output() {
1166 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
1167 let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
1168 let files = git
1169 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
1170 .await
1171 .expect("diff");
1172 assert_eq!(files.len(), 1);
1173 assert_eq!(files[0].path, "m");
1174 assert_eq!(files[0].change, ChangeKind::Modified);
1175 }
1176
1177 #[tokio::test]
1178 async fn branch_exists_maps_exit_codes() {
1179 let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
1180 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
1181 let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
1182 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
1183 }
1184
1185 #[tokio::test]
1188 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
1189 let simple = Git::with_runner(
1190 ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
1191 );
1192 assert_eq!(
1193 simple
1194 .remote_head_branch(Path::new("."))
1195 .await
1196 .unwrap()
1197 .as_deref(),
1198 Some("main")
1199 );
1200
1201 let slashed = Git::with_runner(ScriptedRunner::new().on(
1202 ["symbolic-ref"],
1203 Reply::ok("refs/remotes/origin/release/v2\n"),
1204 ));
1205 assert_eq!(
1206 slashed
1207 .remote_head_branch(Path::new("."))
1208 .await
1209 .unwrap()
1210 .as_deref(),
1211 Some("release/v2")
1212 );
1213
1214 let unset =
1215 Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
1216 assert!(
1217 unset
1218 .remote_head_branch(Path::new("."))
1219 .await
1220 .unwrap()
1221 .is_none()
1222 );
1223 }
1224
1225 #[tokio::test]
1228 async fn remote_branch_exists_sets_env_and_reads_stdout() {
1229 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
1230 let git = Git::with_runner(&rec);
1231 assert!(
1232 git.remote_branch_exists(Path::new("/repo"), "main")
1233 .await
1234 .unwrap()
1235 );
1236 assert!(rec.only_call().envs.iter().any(|(k, v)| {
1237 k.to_str() == Some("GIT_TERMINAL_PROMPT")
1238 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
1239 }));
1240
1241 let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
1242 assert!(
1243 !empty
1244 .remote_branch_exists(Path::new("."), "x")
1245 .await
1246 .unwrap()
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn diff_stat_parses_counts() {
1252 let git = Git::with_runner(ScriptedRunner::new().on(
1253 ["diff", "--shortstat"],
1254 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
1255 ));
1256 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
1257 assert_eq!(
1258 (stat.files_changed, stat.insertions, stat.deletions),
1259 (2, 5, 1)
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn status_text_returns_raw_porcelain() {
1265 let git = Git::with_runner(ScriptedRunner::new().on(
1266 ["status", "--porcelain=v1"],
1267 Reply::ok(" M a.rs\n?? b.rs\n"),
1268 ));
1269 let text = git.status_text(Path::new(".")).await.expect("status_text");
1270 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
1271 }
1272
1273 #[tokio::test]
1274 async fn run_args_forwards_str_slices() {
1275 let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
1276 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
1277 }
1278
1279 #[test]
1282 fn classifies_merge_conflict() {
1283 let on_stdout = Error::Exit {
1284 program: "git".into(),
1285 code: 1,
1286 stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
1287 stderr: String::new(),
1288 };
1289 let on_stderr = Error::Exit {
1290 program: "git".into(),
1291 code: 1,
1292 stdout: String::new(),
1293 stderr: "Automatic merge failed; fix conflicts and then commit".into(),
1294 };
1295 let unrelated = Error::Exit {
1296 program: "git".into(),
1297 code: 128,
1298 stdout: String::new(),
1299 stderr: "fatal: not a git repository".into(),
1300 };
1301 assert!(is_merge_conflict(&on_stdout));
1302 assert!(is_merge_conflict(&on_stderr));
1303 assert!(!is_merge_conflict(&unrelated));
1304 assert!(!is_nothing_to_commit(&on_stdout));
1305 }
1306
1307 #[test]
1308 fn classifies_nothing_to_commit_and_transient_fetch() {
1309 let nothing = Error::Exit {
1310 program: "git".into(),
1311 code: 1,
1312 stdout: "nothing to commit, working tree clean".into(),
1313 stderr: String::new(),
1314 };
1315 assert!(is_nothing_to_commit(¬hing));
1316
1317 let dns = Error::Exit {
1318 program: "git".into(),
1319 code: 128,
1320 stdout: String::new(),
1321 stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
1322 };
1323 assert!(is_transient_fetch_error(&dns));
1324 assert!(!is_transient_fetch_error(¬hing));
1325
1326 let timeout = Error::Timeout {
1328 program: "git".into(),
1329 timeout: Duration::from_secs(10),
1330 };
1331 assert!(is_transient_fetch_error(&timeout));
1332 }
1333
1334 #[tokio::test]
1335 async fn merge_commit_builds_no_ff_and_message() {
1336 let rec = RecordingRunner::replying(Reply::ok(""));
1337 let git = Git::with_runner(&rec);
1338 git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
1339 .await
1340 .unwrap();
1341 assert_eq!(
1342 rec.only_call().args_str(),
1343 ["merge", "--no-ff", "-m", "merge it", "feature"]
1344 );
1345 }
1346
1347 #[tokio::test]
1348 async fn delete_branch_force_uses_capital_d() {
1349 let rec = RecordingRunner::replying(Reply::ok(""));
1350 let git = Git::with_runner(&rec);
1351 git.delete_branch(Path::new("/r"), "old", true)
1352 .await
1353 .unwrap();
1354 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
1355 }
1356
1357 #[tokio::test]
1360 async fn is_merged_strips_branch_markers() {
1361 let git = Git::with_runner(ScriptedRunner::new().on(
1362 ["branch", "--merged"],
1363 Reply::ok(" main\n* feature\n+ wt-branch\n"),
1364 ));
1365 for name in ["main", "feature", "wt-branch"] {
1366 assert!(
1367 git.is_merged(Path::new("."), name, "main").await.unwrap(),
1368 "{name} should be reported merged"
1369 );
1370 }
1371 assert!(
1372 !git.is_merged(Path::new("."), "absent", "main")
1373 .await
1374 .unwrap()
1375 );
1376 }
1377
1378 #[cfg(feature = "mock")]
1381 #[tokio::test]
1382 async fn consumer_mocks_the_interface() {
1383 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
1384 git.current_branch(Path::new(".")).await.unwrap() == want
1385 }
1386 let mut mock = MockGitApi::new();
1387 mock.expect_current_branch()
1388 .returning(|_| Ok("main".to_string()));
1389 assert!(on_branch(&mock, "main").await);
1390 }
1391}