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::{Branch, Commit, DiffStat, StatusEntry, Worktree};
32
33pub const BINARY: &str = "git";
35
36#[derive(Debug, Clone)]
41#[non_exhaustive]
42pub struct WorktreeAdd {
43 pub path: PathBuf,
45 pub new_branch: Option<String>,
48 pub commitish: Option<String>,
50}
51
52impl WorktreeAdd {
53 pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
56 Self {
57 path: path.into(),
58 new_branch: None,
59 commitish: Some(commitish.into()),
60 }
61 }
62
63 pub fn create_branch(
66 path: impl Into<PathBuf>,
67 name: impl Into<String>,
68 commitish: impl Into<String>,
69 ) -> Self {
70 Self {
71 path: path.into(),
72 new_branch: Some(name.into()),
73 commitish: Some(commitish.into()),
74 }
75 }
76}
77
78#[cfg_attr(feature = "mock", mockall::automock)]
81#[async_trait::async_trait]
82pub trait GitApi: Send + Sync {
83 async fn run(&self, args: &[String]) -> Result<String>;
86 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
89 async fn version(&self) -> Result<String>;
91 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
93 async fn current_branch(&self, dir: &Path) -> Result<String>;
95 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
97 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
99 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
101 async fn init(&self, dir: &Path) -> Result<()>;
103 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
105 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
107 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
109 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
111 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
113
114 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
119 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
121 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
124 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
127 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
129 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
133 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
135
136 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
140 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
142 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
144 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
146 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
148 async fn diff_shortstat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
150
151 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
155 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
158 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
160
161 async fn fetch(&self, dir: &Path) -> Result<()>;
165 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
169 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
171 async fn merge_commit(
173 &self,
174 dir: &Path,
175 branch: &str,
176 no_ff: bool,
177 message: Option<String>,
178 ) -> Result<()>;
179 async fn merge_no_commit(
182 &self,
183 dir: &Path,
184 branch: &str,
185 squash: bool,
186 no_ff: bool,
187 ) -> Result<()>;
188 async fn merge_abort(&self, dir: &Path) -> Result<()>;
190 async fn merge_continue(&self, dir: &Path) -> Result<()>;
192 async fn reset_merge(&self, dir: &Path) -> Result<()>;
194 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
196 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
198 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
200 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
202
203 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
207 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
209 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
211 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
213 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
215}
216
217processkit::cli_client!(
218 pub struct Git => BINARY
221);
222
223#[async_trait::async_trait]
224impl<R: ProcessRunner> GitApi for Git<R> {
225 async fn run(&self, args: &[String]) -> Result<String> {
226 self.core.text(self.core.command(args)).await
227 }
228
229 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
230 self.core.capture(self.core.command(args)).await
231 }
232
233 async fn version(&self) -> Result<String> {
234 self.core.text(self.core.command(["--version"])).await
235 }
236
237 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
238 self.core
239 .parse(
240 self.core
241 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
242 parse::parse_porcelain,
243 )
244 .await
245 }
246
247 async fn current_branch(&self, dir: &Path) -> Result<String> {
248 self.core
249 .text(
250 self.core
251 .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
252 )
253 .await
254 }
255
256 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
257 self.core
258 .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
259 .await
260 }
261
262 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
263 let n = format!("-n{max}");
264 self.core
265 .parse(
266 self.core.command_in(
267 dir,
268 [
269 "log",
270 n.as_str(),
271 "-z",
272 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
273 ],
274 ),
275 parse::parse_log,
276 )
277 .await
278 }
279
280 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
281 self.core
282 .text(self.core.command_in(dir, ["rev-parse", rev]))
283 .await
284 }
285
286 async fn init(&self, dir: &Path) -> Result<()> {
287 self.core.unit(self.core.command_in(dir, ["init"])).await
288 }
289
290 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
291 let mut command = self.core.command_in(dir, ["add", "--"]);
293 for path in paths {
294 command = command.arg(path);
295 }
296 self.core.unit(command).await
297 }
298
299 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
300 self.core
301 .unit(self.core.command_in(dir, ["commit", "-m", message]))
302 .await
303 }
304
305 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
306 self.core
307 .unit(self.core.command_in(dir, ["branch", name]))
308 .await
309 }
310
311 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
312 self.core
313 .unit(self.core.command_in(dir, ["checkout", reference]))
314 .await
315 }
316
317 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
318 match self
321 .core
322 .code(self.core.command_in(dir, ["diff", "--quiet"]))
323 .await?
324 {
325 0 => Ok(true),
326 1 => Ok(false),
327 other => Err(Error::Exit {
328 program: BINARY.to_string(),
329 code: other,
330 stderr: String::new(),
331 }),
332 }
333 }
334
335 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
336 Ok(PathBuf::from(
337 self.core
338 .text(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
339 .await?,
340 ))
341 }
342
343 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
344 Ok(PathBuf::from(
345 self.core
346 .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
347 .await?,
348 ))
349 }
350
351 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
352 let spec = format!("{rev}^{{commit}}");
354 self.core
355 .text(
356 self.core
357 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
358 )
359 .await
360 }
361
362 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
363 let res = self
367 .core
368 .capture(
369 self.core
370 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
371 )
372 .await?;
373 if res.exit_code() == 0 {
374 Ok(res.stdout().trim().rsplit('/').next().map(str::to_string))
376 } else {
377 Ok(None)
378 }
379 }
380
381 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
382 let refname = format!("refs/heads/{name}");
383 match self
384 .core
385 .code(
386 self.core
387 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
388 )
389 .await?
390 {
391 0 => Ok(true),
392 1 => Ok(false),
393 other => Err(Error::Exit {
394 program: BINARY.to_string(),
395 code: other,
396 stderr: String::new(),
397 }),
398 }
399 }
400
401 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
402 let cmd = self
408 .core
409 .command_in(dir, ["ls-remote", "--heads", "origin", name])
410 .env("GIT_TERMINAL_PROMPT", "0")
411 .timeout(Duration::from_secs(10));
412 let res = self.core.capture(cmd).await?;
413 Ok(res.exit_code() == 0 && !res.stdout().trim().is_empty())
414 }
415
416 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
417 self.core
418 .text(self.core.command_in(dir, ["remote", "get-url", remote]))
419 .await
420 }
421
422 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
423 let out = self
424 .core
425 .text(self.core.command_in(dir, ["branch", "--merged", target]))
426 .await?;
427 Ok(out
430 .lines()
431 .map(|line| line.trim_start_matches(['*', '+', ' ']))
432 .any(|b| b == branch))
433 }
434
435 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
436 let flag = if force { "-D" } else { "-d" };
437 self.core
438 .unit(self.core.command_in(dir, ["branch", flag, name]))
439 .await
440 }
441
442 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
443 self.core
444 .unit(self.core.command_in(dir, ["branch", "-m", old, new]))
445 .await
446 }
447
448 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
449 self.core
450 .try_parse(
451 self.core.command_in(dir, ["rev-list", "--count", range]),
452 |s| {
453 s.trim().parse::<usize>().map_err(|e| Error::Parse {
454 program: BINARY.to_string(),
455 message: e.to_string(),
456 })
457 },
458 )
459 .await
460 }
461
462 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
463 match self
464 .core
465 .code(self.core.command_in(dir, ["diff", "--quiet", range]))
466 .await?
467 {
468 0 => Ok(true),
469 1 => Ok(false),
470 other => Err(Error::Exit {
471 program: BINARY.to_string(),
472 code: other,
473 stderr: String::new(),
474 }),
475 }
476 }
477
478 async fn diff_shortstat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
479 self.core
480 .parse(
481 self.core.command_in(dir, ["diff", "--shortstat", range]),
482 parse::parse_shortstat,
483 )
484 .await
485 }
486
487 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
488 match self
489 .core
490 .code(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
491 .await?
492 {
493 0 => Ok(true),
494 1 => Ok(false),
495 other => Err(Error::Exit {
496 program: BINARY.to_string(),
497 code: other,
498 stderr: String::new(),
499 }),
500 }
501 }
502
503 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
504 let git_dir = self.resolved_git_dir(dir).await?;
505 Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
506 }
507
508 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
509 Ok(self
510 .resolved_git_dir(dir)
511 .await?
512 .join("MERGE_HEAD")
513 .exists())
514 }
515
516 async fn fetch(&self, dir: &Path) -> Result<()> {
517 self.core
518 .unit(self.core.command_in(dir, ["fetch", "--quiet"]))
519 .await
520 }
521
522 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
523 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
524 let cmd = self
525 .core
526 .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()])
527 .env("GIT_TERMINAL_PROMPT", "0");
528 self.core.unit(cmd).await
529 }
530
531 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
532 self.core
533 .unit(self.core.command_in(dir, ["merge", "--squash", branch]))
534 .await
535 }
536
537 async fn merge_commit(
538 &self,
539 dir: &Path,
540 branch: &str,
541 no_ff: bool,
542 message: Option<String>,
543 ) -> Result<()> {
544 let mut args: Vec<&str> = vec!["merge"];
545 if no_ff {
546 args.push("--no-ff");
547 }
548 if let Some(msg) = message.as_deref() {
549 args.push("-m");
550 args.push(msg);
551 }
552 args.push(branch);
553 self.core.unit(self.core.command_in(dir, args)).await
554 }
555
556 async fn merge_no_commit(
557 &self,
558 dir: &Path,
559 branch: &str,
560 squash: bool,
561 no_ff: bool,
562 ) -> Result<()> {
563 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
564 if squash {
565 args.push("--squash");
566 }
567 if no_ff {
568 args.push("--no-ff");
569 }
570 args.push(branch);
571 self.core.unit(self.core.command_in(dir, args)).await
572 }
573
574 async fn merge_abort(&self, dir: &Path) -> Result<()> {
575 self.core
576 .unit(self.core.command_in(dir, ["merge", "--abort"]))
577 .await
578 }
579
580 async fn merge_continue(&self, dir: &Path) -> Result<()> {
581 self.core
582 .unit(self.core.command_in(dir, ["commit", "--no-edit"]))
583 .await
584 }
585
586 async fn reset_merge(&self, dir: &Path) -> Result<()> {
587 self.core
588 .unit(self.core.command_in(dir, ["reset", "--merge"]))
589 .await
590 }
591
592 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
593 self.core
594 .unit(self.core.command_in(dir, ["reset", "--hard", rev]))
595 .await
596 }
597
598 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
599 self.core
600 .unit(self.core.command_in(dir, ["rebase", onto]))
601 .await
602 }
603
604 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
605 self.core
606 .unit(self.core.command_in(dir, ["rebase", "--abort"]))
607 .await
608 }
609
610 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
611 self.core
612 .unit(self.core.command_in(dir, ["rebase", "--continue"]))
613 .await
614 }
615
616 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
617 self.core
618 .parse(
619 self.core
620 .command_in(dir, ["worktree", "list", "--porcelain"]),
621 parse::parse_worktree_porcelain,
622 )
623 .await
624 }
625
626 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
627 let mut command = self.core.command_in(dir, ["worktree", "add"]);
628 if let Some(name) = spec.new_branch.as_deref() {
629 command = command.arg("-b").arg(name);
630 }
631 command = command.arg(&spec.path);
632 if let Some(commitish) = spec.commitish.as_deref() {
633 command = command.arg(commitish);
634 }
635 self.core.unit(command).await
636 }
637
638 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
639 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
640 if force {
641 command = command.arg("--force");
642 }
643 command = command.arg(path);
644 self.core.unit(command).await
645 }
646
647 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
648 let command = self
649 .core
650 .command_in(dir, ["worktree", "move"])
651 .arg(from)
652 .arg(to);
653 self.core.unit(command).await
654 }
655
656 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
657 self.core
658 .unit(self.core.command_in(dir, ["worktree", "prune"]))
659 .await
660 }
661}
662
663impl<R: ProcessRunner> Git<R> {
664 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
667 let git_dir = PathBuf::from(
668 self.core
669 .text(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
670 .await?,
671 );
672 Ok(if git_dir.is_absolute() {
673 git_dir
674 } else {
675 dir.join(git_dir)
676 })
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use processkit::{RecordingRunner, Reply, ScriptedRunner};
684
685 #[test]
686 fn binary_name_is_git() {
687 assert_eq!(BINARY, "git");
688 }
689
690 #[tokio::test]
693 async fn status_parses_scripted_output() {
694 let git =
696 Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
697 let entries = git.status(Path::new(".")).await.expect("status");
698 assert_eq!(entries.len(), 2);
699 assert_eq!(entries[0].code, " M");
700 assert_eq!(entries[1].path, "b.rs");
701 }
702
703 #[tokio::test]
705 async fn nonzero_exit_is_structured_error() {
706 let git = Git::with_runner(
707 ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
708 );
709 match git.status(Path::new(".")).await.unwrap_err() {
710 Error::Exit { code, stderr, .. } => {
711 assert_eq!(code, 128);
712 assert!(stderr.contains("not a git repository"), "{stderr}");
713 }
714 other => panic!("expected Exit, got {other:?}"),
715 }
716 }
717
718 #[tokio::test]
721 async fn diff_is_empty_maps_exit_codes() {
722 let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
723 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
724
725 let dirty =
726 Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
727 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
728
729 let broken = Git::with_runner(
730 ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
731 );
732 assert!(matches!(
733 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
734 Error::Exit { code: 128, .. }
735 ));
736 }
737
738 #[tokio::test]
741 async fn add_inserts_pathspec_separator() {
742 let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
743 git.add(Path::new("."), &[PathBuf::from("f.rs")])
744 .await
745 .expect("add should build `add -- <paths>`");
746 }
747
748 #[tokio::test]
749 async fn worktree_list_parses_porcelain() {
750 let git = Git::with_runner(ScriptedRunner::new().on(
751 ["worktree", "list"],
752 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
753 ));
754 let wts = git.worktree_list(Path::new(".")).await.expect("list");
755 assert_eq!(wts.len(), 1);
756 assert_eq!(wts[0].branch.as_deref(), Some("main"));
757 assert_eq!(wts[0].head.as_deref(), Some("abc"));
758 }
759
760 #[tokio::test]
763 async fn worktree_add_builds_branch_path_and_base() {
764 let rec = RecordingRunner::replying(Reply::ok(""));
765 let git = Git::with_runner(&rec);
766 git.worktree_add(
767 Path::new("/repo"),
768 WorktreeAdd::create_branch("/wt", "feature", "main"),
769 )
770 .await
771 .expect("worktree add");
772 assert_eq!(
773 rec.only_call().args_str(),
774 ["worktree", "add", "-b", "feature", "/wt", "main"]
775 );
776 }
777
778 #[tokio::test]
779 async fn worktree_remove_passes_force_then_path() {
780 let rec = RecordingRunner::replying(Reply::ok(""));
781 let git = Git::with_runner(&rec);
782 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
783 .await
784 .expect("remove");
785 assert_eq!(
786 rec.only_call().args_str(),
787 ["worktree", "remove", "--force", "/wt"]
788 );
789 }
790
791 #[tokio::test]
792 async fn branch_exists_maps_exit_codes() {
793 let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
794 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
795 let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
796 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
797 }
798
799 #[tokio::test]
802 async fn remote_branch_exists_sets_env_and_reads_stdout() {
803 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
804 let git = Git::with_runner(&rec);
805 assert!(
806 git.remote_branch_exists(Path::new("/repo"), "main")
807 .await
808 .unwrap()
809 );
810 assert!(rec.only_call().envs.iter().any(|(k, v)| {
811 k.to_str() == Some("GIT_TERMINAL_PROMPT")
812 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
813 }));
814
815 let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
816 assert!(
817 !empty
818 .remote_branch_exists(Path::new("."), "x")
819 .await
820 .unwrap()
821 );
822 }
823
824 #[tokio::test]
825 async fn diff_shortstat_parses_counts() {
826 let git = Git::with_runner(ScriptedRunner::new().on(
827 ["diff", "--shortstat"],
828 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
829 ));
830 let stat = git
831 .diff_shortstat(Path::new("."), "main..HEAD")
832 .await
833 .unwrap();
834 assert_eq!(
835 (stat.files_changed, stat.insertions, stat.deletions),
836 (2, 5, 1)
837 );
838 }
839
840 #[tokio::test]
841 async fn merge_commit_builds_no_ff_and_message() {
842 let rec = RecordingRunner::replying(Reply::ok(""));
843 let git = Git::with_runner(&rec);
844 git.merge_commit(Path::new("/r"), "feature", true, Some("merge it".into()))
845 .await
846 .unwrap();
847 assert_eq!(
848 rec.only_call().args_str(),
849 ["merge", "--no-ff", "-m", "merge it", "feature"]
850 );
851 }
852
853 #[tokio::test]
854 async fn delete_branch_force_uses_capital_d() {
855 let rec = RecordingRunner::replying(Reply::ok(""));
856 let git = Git::with_runner(&rec);
857 git.delete_branch(Path::new("/r"), "old", true)
858 .await
859 .unwrap();
860 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
861 }
862
863 #[tokio::test]
866 async fn is_merged_strips_branch_markers() {
867 let git = Git::with_runner(ScriptedRunner::new().on(
868 ["branch", "--merged"],
869 Reply::ok(" main\n* feature\n+ wt-branch\n"),
870 ));
871 for name in ["main", "feature", "wt-branch"] {
872 assert!(
873 git.is_merged(Path::new("."), name, "main").await.unwrap(),
874 "{name} should be reported merged"
875 );
876 }
877 assert!(
878 !git.is_merged(Path::new("."), "absent", "main")
879 .await
880 .unwrap()
881 );
882 }
883
884 #[cfg(feature = "mock")]
887 #[tokio::test]
888 async fn consumer_mocks_the_interface() {
889 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
890 git.current_branch(Path::new(".")).await.unwrap() == want
891 }
892 let mut mock = MockGitApi::new();
893 mock.expect_current_branch()
894 .returning(|_| Ok("main".to_string()));
895 assert!(on_branch(&mock, "main").await);
896 }
897}