1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::{Path, PathBuf};
87use std::time::Duration;
88
89use processkit::ProcessRunner;
90pub use processkit::{Error, ProcessResult, Result};
94#[cfg(feature = "cancellation")]
97#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
98pub use processkit::CancellationToken;
99
100pub mod conflict;
101mod parse;
102pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
103pub use vcs_diff::{
107 ChangeKind, DiffLine, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
108};
109pub use vcs_cli_support::{is_merge_conflict, is_nothing_to_commit, is_transient_fetch_error};
112
113pub const BINARY: &str = "git";
115
116#[derive(Debug, Clone)]
120#[non_exhaustive]
121pub enum DiffSpec {
122 WorkingTree,
125 Rev(String),
127}
128
129#[derive(Debug, Clone)]
134#[non_exhaustive]
135pub struct WorktreeAdd {
136 pub path: PathBuf,
138 pub new_branch: Option<String>,
141 pub commitish: Option<String>,
143 pub no_checkout: bool,
146}
147
148impl WorktreeAdd {
149 pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
152 Self {
153 path: path.into(),
154 new_branch: None,
155 commitish: Some(commitish.into()),
156 no_checkout: false,
157 }
158 }
159
160 pub fn create_branch(
163 path: impl Into<PathBuf>,
164 name: impl Into<String>,
165 commitish: impl Into<String>,
166 ) -> Self {
167 Self {
168 path: path.into(),
169 new_branch: Some(name.into()),
170 commitish: Some(commitish.into()),
171 no_checkout: false,
172 }
173 }
174
175 pub fn no_checkout(mut self) -> Self {
178 self.no_checkout = true;
179 self
180 }
181}
182
183#[derive(Debug, Clone)]
188#[non_exhaustive]
189pub struct GitPush {
190 pub remote: String,
192 pub refspec: String,
194 pub set_upstream: bool,
196}
197
198impl GitPush {
199 pub fn branch(name: impl Into<String>) -> Self {
201 Self {
202 remote: "origin".to_string(),
203 refspec: name.into(),
204 set_upstream: false,
205 }
206 }
207
208 pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
211 Self {
212 remote: "origin".to_string(),
213 refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
214 set_upstream: false,
215 }
216 }
217
218 pub fn remote(mut self, remote: impl Into<String>) -> Self {
220 self.remote = remote.into();
221 self
222 }
223
224 pub fn set_upstream(mut self) -> Self {
226 self.set_upstream = true;
227 self
228 }
229}
230
231#[derive(Debug, Clone, Default)]
236#[non_exhaustive]
237pub struct CloneSpec {
238 pub branch: Option<String>,
240 pub depth: Option<u32>,
244 pub bare: bool,
246}
247
248impl CloneSpec {
249 pub fn new() -> Self {
251 Self::default()
252 }
253
254 pub fn branch(mut self, branch: impl Into<String>) -> Self {
256 self.branch = Some(branch.into());
257 self
258 }
259
260 pub fn depth(mut self, depth: u32) -> Self {
263 self.depth = Some(depth);
264 self
265 }
266
267 pub fn bare(mut self) -> Self {
269 self.bare = true;
270 self
271 }
272}
273
274#[derive(Debug, Clone)]
279#[non_exhaustive]
280pub struct CommitPaths {
281 pub paths: Vec<PathBuf>,
283 pub message: String,
285 pub amend: bool,
287}
288
289impl CommitPaths {
290 pub fn new(
293 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
294 message: impl Into<String>,
295 ) -> Self {
296 Self {
297 paths: paths.into_iter().map(Into::into).collect(),
298 message: message.into(),
299 amend: false,
300 }
301 }
302
303 pub fn amend(mut self) -> Self {
305 self.amend = true;
306 self
307 }
308}
309
310#[derive(Debug, Clone)]
315#[non_exhaustive]
316pub struct MergeCommit {
317 pub branch: String,
319 pub no_ff: bool,
322 pub message: Option<String>,
325}
326
327impl MergeCommit {
328 pub fn branch(name: impl Into<String>) -> Self {
331 Self {
332 branch: name.into(),
333 no_ff: false,
334 message: None,
335 }
336 }
337
338 pub fn no_ff(mut self) -> Self {
341 self.no_ff = true;
342 self
343 }
344
345 pub fn message(mut self, m: impl Into<String>) -> Self {
347 self.message = Some(m.into());
348 self
349 }
350}
351
352#[derive(Debug, Clone)]
357#[non_exhaustive]
358pub struct MergeNoCommit {
359 pub branch: String,
361 pub squash: bool,
364 pub no_ff: bool,
367}
368
369impl MergeNoCommit {
370 pub fn branch(name: impl Into<String>) -> Self {
372 Self {
373 branch: name.into(),
374 squash: false,
375 no_ff: false,
376 }
377 }
378
379 pub fn squash(mut self) -> Self {
381 self.squash = true;
382 self
383 }
384
385 pub fn no_ff(mut self) -> Self {
388 self.no_ff = true;
389 self
390 }
391}
392
393#[derive(Debug, Clone)]
398#[non_exhaustive]
399pub struct AnnotatedTag {
400 pub name: String,
402 pub message: String,
404 pub rev: Option<String>,
406}
407
408impl AnnotatedTag {
409 pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
412 Self {
413 name: name.into(),
414 message: message.into(),
415 rev: None,
416 }
417 }
418
419 pub fn rev(mut self, r: impl Into<String>) -> Self {
421 self.rev = Some(r.into());
422 self
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Hash)]
436pub struct RefName(String);
437
438impl RefName {
439 pub fn new(name: impl Into<String>) -> Result<Self> {
441 let name = name.into();
442 let bad = name.is_empty()
443 || name.starts_with('-')
444 || name.starts_with('.')
445 || name.ends_with('/')
446 || name.ends_with(".lock")
447 || name.contains("..")
448 || name
449 .chars()
450 .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
451 if bad {
452 return Err(Error::Spawn {
453 program: BINARY.to_string(),
454 source: std::io::Error::new(
455 std::io::ErrorKind::InvalidInput,
456 format!("invalid git reference name: {name:?}"),
457 ),
458 });
459 }
460 Ok(RefName(name))
461 }
462
463 pub fn as_str(&self) -> &str {
465 &self.0
466 }
467}
468
469impl std::fmt::Display for RefName {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 f.write_str(&self.0)
472 }
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Hash)]
481pub struct RevSpec(String);
482
483impl RevSpec {
484 pub fn new(rev: impl Into<String>) -> Result<Self> {
486 let rev = rev.into();
487 reject_flag_like("revision", &rev)?;
488 Ok(RevSpec(rev))
489 }
490
491 pub fn as_str(&self) -> &str {
493 &self.0
494 }
495}
496
497impl std::fmt::Display for RevSpec {
498 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499 f.write_str(&self.0)
500 }
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507#[non_exhaustive]
508pub struct GitCapabilities {
509 pub version: GitVersion,
511}
512
513const MIN_SUPPORTED_MAJOR: u64 = 2;
519
520impl GitCapabilities {
521 pub fn is_supported(&self) -> bool {
523 self.version.major >= MIN_SUPPORTED_MAJOR
524 }
525
526 pub fn ensure_supported(&self) -> Result<()> {
529 if self.is_supported() {
530 return Ok(());
531 }
532 Err(Error::Spawn {
533 program: BINARY.to_string(),
534 source: std::io::Error::new(
535 std::io::ErrorKind::Unsupported,
536 format!(
537 "vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
538 found {}",
539 self.version
540 ),
541 ),
542 })
543 }
544}
545
546#[cfg_attr(feature = "mock", mockall::automock)]
558#[async_trait::async_trait]
559pub trait GitApi: Send + Sync {
560 async fn run(&self, args: &[String]) -> Result<String>;
563 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
566 async fn version(&self) -> Result<String>;
568 async fn capabilities(&self) -> Result<GitCapabilities>;
572 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
574 async fn status_text(&self, dir: &Path) -> Result<String>;
577 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
581 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
586 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
589 async fn current_branch(&self, dir: &Path) -> Result<String>;
591 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
593 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
595 async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>>;
597 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
599 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
602 async fn init(&self, dir: &Path) -> Result<()>;
604 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
606 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
608 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
610 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
612 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
614 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
617 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
620 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
623 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
627
628 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
633 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
635 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
638 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
641 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
643 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
648 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
650 async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
653 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
656
657 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
661 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
664 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
666 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
668 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
670 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
672 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
675 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
678 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
680
681 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
685 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
688 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
690
691 async fn fetch(&self, dir: &Path) -> Result<()>;
696 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
700 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
704 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
706 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
708 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
712 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
721 async fn merge_abort(&self, dir: &Path) -> Result<()>;
723 async fn merge_continue(&self, dir: &Path) -> Result<()>;
725 async fn reset_merge(&self, dir: &Path) -> Result<()>;
731 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
733 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
736 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
738 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
741 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
744 async fn stash_pop(&self, dir: &Path) -> Result<()>;
746
747 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
751 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
753 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
755 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
757 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
759
760 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
765 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
767 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
770 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
772 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
774 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
779 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
783 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
785 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
787 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
789 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
792
793 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
798 async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
800 async fn rebase_skip(&self, dir: &Path) -> Result<()>;
804}
805
806processkit::cli_client!(
807 pub struct Git => BINARY
810);
811
812#[async_trait::async_trait]
813impl<R: ProcessRunner> GitApi for Git<R> {
814 async fn run(&self, args: &[String]) -> Result<String> {
815 self.core.run(self.core.command(args)).await
816 }
817
818 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
819 self.core.output(self.core.command(args)).await
820 }
821
822 async fn version(&self) -> Result<String> {
823 self.core.run(self.core.command(["--version"])).await
824 }
825
826 async fn capabilities(&self) -> Result<GitCapabilities> {
827 let raw = self.version().await?;
828 let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
829 program: BINARY.to_string(),
830 message: format!("unrecognisable `git --version` output: {raw:?}"),
831 })?;
832 Ok(GitCapabilities { version })
833 }
834
835 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
836 self.core
837 .parse(
838 self.core
839 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
840 parse::parse_porcelain,
841 )
842 .await
843 }
844
845 async fn status_text(&self, dir: &Path) -> Result<String> {
846 self.core
847 .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
848 .await
849 }
850
851 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
852 self.core
858 .parse(
859 self.core
860 .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
861 .env("GIT_OPTIONAL_LOCKS", "0"),
862 parse::parse_porcelain_v2,
863 )
864 .await
865 }
866
867 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
868 self.core
869 .parse(
870 self.core.command_in(
871 dir,
872 ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
873 ),
874 parse::parse_porcelain,
875 )
876 .await
877 }
878
879 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
880 self.core
882 .parse(
883 self.core
884 .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
885 parse::parse_nul_paths,
886 )
887 .await
888 }
889
890 async fn current_branch(&self, dir: &Path) -> Result<String> {
891 self.core
892 .run(
893 self.core
894 .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
895 )
896 .await
897 }
898
899 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
900 self.core
903 .parse(
904 self.core.command_in(dir, ["branch", "--no-column"]),
905 parse::parse_branches,
906 )
907 .await
908 }
909
910 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
911 let n = format!("-n{max}");
912 self.core
913 .parse(
914 self.core.command_in(
915 dir,
916 [
917 "log",
918 n.as_str(),
919 "-z",
920 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
921 ],
922 ),
923 parse::parse_log,
924 )
925 .await
926 }
927
928 async fn log_range(&self, dir: &Path, range: &str, max: usize) -> Result<Vec<Commit>> {
929 reject_flag_like("range", range)?;
930 let n = format!("-n{max}");
931 self.core
932 .parse(
933 self.core.command_in(
934 dir,
935 [
936 "log",
937 range,
938 n.as_str(),
939 "-z",
940 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
941 ],
942 ),
943 parse::parse_log,
944 )
945 .await
946 }
947
948 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
949 reject_flag_like("revision", rev)?;
950 self.core
951 .run(self.core.command_in(dir, ["rev-parse", rev]))
952 .await
953 }
954
955 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
956 reject_flag_like("revision", rev)?;
957 self.core
958 .run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
959 .await
960 }
961
962 async fn init(&self, dir: &Path) -> Result<()> {
963 self.core
964 .run_unit(self.core.command_in(dir, ["init"]))
965 .await
966 }
967
968 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
969 let mut command = self.core.command_in(dir, ["add", "--"]);
971 for path in paths {
972 command = command.arg(path);
973 }
974 self.core.run_unit(command).await
975 }
976
977 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
978 self.core
980 .run_unit(c_locale(
981 self.core.command_in(dir, ["commit", "-m", message]),
982 ))
983 .await
984 }
985
986 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
987 reject_flag_like("branch name", name)?;
988 self.core
989 .run_unit(self.core.command_in(dir, ["branch", name]))
990 .await
991 }
992
993 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
994 reject_flag_like("reference", reference)?;
995 self.core
996 .run_unit(self.core.command_in(dir, ["checkout", reference]))
997 .await
998 }
999
1000 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1001 reject_flag_like("commit", commit)?;
1002 self.core
1003 .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1004 .await
1005 }
1006
1007 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1008 let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1012 if spec.amend {
1013 command = command.arg("--amend");
1014 }
1015 command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1016 for path in &spec.paths {
1017 command = command.arg(path);
1018 }
1019 self.core.run_unit(command).await
1020 }
1021
1022 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1023 self.core
1024 .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1025 .await
1026 }
1027
1028 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1029 Ok(!self
1033 .core
1034 .probe(
1035 self.core
1036 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1037 )
1038 .await?)
1039 }
1040
1041 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1042 self.core
1045 .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1046 .await
1047 }
1048
1049 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1050 Ok(PathBuf::from(
1051 self.core
1052 .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1053 .await?,
1054 ))
1055 }
1056
1057 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1058 Ok(PathBuf::from(
1059 self.core
1060 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1061 .await?,
1062 ))
1063 }
1064
1065 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1066 reject_flag_like("revision", rev)?;
1067 let spec = format!("{rev}^{{commit}}");
1069 self.core
1070 .run(
1071 self.core
1072 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1073 )
1074 .await
1075 }
1076
1077 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1078 let res = self
1082 .core
1083 .output(
1084 self.core
1085 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1086 )
1087 .await?;
1088 if res.code() == Some(0) {
1089 let out = res.stdout().trim();
1092 Ok(Some(
1093 out.strip_prefix("refs/remotes/origin/")
1094 .unwrap_or(out)
1095 .to_string(),
1096 ))
1097 } else {
1098 Ok(None)
1099 }
1100 }
1101
1102 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1103 let refname = format!("refs/heads/{name}");
1104 self.core
1106 .probe(
1107 self.core
1108 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1109 )
1110 .await
1111 }
1112
1113 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1114 let refname = format!("refs/heads/{name}");
1124 let cmd = self
1125 .core
1126 .command_in(dir, ["ls-remote", "origin", refname.as_str()])
1127 .env("GIT_TERMINAL_PROMPT", "0")
1128 .timeout(Duration::from_secs(10));
1129 let res = self.core.output(cmd).await?;
1130 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1131 }
1132
1133 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1134 reject_flag_like("remote name", remote)?;
1135 self.core
1136 .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1137 .await
1138 }
1139
1140 async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1141 match self
1144 .core
1145 .output(self.core.command_in(
1146 dir,
1147 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1148 ))
1149 .await?
1150 {
1151 res if res.code() == Some(0) => {
1152 let name = res.stdout().trim();
1153 Ok((!name.is_empty()).then(|| name.to_string()))
1154 }
1155 _ => Ok(None),
1156 }
1157 }
1158
1159 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1160 reject_flag_like("remote name", remote)?;
1161 let cmd = self
1164 .core
1165 .command_in(dir, ["ls-remote", "--heads", remote])
1166 .env("GIT_TERMINAL_PROMPT", "0");
1167 self.core.parse(cmd, parse::parse_ls_remote_heads).await
1168 }
1169
1170 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
1171 reject_flag_like("branch", branch)?;
1172 reject_flag_like("target", target)?;
1173 let out = self
1177 .core
1178 .run(
1179 self.core
1180 .command_in(dir, ["branch", "--merged", target, "--no-column"]),
1181 )
1182 .await?;
1183 Ok(out
1187 .lines()
1188 .filter_map(|line| line.get(2..))
1189 .any(|b| b == branch))
1190 }
1191
1192 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1193 reject_flag_like("branch name", branch)?;
1194 let flag = format!("--set-upstream-to={upstream}");
1195 self.core
1196 .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1197 .await
1198 }
1199
1200 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1201 reject_flag_like("branch name", name)?;
1202 let flag = if force { "-D" } else { "-d" };
1203 self.core
1204 .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1205 .await
1206 }
1207
1208 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1209 reject_flag_like("branch name", old)?;
1210 reject_flag_like("branch name", new)?;
1211 self.core
1212 .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1213 .await
1214 }
1215
1216 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1217 reject_flag_like("range", range)?;
1218 self.core
1219 .try_parse(
1220 self.core.command_in(dir, ["rev-list", "--count", range]),
1221 |s| {
1222 s.trim().parse::<usize>().map_err(|e| Error::Parse {
1223 program: BINARY.to_string(),
1224 message: e.to_string(),
1225 })
1226 },
1227 )
1228 .await
1229 }
1230
1231 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1232 reject_flag_like("range", range)?;
1233 self.core
1235 .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
1236 .await
1237 }
1238
1239 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1240 reject_flag_like("range", range)?;
1241 self.core
1242 .parse(
1243 self.core.command_in(dir, ["diff", "--shortstat", range]),
1244 parse::parse_shortstat,
1245 )
1246 .await
1247 }
1248
1249 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1250 let target = match spec {
1254 DiffSpec::WorkingTree => {
1255 if self.is_unborn(dir).await? {
1259 EMPTY_TREE.to_string()
1260 } else {
1261 "HEAD".to_string()
1262 }
1263 }
1264 DiffSpec::Rev(rev) => {
1265 reject_flag_like("revision", &rev)?;
1266 rev
1267 }
1268 };
1269 self.core
1274 .run(self.core.command_in(
1275 dir,
1276 [
1277 "diff",
1278 target.as_str(),
1279 "--no-color",
1280 "--no-ext-diff",
1281 "-M",
1282 "--src-prefix=a/",
1283 "--dst-prefix=b/",
1284 ],
1285 ))
1286 .await
1287 }
1288
1289 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1290 let text = self.diff_text(dir, spec).await?;
1291 Ok(parse_diff(&text))
1292 }
1293
1294 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1295 self.core
1297 .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1298 .await
1299 }
1300
1301 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1302 let git_dir = self.resolved_git_dir(dir).await?;
1303 Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
1304 }
1305
1306 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1307 Ok(self
1308 .resolved_git_dir(dir)
1309 .await?
1310 .join("MERGE_HEAD")
1311 .exists())
1312 }
1313
1314 async fn fetch(&self, dir: &Path) -> Result<()> {
1315 let cmd = c_locale(self.core.command_in(dir, ["fetch", "--quiet"]))
1322 .env("GIT_TERMINAL_PROMPT", "0")
1323 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1324 self.core.run_unit(cmd).await
1325 }
1326
1327 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1328 reject_flag_like("remote", remote)?;
1332 let cmd = c_locale(self.core.command_in(dir, ["fetch", "--quiet", remote]))
1335 .env("GIT_TERMINAL_PROMPT", "0")
1336 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1337 self.core.run_unit(cmd).await
1338 }
1339
1340 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1341 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1342 let cmd = c_locale(
1343 self.core
1344 .command_in(dir, ["fetch", "--quiet", "origin", refspec.as_str()]),
1345 )
1346 .env("GIT_TERMINAL_PROMPT", "0")
1347 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1348 self.core.run_unit(cmd).await
1349 }
1350
1351 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1352 reject_flag_like("remote", &spec.remote)?;
1353 reject_flag_like("refspec", &spec.refspec)?;
1354 let mut args: Vec<&str> = vec!["push"];
1355 if spec.set_upstream {
1356 args.push("-u");
1357 }
1358 args.push(spec.remote.as_str());
1359 args.push(spec.refspec.as_str());
1360 let cmd = self
1361 .core
1362 .command_in(dir, args)
1363 .env("GIT_TERMINAL_PROMPT", "0");
1364 self.core.run_unit(cmd).await
1365 }
1366
1367 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1368 reject_flag_like("branch", branch)?;
1369 self.core
1370 .run_unit(self.core.command_in(dir, ["merge", "--squash", branch]))
1371 .await
1372 }
1373
1374 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1375 reject_flag_like("branch", &spec.branch)?;
1376 let mut args: Vec<&str> = vec!["merge"];
1377 if spec.no_ff {
1378 args.push("--no-ff");
1379 }
1380 if let Some(msg) = spec.message.as_deref() {
1381 args.push("-m");
1382 args.push(msg);
1383 } else {
1384 args.push("--no-edit");
1387 }
1388 args.push(&spec.branch);
1389 self.core
1391 .run_unit(c_locale(self.core.command_in(dir, args)))
1392 .await
1393 }
1394
1395 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1396 reject_flag_like("branch", &spec.branch)?;
1397 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1398 if spec.squash {
1401 args.push("--squash");
1402 } else if spec.no_ff {
1403 args.push("--no-ff");
1404 }
1405 args.push(&spec.branch);
1406 self.core
1408 .run_unit(c_locale(self.core.command_in(dir, args)))
1409 .await
1410 }
1411
1412 async fn merge_abort(&self, dir: &Path) -> Result<()> {
1413 self.core
1414 .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1415 .await
1416 }
1417
1418 async fn merge_continue(&self, dir: &Path) -> Result<()> {
1419 self.core
1424 .run_unit(no_editor(c_locale(
1425 self.core.command_in(dir, ["commit", "--no-edit"]),
1426 )))
1427 .await
1428 }
1429
1430 async fn reset_merge(&self, dir: &Path) -> Result<()> {
1431 self.core
1432 .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1433 .await
1434 }
1435
1436 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1437 reject_flag_like("revision", rev)?;
1438 self.core
1439 .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1440 .await
1441 }
1442
1443 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1444 reject_flag_like("rebase target", onto)?;
1445 self.core
1449 .run_unit(no_editor(c_locale(
1450 self.core.command_in(dir, ["rebase", onto]),
1451 )))
1452 .await
1453 }
1454
1455 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1456 self.core
1457 .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1458 .await
1459 }
1460
1461 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1462 self.core
1463 .run_unit(no_editor(c_locale(
1464 self.core.command_in(dir, ["rebase", "--continue"]),
1465 )))
1466 .await
1467 }
1468
1469 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1470 let mut command = self.core.command_in(dir, ["stash", "push"]);
1471 if include_untracked {
1472 command = command.arg("--include-untracked");
1473 }
1474 self.core.run_unit(command).await
1475 }
1476
1477 async fn stash_pop(&self, dir: &Path) -> Result<()> {
1478 self.core
1479 .run_unit(self.core.command_in(dir, ["stash", "pop"]))
1480 .await
1481 }
1482
1483 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1484 self.core
1485 .parse(
1486 self.core
1487 .command_in(dir, ["worktree", "list", "--porcelain"]),
1488 parse::parse_worktree_porcelain,
1489 )
1490 .await
1491 }
1492
1493 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1494 if let Some(name) = spec.new_branch.as_deref() {
1495 reject_flag_like("branch name", name)?;
1496 }
1497 if let Some(commitish) = spec.commitish.as_deref() {
1498 reject_flag_like("commit-ish", commitish)?;
1499 }
1500 let mut command = self.core.command_in(dir, ["worktree", "add"]);
1501 if let Some(name) = spec.new_branch.as_deref() {
1502 command = command.arg("-b").arg(name);
1503 }
1504 if spec.no_checkout {
1505 command = command.arg("--no-checkout");
1506 }
1507 command = command.arg(&spec.path);
1508 if let Some(commitish) = spec.commitish.as_deref() {
1509 command = command.arg(commitish);
1510 }
1511 self.core.run_unit(command).await
1512 }
1513
1514 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1515 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1516 if force {
1517 command = command.arg("--force");
1518 }
1519 command = command.arg(path);
1520 self.core.run_unit(command).await
1521 }
1522
1523 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1524 let command = self
1525 .core
1526 .command_in(dir, ["worktree", "move"])
1527 .arg(from)
1528 .arg(to);
1529 self.core.run_unit(command).await
1530 }
1531
1532 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1533 self.core
1534 .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1535 .await
1536 }
1537
1538 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1539 reject_flag_like("url", url)?;
1543 let mut command = self.core.command(["clone"]);
1546 if let Some(branch) = spec.branch.as_deref() {
1547 command = command.arg("--branch").arg(branch);
1548 }
1549 if let Some(depth) = spec.depth {
1550 command = command.arg("--depth").arg(depth.to_string());
1551 }
1552 if spec.bare {
1553 command = command.arg("--bare");
1554 }
1555 let command = command.arg(url).arg(dest).env("GIT_TERMINAL_PROMPT", "0");
1556 self.core.run_unit(command).await
1557 }
1558
1559 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1560 reject_flag_like("tag name", name)?;
1561 if let Some(rev) = rev.as_deref() {
1562 reject_flag_like("revision", rev)?;
1563 }
1564 let mut args = vec!["tag", name];
1565 if let Some(rev) = rev.as_deref() {
1566 args.push(rev);
1567 }
1568 self.core.run_unit(self.core.command_in(dir, args)).await
1569 }
1570
1571 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1572 reject_flag_like("tag name", &spec.name)?;
1573 if let Some(rev) = spec.rev.as_deref() {
1574 reject_flag_like("revision", rev)?;
1575 }
1576 let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1577 if let Some(rev) = spec.rev.as_deref() {
1578 args.push(rev);
1579 }
1580 self.core.run_unit(self.core.command_in(dir, args)).await
1581 }
1582
1583 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1584 let out = self
1587 .core
1588 .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1589 .await?;
1590 Ok(out.lines().map(str::to_string).collect())
1591 }
1592
1593 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1594 reject_flag_like("tag name", name)?;
1595 self.core
1596 .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1597 .await
1598 }
1599
1600 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1601 reject_flag_like("revision", rev)?;
1604 #[cfg(windows)]
1609 let path = path.replace('\\', "/");
1610 let spec = format!("{rev}:{path}");
1611 self.core
1612 .run(self.core.command_in(dir, ["show", spec.as_str()]))
1613 .await
1614 }
1615
1616 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
1617 reject_flag_like("config key", key)?;
1618 let res = self
1619 .core
1620 .output(self.core.command_in(dir, ["config", "--get", key]))
1621 .await?;
1622 match res.code() {
1623 Some(1) => Ok(None),
1625 Some(0) => Ok(Some(res.stdout().trim_end().to_string())),
1626 _ => {
1627 res.ensure_success()?;
1628 Ok(None) }
1630 }
1631 }
1632
1633 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
1634 reject_flag_like("config key", key)?;
1635 self.core
1636 .run_unit(self.core.command_in(dir, ["config", key, value]))
1637 .await
1638 }
1639
1640 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1641 reject_flag_like("remote name", name)?;
1642 reject_flag_like("url", url)?;
1643 self.core
1644 .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
1645 .await
1646 }
1647
1648 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1649 reject_flag_like("remote name", name)?;
1650 reject_flag_like("url", url)?;
1651 self.core
1652 .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
1653 .await
1654 }
1655
1656 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
1657 let mut args = vec!["blame", "--line-porcelain"];
1658 if let Some(rev) = rev.as_deref() {
1659 reject_flag_like("revision", rev)?;
1662 args.push(rev);
1663 }
1664 args.push("--");
1665 args.push(path);
1666 self.core
1667 .parse(
1668 self.core.command_in(dir, args),
1669 parse::parse_blame_porcelain,
1670 )
1671 .await
1672 }
1673
1674 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
1675 reject_flag_like("revision", rev)?;
1676 self.core
1679 .run_unit(no_editor(c_locale(
1680 self.core.command_in(dir, ["cherry-pick", rev]),
1681 )))
1682 .await
1683 }
1684
1685 async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
1686 reject_flag_like("revision", rev)?;
1687 self.core
1688 .run_unit(no_editor(c_locale(
1689 self.core.command_in(dir, ["revert", "--no-edit", rev]),
1690 )))
1691 .await
1692 }
1693
1694 async fn rebase_skip(&self, dir: &Path) -> Result<()> {
1695 self.core
1696 .run_unit(no_editor(c_locale(
1697 self.core.command_in(dir, ["rebase", "--skip"]),
1698 )))
1699 .await
1700 }
1701}
1702
1703pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1714
1715const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
1718const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
1719
1720fn no_editor(cmd: processkit::Command) -> processkit::Command {
1724 cmd.env("GIT_EDITOR", "true")
1725 .env("GIT_SEQUENCE_EDITOR", "true")
1726}
1727
1728fn c_locale(cmd: processkit::Command) -> processkit::Command {
1734 cmd.env("LC_ALL", "C")
1735}
1736
1737fn reject_flag_like(what: &str, value: &str) -> Result<()> {
1741 vcs_cli_support::reject_flag_like(BINARY, what, value)
1742}
1743
1744impl<R: ProcessRunner> Git<R> {
1745 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1750 self.core.run(self.core.command(args)).await
1751 }
1752
1753 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1756 self.core.output(self.core.command(args)).await
1757 }
1758
1759 pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
1764 GitAt { git: self, dir }
1765 }
1766
1767 pub fn harden(self) -> Self {
1793 let removed = [
1794 "GIT_DIR",
1795 "GIT_WORK_TREE",
1796 "GIT_INDEX_FILE",
1797 "GIT_OBJECT_DIRECTORY",
1798 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1799 "GIT_NAMESPACE",
1800 "GIT_CEILING_DIRECTORIES",
1801 "GIT_CONFIG_PARAMETERS",
1802 "GIT_CONFIG_GLOBAL",
1803 "GIT_CONFIG_SYSTEM",
1804 ];
1805 let mut hardened = self;
1806 for key in removed {
1807 hardened = hardened.default_env_remove(key);
1808 }
1809 hardened
1810 .default_env("GIT_CONFIG_NOSYSTEM", "1")
1811 .default_env("GIT_TERMINAL_PROMPT", "0")
1812 .default_env("GIT_CONFIG_COUNT", "2")
1813 .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
1814 .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
1815 .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
1816 .default_env("GIT_CONFIG_VALUE_1", "false")
1817 }
1818
1819 pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
1837 if self.status(dir).await?.is_empty() {
1840 return self.checkout(dir, branch).await;
1841 }
1842 self.stash_push(dir, true).await?;
1843 match self.checkout(dir, branch).await {
1844 Ok(()) => self.stash_pop(dir).await,
1845 Err(err) => {
1846 let _ = self.stash_pop(dir).await;
1850 Err(err)
1851 }
1852 }
1853 }
1854
1855 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
1858 let git_dir = PathBuf::from(
1859 self.core
1860 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1861 .await?,
1862 );
1863 Ok(if git_dir.is_absolute() {
1864 git_dir
1865 } else {
1866 dir.join(git_dir)
1867 })
1868 }
1869}
1870
1871impl Git {
1872 pub fn hardened() -> Self {
1875 Self::new().harden()
1876 }
1877}
1878
1879pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
1884 git: &'a Git<R>,
1885 dir: &'a Path,
1886}
1887
1888impl<R: ProcessRunner> Clone for GitAt<'_, R> {
1893 fn clone(&self) -> Self {
1894 *self
1895 }
1896}
1897impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
1898
1899macro_rules! git_at_forwarders {
1902 (
1903 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1904 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1905 ) => {
1906 impl<'a, R: ProcessRunner> GitAt<'a, R> {
1907 $(
1908 #[doc = concat!("Bound form of [`Git`]'s `", stringify!($bn), "`.")]
1909 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1910 self.git.$bn($($ba),*).await
1911 }
1912 )*
1913 $(
1914 #[doc = concat!("Bound form of [`Git`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1915 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1916 self.git.$dn(self.dir, $($da),*).await
1917 }
1918 )*
1919 }
1920 };
1921}
1922
1923git_at_forwarders! {
1924 bare {
1925 fn run(args: &[String]) -> Result<String>;
1926 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1927 fn run_args(args: &[&str]) -> Result<String>;
1928 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1929 fn version() -> Result<String>;
1930 fn capabilities() -> Result<GitCapabilities>;
1931 fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
1932 }
1933 dir {
1934 fn status() -> Result<Vec<StatusEntry>>;
1935 fn status_text() -> Result<String>;
1936 fn status_tracked() -> Result<Vec<StatusEntry>>;
1937 fn branch_status() -> Result<BranchStatus>;
1938 fn conflicted_files() -> Result<Vec<String>>;
1939 fn current_branch() -> Result<String>;
1940 fn branches() -> Result<Vec<Branch>>;
1941 fn log(max: usize) -> Result<Vec<Commit>>;
1942 fn log_range(range: &str, max: usize) -> Result<Vec<Commit>>;
1943 fn rev_parse(rev: &str) -> Result<String>;
1944 fn rev_parse_short(rev: &str) -> Result<String>;
1945 fn init() -> Result<()>;
1946 fn add(paths: &[PathBuf]) -> Result<()>;
1947 fn commit(message: &str) -> Result<()>;
1948 fn create_branch(name: &str) -> Result<()>;
1949 fn checkout(reference: &str) -> Result<()>;
1950 fn checkout_detach(commit: &str) -> Result<()>;
1951 fn commit_paths(spec: CommitPaths) -> Result<()>;
1952 fn last_commit_message() -> Result<String>;
1953 fn is_unborn() -> Result<bool>;
1954 fn diff_is_empty() -> Result<bool>;
1955 fn common_dir() -> Result<PathBuf>;
1956 fn git_dir() -> Result<PathBuf>;
1957 fn resolve_commit(rev: &str) -> Result<String>;
1958 fn remote_head_branch() -> Result<Option<String>>;
1959 fn branch_exists(name: &str) -> Result<bool>;
1960 fn remote_branch_exists(name: &str) -> Result<bool>;
1961 fn remote_url(remote: &str) -> Result<String>;
1962 fn upstream() -> Result<Option<String>>;
1963 fn remote_branches(remote: &str) -> Result<Vec<String>>;
1964 fn is_merged(branch: &str, target: &str) -> Result<bool>;
1965 fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
1966 fn delete_branch(name: &str, force: bool) -> Result<()>;
1967 fn rename_branch(old: &str, new: &str) -> Result<()>;
1968 fn rev_list_count(range: &str) -> Result<usize>;
1969 fn diff_range_is_empty(range: &str) -> Result<bool>;
1970 fn diff_stat(range: &str) -> Result<DiffStat>;
1971 fn diff_text(spec: DiffSpec) -> Result<String>;
1972 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
1973 fn staged_is_empty() -> Result<bool>;
1974 fn is_rebase_in_progress() -> Result<bool>;
1975 fn is_merge_in_progress() -> Result<bool>;
1976 fn fetch() -> Result<()>;
1977 fn fetch_from(remote: &str) -> Result<()>;
1978 fn fetch_remote_branch(branch: &str) -> Result<()>;
1979 fn push(spec: GitPush) -> Result<()>;
1980 fn merge_squash(branch: &str) -> Result<()>;
1981 fn merge_commit(spec: MergeCommit) -> Result<()>;
1982 fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
1983 fn merge_abort() -> Result<()>;
1984 fn merge_continue() -> Result<()>;
1985 fn reset_merge() -> Result<()>;
1986 fn reset_hard(rev: &str) -> Result<()>;
1987 fn rebase(onto: &str) -> Result<()>;
1988 fn rebase_abort() -> Result<()>;
1989 fn rebase_continue() -> Result<()>;
1990 fn stash_push(include_untracked: bool) -> Result<()>;
1991 fn stash_pop() -> Result<()>;
1992 fn switch_with_stash(branch: &str) -> Result<()>;
1993 fn worktree_list() -> Result<Vec<Worktree>>;
1994 fn worktree_add(spec: WorktreeAdd) -> Result<()>;
1995 fn worktree_remove(path: &Path, force: bool) -> Result<()>;
1996 fn worktree_move(from: &Path, to: &Path) -> Result<()>;
1997 fn worktree_prune() -> Result<()>;
1998 fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
1999 fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2000 fn tag_list() -> Result<Vec<String>>;
2001 fn tag_delete(name: &str) -> Result<()>;
2002 fn show_file(rev: &str, path: &str) -> Result<String>;
2003 fn config_get(key: &str) -> Result<Option<String>>;
2004 fn config_set(key: &str, value: &str) -> Result<()>;
2005 fn remote_add(name: &str, url: &str) -> Result<()>;
2006 fn remote_set_url(name: &str, url: &str) -> Result<()>;
2007 fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2008 fn cherry_pick(rev: &str) -> Result<()>;
2009 fn revert(rev: &str) -> Result<()>;
2010 fn rebase_skip() -> Result<()>;
2011 }
2012}
2013
2014pub mod blocking {
2018 use std::path::Path;
2019 use std::process::Command;
2020
2021 pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2023 let mut cmd = Command::new(super::BINARY);
2024 cmd.current_dir(dir).args(["worktree", "remove"]);
2025 if force {
2026 cmd.arg("--force");
2027 }
2028 cmd.arg(path);
2029 let status = cmd.status()?;
2030 if status.success() {
2031 Ok(())
2032 } else {
2033 Err(std::io::Error::other(format!(
2034 "`git worktree remove` exited with {status}"
2035 )))
2036 }
2037 }
2038}
2039
2040#[cfg(test)]
2041mod tests {
2042 use super::*;
2043 use processkit::{RecordingRunner, Reply, ScriptedRunner};
2044
2045 #[test]
2046 fn binary_name_is_git() {
2047 assert_eq!(BINARY, "git");
2048 }
2049
2050 #[allow(dead_code)]
2054 fn bound_view_is_copy_for_default_runner() {
2055 fn assert_copy<T: Copy>() {}
2056 assert_copy::<GitAt<'static, processkit::JobRunner>>();
2057 }
2058
2059 #[tokio::test]
2063 async fn bound_view_matches_dir_taking_calls() {
2064 let dir = Path::new("/repo");
2065 let rec = RecordingRunner::replying(Reply::ok(""));
2066 let git = Git::with_runner(&rec);
2067
2068 git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2070 .await
2071 .unwrap();
2072 git.at(dir)
2073 .merge_commit(MergeCommit::branch("feat").no_ff())
2074 .await
2075 .unwrap();
2076 git.worktree_remove(dir, Path::new("/wt"), true)
2078 .await
2079 .unwrap();
2080 git.at(dir)
2081 .worktree_remove(Path::new("/wt"), true)
2082 .await
2083 .unwrap();
2084 git.conflicted_files(dir).await.unwrap();
2086 git.at(dir).conflicted_files().await.unwrap();
2087 git.tag_delete(dir, "v1").await.unwrap();
2089 git.at(dir).tag_delete("v1").await.unwrap();
2090
2091 let calls = rec.calls();
2092 assert_eq!(calls[0].args_str(), calls[1].args_str());
2093 assert_eq!(calls[2].args_str(), calls[3].args_str());
2094 assert_eq!(calls[4].args_str(), calls[5].args_str());
2095 assert_eq!(calls[6].args_str(), calls[7].args_str());
2096 assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
2098 assert_eq!(calls[3].cwd.as_deref(), Some(dir.as_os_str()));
2099 }
2100
2101 #[tokio::test]
2104 async fn status_parses_scripted_output() {
2105 let git =
2107 Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
2108 let entries = git.status(Path::new(".")).await.expect("status");
2109 assert_eq!(entries.len(), 2);
2110 assert_eq!(entries[0].code, " M");
2111 assert_eq!(entries[1].path, "b.rs");
2112 }
2113
2114 #[tokio::test]
2116 async fn status_tracked_excludes_untracked_flag() {
2117 let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2118 let git = Git::with_runner(&rec);
2119 let entries = git.status_tracked(Path::new(".")).await.expect("status");
2120 assert_eq!(entries.len(), 1);
2121 assert_eq!(entries[0].code, " M");
2122 assert_eq!(
2123 rec.only_call().args_str(),
2124 ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2125 );
2126 }
2127
2128 #[tokio::test]
2131 async fn branch_status_builds_v2_branch_args_and_parses() {
2132 let out = concat!(
2133 "# branch.oid abc\0",
2134 "# branch.head main\0",
2135 "# branch.upstream origin/main\0",
2136 "# branch.ab +1 -0\0",
2137 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2138 "? new.txt\0",
2139 );
2140 let rec = RecordingRunner::replying(Reply::ok(out));
2141 let git = Git::with_runner(&rec);
2142 let s = git
2143 .branch_status(Path::new("."))
2144 .await
2145 .expect("branch_status");
2146 assert_eq!(
2147 rec.only_call().args_str(),
2148 ["status", "--porcelain=v2", "--branch", "-z"]
2149 );
2150 assert!(rec.only_call().envs.iter().any(|(k, v)| {
2153 k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2154 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2155 }));
2156 assert_eq!(s.branch.as_deref(), Some("main"));
2157 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2158 assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2159 assert_eq!(s.tracked_changes, 1);
2160 assert_eq!(s.untracked, 1);
2161 assert!(s.is_dirty());
2162 }
2163
2164 #[tokio::test]
2166 async fn conflicted_files_builds_args_and_parses_nul_list() {
2167 let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2168 let git = Git::with_runner(&rec);
2169 let paths = git
2170 .conflicted_files(Path::new("."))
2171 .await
2172 .expect("conflicted_files");
2173 assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2174 assert_eq!(
2175 rec.only_call().args_str(),
2176 ["diff", "--name-only", "--diff-filter=U", "-z"]
2177 );
2178 }
2179
2180 #[tokio::test]
2181 async fn rev_parse_short_builds_short_flag() {
2182 let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2183 let git = Git::with_runner(&rec);
2184 let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2185 assert_eq!(out, "a1b2c3d");
2186 assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
2187 }
2188
2189 #[tokio::test]
2191 async fn nonzero_exit_is_structured_error() {
2192 let git = Git::with_runner(
2193 ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
2194 );
2195 match git.status(Path::new(".")).await.unwrap_err() {
2196 Error::Exit { code, stderr, .. } => {
2197 assert_eq!(code, 128);
2198 assert!(stderr.contains("not a git repository"), "{stderr}");
2199 }
2200 other => panic!("expected Exit, got {other:?}"),
2201 }
2202 }
2203
2204 #[tokio::test]
2207 async fn diff_is_empty_maps_exit_codes() {
2208 let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
2209 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2210
2211 let dirty =
2212 Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
2213 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2214
2215 let broken = Git::with_runner(
2216 ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
2217 );
2218 assert!(matches!(
2219 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2220 Error::Exit { code: 128, .. }
2221 ));
2222 }
2223
2224 #[tokio::test]
2227 async fn add_inserts_pathspec_separator() {
2228 let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
2229 git.add(Path::new("."), &[PathBuf::from("f.rs")])
2230 .await
2231 .expect("add should build `add -- <paths>`");
2232 }
2233
2234 #[tokio::test]
2235 async fn worktree_list_parses_porcelain() {
2236 let git = Git::with_runner(ScriptedRunner::new().on(
2237 ["worktree", "list"],
2238 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2239 ));
2240 let wts = git.worktree_list(Path::new(".")).await.expect("list");
2241 assert_eq!(wts.len(), 1);
2242 assert_eq!(wts[0].branch.as_deref(), Some("main"));
2243 assert_eq!(wts[0].head.as_deref(), Some("abc"));
2244 }
2245
2246 #[tokio::test]
2249 async fn worktree_add_builds_branch_path_and_base() {
2250 let rec = RecordingRunner::replying(Reply::ok(""));
2251 let git = Git::with_runner(&rec);
2252 git.worktree_add(
2253 Path::new("/repo"),
2254 WorktreeAdd::create_branch("/wt", "feature", "main"),
2255 )
2256 .await
2257 .expect("worktree add");
2258 assert_eq!(
2259 rec.only_call().args_str(),
2260 ["worktree", "add", "-b", "feature", "/wt", "main"]
2261 );
2262 }
2263
2264 #[tokio::test]
2265 async fn worktree_remove_passes_force_then_path() {
2266 let rec = RecordingRunner::replying(Reply::ok(""));
2267 let git = Git::with_runner(&rec);
2268 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2269 .await
2270 .expect("remove");
2271 assert_eq!(
2272 rec.only_call().args_str(),
2273 ["worktree", "remove", "--force", "/wt"]
2274 );
2275 }
2276
2277 #[tokio::test]
2279 async fn worktree_add_no_checkout_inserts_flag() {
2280 let rec = RecordingRunner::replying(Reply::ok(""));
2281 let git = Git::with_runner(&rec);
2282 git.worktree_add(
2283 Path::new("/repo"),
2284 WorktreeAdd::checkout("/wt", "main").no_checkout(),
2285 )
2286 .await
2287 .expect("worktree add");
2288 assert_eq!(
2289 rec.only_call().args_str(),
2290 ["worktree", "add", "--no-checkout", "/wt", "main"]
2291 );
2292 }
2293
2294 #[tokio::test]
2295 async fn checkout_detach_builds_args() {
2296 let rec = RecordingRunner::replying(Reply::ok(""));
2297 let git = Git::with_runner(&rec);
2298 git.checkout_detach(Path::new("."), "abc123")
2299 .await
2300 .expect("detach");
2301 assert_eq!(
2302 rec.only_call().args_str(),
2303 ["checkout", "--detach", "abc123"]
2304 );
2305 }
2306
2307 #[tokio::test]
2309 async fn commit_paths_builds_only_amend_args() {
2310 let rec = RecordingRunner::replying(Reply::ok(""));
2311 let git = Git::with_runner(&rec);
2312 git.commit_paths(
2313 Path::new("."),
2314 CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2315 )
2316 .await
2317 .expect("commit_paths");
2318 assert_eq!(
2319 rec.only_call().args_str(),
2320 [
2321 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2322 ]
2323 );
2324 }
2325
2326 #[tokio::test]
2329 async fn is_unborn_maps_exit_codes() {
2330 let born = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("abc\n")));
2331 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2332 let unborn = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(1, "")));
2333 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2334 let broken =
2335 Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "boom")));
2336 assert!(matches!(
2337 broken.is_unborn(Path::new(".")).await.unwrap_err(),
2338 Error::Exit { code: 128, .. }
2339 ));
2340 }
2341
2342 #[tokio::test]
2343 async fn log_range_builds_range_and_format() {
2344 let rec = RecordingRunner::replying(Reply::ok(""));
2345 let git = Git::with_runner(&rec);
2346 git.log_range(Path::new("."), "main..HEAD", 5)
2347 .await
2348 .expect("log_range");
2349 assert_eq!(
2350 rec.only_call().args_str(),
2351 [
2352 "log",
2353 "main..HEAD",
2354 "-n5",
2355 "-z",
2356 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2357 ]
2358 );
2359 }
2360
2361 #[tokio::test]
2362 async fn stash_push_adds_include_untracked() {
2363 let rec = RecordingRunner::replying(Reply::ok(""));
2364 let git = Git::with_runner(&rec);
2365 git.stash_push(Path::new("."), true).await.expect("stash");
2366 assert_eq!(
2367 rec.only_call().args_str(),
2368 ["stash", "push", "--include-untracked"]
2369 );
2370 }
2371
2372 #[tokio::test]
2375 async fn diff_text_builds_working_tree_args() {
2376 let rec = RecordingRunner::replying(Reply::ok(""));
2379 let git = Git::with_runner(&rec);
2380 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2381 .await
2382 .expect("diff_text");
2383 assert_eq!(
2384 rec.calls().last().unwrap().args_str(),
2385 [
2386 "diff",
2387 "HEAD",
2388 "--no-color",
2389 "--no-ext-diff",
2390 "-M",
2391 "--src-prefix=a/",
2394 "--dst-prefix=b/",
2395 ]
2396 );
2397 }
2398
2399 #[tokio::test]
2403 async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
2404 let git = Git::with_runner(
2405 ScriptedRunner::new()
2406 .on(["rev-parse"], Reply::fail(1, "")) .on(["diff", EMPTY_TREE], Reply::ok("EMPTY")),
2408 );
2409 let out = git
2410 .diff_text(Path::new("."), DiffSpec::WorkingTree)
2411 .await
2412 .expect("diff_text");
2413 assert_eq!(out, "EMPTY");
2414 }
2415
2416 #[tokio::test]
2419 async fn diff_parses_scripted_output() {
2420 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2421 let git = Git::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
2422 let files = git
2423 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
2424 .await
2425 .expect("diff");
2426 assert_eq!(files.len(), 1);
2427 assert_eq!(files[0].path, "m");
2428 assert_eq!(files[0].change, ChangeKind::Modified);
2429 }
2430
2431 #[tokio::test]
2432 async fn branch_exists_maps_exit_codes() {
2433 let yes = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
2434 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
2435 let no = Git::with_runner(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
2436 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
2437 }
2438
2439 #[tokio::test]
2442 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
2443 let simple = Git::with_runner(
2444 ScriptedRunner::new().on(["symbolic-ref"], Reply::ok("refs/remotes/origin/main\n")),
2445 );
2446 assert_eq!(
2447 simple
2448 .remote_head_branch(Path::new("."))
2449 .await
2450 .unwrap()
2451 .as_deref(),
2452 Some("main")
2453 );
2454
2455 let slashed = Git::with_runner(ScriptedRunner::new().on(
2456 ["symbolic-ref"],
2457 Reply::ok("refs/remotes/origin/release/v2\n"),
2458 ));
2459 assert_eq!(
2460 slashed
2461 .remote_head_branch(Path::new("."))
2462 .await
2463 .unwrap()
2464 .as_deref(),
2465 Some("release/v2")
2466 );
2467
2468 let unset =
2469 Git::with_runner(ScriptedRunner::new().on(["symbolic-ref"], Reply::fail(1, "")));
2470 assert!(
2471 unset
2472 .remote_head_branch(Path::new("."))
2473 .await
2474 .unwrap()
2475 .is_none()
2476 );
2477 }
2478
2479 #[tokio::test]
2482 async fn remote_branch_exists_sets_env_and_reads_stdout() {
2483 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
2484 let git = Git::with_runner(&rec);
2485 assert!(
2486 git.remote_branch_exists(Path::new("/repo"), "main")
2487 .await
2488 .unwrap()
2489 );
2490 let call = rec.only_call();
2491 assert!(call.envs.iter().any(|(k, v)| {
2492 k.to_str() == Some("GIT_TERMINAL_PROMPT")
2493 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2494 }));
2495 assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
2497
2498 let empty = Git::with_runner(ScriptedRunner::new().on(["ls-remote"], Reply::ok("")));
2499 assert!(
2500 !empty
2501 .remote_branch_exists(Path::new("."), "x")
2502 .await
2503 .unwrap()
2504 );
2505 }
2506
2507 #[tokio::test]
2508 async fn diff_stat_parses_counts() {
2509 let git = Git::with_runner(ScriptedRunner::new().on(
2510 ["diff", "--shortstat"],
2511 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
2512 ));
2513 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
2514 assert_eq!(
2515 (stat.files_changed, stat.insertions, stat.deletions),
2516 (2, 5, 1)
2517 );
2518 }
2519
2520 #[tokio::test]
2521 async fn status_text_returns_raw_porcelain() {
2522 let git = Git::with_runner(ScriptedRunner::new().on(
2523 ["status", "--porcelain=v1"],
2524 Reply::ok(" M a.rs\n?? b.rs\n"),
2525 ));
2526 let text = git.status_text(Path::new(".")).await.expect("status_text");
2527 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
2528 }
2529
2530 #[tokio::test]
2531 async fn run_args_forwards_str_slices() {
2532 let git = Git::with_runner(ScriptedRunner::new().on(["status", "-s"], Reply::ok("ok\n")));
2533 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
2534 }
2535
2536 #[tokio::test]
2537 async fn merge_commit_builds_no_ff_and_message() {
2538 let rec = RecordingRunner::replying(Reply::ok(""));
2539 let git = Git::with_runner(&rec);
2540 git.merge_commit(
2541 Path::new("/r"),
2542 MergeCommit::branch("feature").no_ff().message("merge it"),
2543 )
2544 .await
2545 .unwrap();
2546 assert_eq!(
2547 rec.only_call().args_str(),
2548 ["merge", "--no-ff", "-m", "merge it", "feature"]
2549 );
2550 }
2551
2552 #[tokio::test]
2554 async fn merge_commit_without_message_uses_no_edit() {
2555 let rec = RecordingRunner::replying(Reply::ok(""));
2556 let git = Git::with_runner(&rec);
2557 git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
2558 .await
2559 .unwrap();
2560 assert_eq!(
2561 rec.only_call().args_str(),
2562 ["merge", "--no-edit", "feature"]
2563 );
2564 }
2565
2566 #[tokio::test]
2568 async fn rebase_suppresses_editor() {
2569 let rec = RecordingRunner::replying(Reply::ok(""));
2570 let git = Git::with_runner(&rec);
2571 git.rebase(Path::new("/r"), "main").await.unwrap();
2572 let call = rec.only_call();
2573 assert_eq!(call.args_str(), ["rebase", "main"]);
2574 assert!(call.envs.iter().any(|(k, v)| {
2575 k.to_str() == Some("GIT_EDITOR")
2576 && v.as_deref().and_then(|o| o.to_str()) == Some("true")
2577 }));
2578 }
2579
2580 #[tokio::test]
2581 async fn push_builds_set_upstream_remote_refspec() {
2582 let rec = RecordingRunner::replying(Reply::ok(""));
2583 let git = Git::with_runner(&rec);
2584 git.push(
2585 Path::new("/r"),
2586 GitPush::refspec("feat", "feature").set_upstream(),
2587 )
2588 .await
2589 .unwrap();
2590 assert_eq!(
2591 rec.only_call().args_str(),
2592 ["push", "-u", "origin", "feat:feature"]
2593 );
2594 }
2595
2596 #[tokio::test]
2599 async fn push_bare_branch_builds_origin_branch_prompt_off() {
2600 let rec = RecordingRunner::replying(Reply::ok(""));
2601 let git = Git::with_runner(&rec);
2602 git.push(Path::new("/r"), GitPush::branch("feature"))
2603 .await
2604 .unwrap();
2605 let call = rec.only_call();
2606 assert_eq!(call.args_str(), ["push", "origin", "feature"]);
2607 assert!(call.envs.iter().any(|(k, v)| {
2608 k.to_str() == Some("GIT_TERMINAL_PROMPT")
2609 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2610 }));
2611 }
2612
2613 #[tokio::test]
2615 async fn push_remote_override_swaps_remote() {
2616 let rec = RecordingRunner::replying(Reply::ok(""));
2617 let git = Git::with_runner(&rec);
2618 git.push(
2619 Path::new("/r"),
2620 GitPush::branch("feature").remote("upstream"),
2621 )
2622 .await
2623 .unwrap();
2624 assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
2625 }
2626
2627 #[tokio::test]
2628 async fn upstream_maps_unset_to_none() {
2629 let set =
2630 Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::ok("origin/main\n")));
2631 assert_eq!(
2632 set.upstream(Path::new(".")).await.unwrap().as_deref(),
2633 Some("origin/main")
2634 );
2635 let unset = Git::with_runner(ScriptedRunner::new().on(["rev-parse"], Reply::fail(128, "")));
2636 assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
2637 }
2638
2639 #[tokio::test]
2640 async fn set_upstream_builds_branch_flag() {
2641 let rec = RecordingRunner::replying(Reply::ok(""));
2642 let git = Git::with_runner(&rec);
2643 git.set_upstream(Path::new("/r"), "feat", "origin/feature")
2644 .await
2645 .unwrap();
2646 assert_eq!(
2647 rec.only_call().args_str(),
2648 ["branch", "--set-upstream-to=origin/feature", "feat"]
2649 );
2650 }
2651
2652 #[tokio::test]
2653 async fn remote_branches_parses_ls_remote() {
2654 let git = Git::with_runner(ScriptedRunner::new().on(
2655 ["ls-remote"],
2656 Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
2657 ));
2658 let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
2659 assert_eq!(branches, ["main", "feat/x"]);
2660 }
2661
2662 #[tokio::test]
2663 async fn delete_branch_force_uses_capital_d() {
2664 let rec = RecordingRunner::replying(Reply::ok(""));
2665 let git = Git::with_runner(&rec);
2666 git.delete_branch(Path::new("/r"), "old", true)
2667 .await
2668 .unwrap();
2669 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
2670 }
2671
2672 #[tokio::test]
2675 async fn is_merged_strips_branch_markers() {
2676 let git = Git::with_runner(ScriptedRunner::new().on(
2677 ["branch", "--merged"],
2678 Reply::ok(" main\n* feature\n+ wt-branch\n"),
2679 ));
2680 for name in ["main", "feature", "wt-branch"] {
2681 assert!(
2682 git.is_merged(Path::new("."), name, "main").await.unwrap(),
2683 "{name} should be reported merged"
2684 );
2685 }
2686 assert!(
2687 !git.is_merged(Path::new("."), "absent", "main")
2688 .await
2689 .unwrap()
2690 );
2691 }
2692
2693 #[tokio::test]
2696 async fn fetch_disables_terminal_prompt() {
2697 let rec = RecordingRunner::replying(Reply::ok(""));
2698 let git = Git::with_runner(&rec);
2699 git.fetch(Path::new("/r")).await.unwrap();
2700 let call = rec.only_call();
2701 assert_eq!(call.args_str(), ["fetch", "--quiet"]);
2702 assert!(call.envs.iter().any(|(k, v)| {
2703 k.to_str() == Some("GIT_TERMINAL_PROMPT")
2704 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2705 }));
2706 }
2707
2708 #[tokio::test]
2710 async fn fetch_retries_transient_failures() {
2711 let rec = RecordingRunner::replying(Reply::fail(
2712 128,
2713 "fatal: unable to access: Could not resolve host: example.com",
2714 ));
2715 let git = Git::with_runner(&rec);
2716 assert!(git.fetch(Path::new("/r")).await.is_err());
2717 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
2718 }
2719
2720 #[tokio::test]
2722 async fn fetch_does_not_retry_permanent_failures() {
2723 let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
2724 let git = Git::with_runner(&rec);
2725 assert!(git.fetch(Path::new("/r")).await.is_err());
2726 assert_eq!(rec.calls().len(), 1);
2727 }
2728
2729 #[cfg(feature = "cancellation")]
2736 #[tokio::test(start_paused = true)]
2737 async fn fetch_cancels_and_does_not_retry() {
2738 use processkit::CancellationToken;
2739 let token = CancellationToken::new();
2740 let rec = RecordingRunner::new(ScriptedRunner::new().on(["fetch"], Reply::pending()));
2741 let git = Git::with_runner(&rec).default_cancel_on(token.clone());
2742 let call = git.fetch(Path::new("/r"));
2743 tokio::pin!(call);
2744 assert!(
2745 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
2746 .await
2747 .is_err(),
2748 "fetch must park until the token fires"
2749 );
2750 token.cancel();
2751 assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
2752 assert_eq!(
2753 rec.calls().len(),
2754 1,
2755 "cancellation is terminal — the fetch-retry must not replay it"
2756 );
2757 }
2758
2759 #[tokio::test]
2762 async fn flag_like_positionals_are_rejected_before_spawning() {
2763 let rec = RecordingRunner::replying(Reply::ok(""));
2764 let git = Git::with_runner(&rec);
2765 let dir = Path::new("/r");
2766
2767 assert!(git.checkout(dir, "-evil").await.is_err());
2768 assert!(git.create_branch(dir, "--force").await.is_err());
2769 assert!(git.delete_branch(dir, "-D", false).await.is_err());
2770 assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
2771 assert!(
2772 git.merge_commit(dir, MergeCommit::branch("-evil"))
2773 .await
2774 .is_err()
2775 );
2776 assert!(
2777 git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
2778 .await
2779 .is_err()
2780 );
2781 assert!(git.merge_squash(dir, "-evil").await.is_err());
2782 assert!(git.rebase(dir, "-i").await.is_err());
2783 assert!(git.cherry_pick(dir, "-n").await.is_err());
2784 assert!(git.revert(dir, "-evil").await.is_err());
2785 assert!(git.tag_create(dir, "-d", None).await.is_err());
2786 assert!(
2787 git.tag_create(dir, "ok", Some("-evil".into()))
2788 .await
2789 .is_err()
2790 );
2791 assert!(git.tag_delete(dir, "-evil").await.is_err());
2792 assert!(git.remote_add(dir, "-evil", "url").await.is_err());
2793 assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
2794 assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
2795 assert!(git.log_range(dir, "-evil", 5).await.is_err());
2796 assert!(git.rev_list_count(dir, "-evil").await.is_err());
2797 assert!(git.diff_stat(dir, "-evil").await.is_err());
2798 assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
2799 assert!(
2800 git.diff_text(dir, DiffSpec::Rev("-evil".into()))
2801 .await
2802 .is_err()
2803 );
2804 assert!(git.rev_parse(dir, "-evil").await.is_err());
2805 assert!(git.rev_parse_short(dir, "-evil").await.is_err());
2806 assert!(git.resolve_commit(dir, "-evil").await.is_err());
2807 assert!(git.reset_hard(dir, "-evil").await.is_err());
2808 assert!(git.checkout_detach(dir, "-evil").await.is_err());
2809 assert!(git.config_set(dir, "-evil", "v").await.is_err());
2810 assert!(
2811 git.push(dir, GitPush::branch("-evil")).await.is_err(),
2812 "refspec guard"
2813 );
2814 assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
2816 assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
2817 assert!(git.remote_url(dir, "-evil").await.is_err());
2818 assert!(git.remote_branches(dir, "-evil").await.is_err());
2819 assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
2820 assert!(
2822 git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
2823 .await
2824 .is_err()
2825 );
2826 assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
2827 assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
2828 assert!(git.is_merged(dir, "-evil", "main").await.is_err());
2829 assert!(git.config_get(dir, "-evil").await.is_err());
2830 assert!(
2831 git.worktree_add(
2832 dir,
2833 WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
2834 )
2835 .await
2836 .is_err()
2837 );
2838 assert!(git.checkout(dir, "").await.is_err());
2840
2841 assert!(
2842 rec.calls().is_empty(),
2843 "nothing may spawn: {:?}",
2844 rec.calls()
2845 );
2846
2847 git.checkout(dir, "feature/x").await.expect("checkout");
2849 assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x"]);
2850 }
2851
2852 #[tokio::test]
2855 async fn harden_applies_env_profile_to_every_command() {
2856 let rec = RecordingRunner::replying(Reply::ok(""));
2857 let git = Git::with_runner(&rec).harden();
2858 git.status(Path::new("/r")).await.expect("status");
2859 git.fetch(Path::new("/r")).await.expect("fetch");
2860
2861 for call in rec.calls() {
2862 let has = |k: &str, v: &str| {
2863 call.envs.iter().any(|(key, val)| {
2864 key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
2865 })
2866 };
2867 let removed = |k: &str| {
2868 call.envs
2869 .iter()
2870 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
2871 };
2872 assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
2873 assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
2874 assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
2875 assert!(has("GIT_TERMINAL_PROMPT", "0"));
2876 assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
2877 assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
2878 }
2879 }
2880
2881 #[test]
2883 fn ref_name_and_rev_spec_validate() {
2884 for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
2885 assert!(RefName::new(ok).is_ok(), "{ok}");
2886 }
2887 for bad in [
2888 "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
2889 "a\\b", "end/", "x.lock",
2890 ] {
2891 assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
2892 }
2893 assert!(RevSpec::new("HEAD~2").is_ok());
2894 assert!(RevSpec::new("main..feature").is_ok());
2895 assert!(RevSpec::new("-evil").is_err());
2896 assert!(RevSpec::new("").is_err());
2897 }
2898
2899 #[tokio::test]
2902 async fn capabilities_parse_and_gate_versions() {
2903 let gh = Git::with_runner(
2904 ScriptedRunner::new().on(["--version"], Reply::ok("git version 2.54.0.windows.1\n")),
2905 );
2906 let caps = gh.capabilities().await.expect("capabilities");
2907 assert_eq!(caps.version.to_string(), "2.54.0");
2908 assert!(caps.is_supported());
2909 caps.ensure_supported().expect("supported");
2910
2911 let old = Git::with_runner(
2914 ScriptedRunner::new().on(["--version"], Reply::ok("git version 1.9\n")),
2915 );
2916 let caps = old.capabilities().await.expect("capabilities");
2917 assert_eq!(
2918 caps.version,
2919 GitVersion {
2920 major: 1,
2921 minor: 9,
2922 patch: 0
2923 }
2924 );
2925 let err = caps.ensure_supported().expect_err("unsupported");
2926 let Error::Spawn { source, .. } = &err else {
2928 panic!("expected Spawn, got {err:?}");
2929 };
2930 let message = source.to_string();
2931 assert!(message.contains(">= 2"), "names the floor: {message}");
2932 assert!(
2933 message.contains("1.9.0"),
2934 "names the found version: {message}"
2935 );
2936
2937 let garbage =
2939 Git::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("not a version")));
2940 assert!(matches!(
2941 garbage.capabilities().await.unwrap_err(),
2942 Error::Parse { .. }
2943 ));
2944 }
2945
2946 #[tokio::test]
2948 async fn clone_repo_builds_flags_and_runs_dirless() {
2949 let rec = RecordingRunner::replying(Reply::ok(""));
2950 let git = Git::with_runner(&rec);
2951 git.clone_repo(
2952 "https://example.com/r.git",
2953 Path::new("/dest"),
2954 CloneSpec::new().branch("main").depth(1).bare(),
2955 )
2956 .await
2957 .expect("clone");
2958 let call = rec.only_call();
2959 assert_eq!(
2960 call.args_str(),
2961 [
2962 "clone",
2963 "--branch",
2964 "main",
2965 "--depth",
2966 "1",
2967 "--bare",
2968 "https://example.com/r.git",
2969 "/dest"
2970 ]
2971 );
2972 assert_eq!(call.cwd, None, "clone runs without a working directory");
2973
2974 let bare = RecordingRunner::replying(Reply::ok(""));
2975 let git = Git::with_runner(&bare);
2976 git.clone_repo("u", Path::new("/d"), CloneSpec::new())
2977 .await
2978 .expect("clone");
2979 assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
2980 }
2981
2982 #[tokio::test]
2983 async fn tag_methods_build_args() {
2984 let rec = RecordingRunner::replying(Reply::ok(""));
2985 let git = Git::with_runner(&rec);
2986 git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
2987 git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
2988 .await
2989 .unwrap();
2990 git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
2991 .await
2992 .unwrap();
2993 git.tag_delete(Path::new("/r"), "v1").await.unwrap();
2994 let calls = rec.calls();
2995 assert_eq!(calls[0].args_str(), ["tag", "v1"]);
2996 assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
2997 assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
2998 assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
2999 }
3000
3001 #[tokio::test]
3002 async fn tag_list_splits_lines() {
3003 let git =
3004 Git::with_runner(ScriptedRunner::new().on(["tag", "--list"], Reply::ok("v1\nv2.0\n")));
3005 assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
3006 }
3007
3008 #[tokio::test]
3011 async fn list_commands_disable_column_output() {
3012 let rec = RecordingRunner::replying(Reply::ok(""));
3013 let git = Git::with_runner(&rec);
3014 git.branches(Path::new(".")).await.unwrap();
3015 git.is_merged(Path::new("."), "b", "main").await.unwrap();
3016 git.tag_list(Path::new(".")).await.unwrap();
3017 let calls = rec.calls();
3018 assert_eq!(calls[0].args_str(), ["branch", "--no-column"]);
3019 assert_eq!(
3020 calls[1].args_str(),
3021 ["branch", "--merged", "main", "--no-column"]
3022 );
3023 assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
3024 }
3025
3026 #[tokio::test]
3029 async fn classified_commands_force_c_locale() {
3030 let rec = RecordingRunner::replying(Reply::ok(""));
3031 let git = Git::with_runner(&rec);
3032 git.commit(Path::new("."), "msg").await.unwrap();
3033 git.merge_commit(Path::new("."), MergeCommit::branch("b"))
3034 .await
3035 .unwrap();
3036 git.cherry_pick(Path::new("."), "abc").await.unwrap();
3037 git.fetch(Path::new(".")).await.unwrap();
3038 for call in rec.calls() {
3039 assert!(
3040 call.envs.iter().any(|(k, v)| {
3041 k.to_str() == Some("LC_ALL")
3042 && v.as_deref().and_then(|o| o.to_str()) == Some("C")
3043 }),
3044 "{:?} should force LC_ALL=C",
3045 call.args_str()
3046 );
3047 }
3048 }
3049
3050 #[cfg(windows)]
3053 #[tokio::test]
3054 async fn show_file_normalises_path_separators() {
3055 let rec = RecordingRunner::replying(Reply::ok("content\n"));
3056 let git = Git::with_runner(&rec);
3057 let out = git
3058 .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
3059 .await
3060 .expect("show_file");
3061 assert_eq!(out, "content");
3062 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
3063 }
3064
3065 #[cfg(not(windows))]
3068 #[tokio::test]
3069 async fn show_file_keeps_backslashes_on_unix() {
3070 let rec = RecordingRunner::replying(Reply::ok("content\n"));
3071 let git = Git::with_runner(&rec);
3072 git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
3073 .await
3074 .expect("show_file");
3075 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
3076 }
3077
3078 #[tokio::test]
3080 async fn config_get_maps_exit_codes() {
3081 let set =
3082 Git::with_runner(ScriptedRunner::new().on(["config", "--get"], Reply::ok("Alice\n")));
3083 assert_eq!(
3084 set.config_get(Path::new("."), "user.name").await.unwrap(),
3085 Some("Alice".to_string())
3086 );
3087 let unset =
3088 Git::with_runner(ScriptedRunner::new().on(["config", "--get"], Reply::fail(1, "")));
3089 assert_eq!(
3090 unset.config_get(Path::new("."), "user.name").await.unwrap(),
3091 None
3092 );
3093 let multi = Git::with_runner(
3095 ScriptedRunner::new().on(["config", "--get"], Reply::fail(2, "multiple values")),
3096 );
3097 assert!(
3098 multi
3099 .config_get(Path::new("."), "remote.all")
3100 .await
3101 .is_err()
3102 );
3103 }
3104
3105 #[tokio::test]
3106 async fn blame_builds_rev_before_pathspec_separator() {
3107 let rec = RecordingRunner::replying(Reply::ok(""));
3108 let git = Git::with_runner(&rec);
3109 git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
3110 .await
3111 .unwrap();
3112 git.blame(Path::new("/r"), "src/lib.rs", None)
3113 .await
3114 .unwrap();
3115 let calls = rec.calls();
3116 assert_eq!(
3117 calls[0].args_str(),
3118 ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
3119 );
3120 assert_eq!(
3121 calls[1].args_str(),
3122 ["blame", "--line-porcelain", "--", "src/lib.rs"]
3123 );
3124 }
3125
3126 #[tokio::test]
3128 async fn sequencer_methods_suppress_editors() {
3129 let rec = RecordingRunner::replying(Reply::ok(""));
3130 let git = Git::with_runner(&rec);
3131 git.revert(Path::new("/r"), "abc").await.unwrap();
3132 git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
3133 git.rebase_skip(Path::new("/r")).await.unwrap();
3134 let calls = rec.calls();
3135 assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
3136 assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
3137 assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
3138 for call in &calls {
3139 assert!(
3140 call.envs
3141 .iter()
3142 .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
3143 "editor suppressed on {:?}",
3144 call.args_str()
3145 );
3146 }
3147 }
3148
3149 #[tokio::test]
3150 async fn remote_add_and_set_url_build_args() {
3151 let rec = RecordingRunner::replying(Reply::ok(""));
3152 let git = Git::with_runner(&rec);
3153 git.remote_add(Path::new("/r"), "up", "https://x/y.git")
3154 .await
3155 .unwrap();
3156 git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
3157 .await
3158 .unwrap();
3159 let calls = rec.calls();
3160 assert_eq!(
3161 calls[0].args_str(),
3162 ["remote", "add", "up", "https://x/y.git"]
3163 );
3164 assert_eq!(
3165 calls[1].args_str(),
3166 ["remote", "set-url", "up", "https://x/z.git"]
3167 );
3168 }
3169
3170 #[tokio::test]
3172 async fn switch_with_stash_round_trips_dirty_tree() {
3173 let rec = RecordingRunner::new(
3174 ScriptedRunner::new()
3175 .on(["status"], Reply::ok(" M a.rs\0"))
3176 .on(["stash", "push"], Reply::ok(""))
3177 .on(["checkout"], Reply::ok(""))
3178 .on(["stash", "pop"], Reply::ok("")),
3179 );
3180 let git = Git::with_runner(&rec);
3181 git.switch_with_stash(Path::new("/r"), "feature")
3182 .await
3183 .expect("switch");
3184 let calls = rec.calls();
3185 assert_eq!(calls.len(), 4);
3186 assert_eq!(
3187 calls[1].args_str(),
3188 ["stash", "push", "--include-untracked"]
3189 );
3190 assert_eq!(calls[2].args_str(), ["checkout", "feature"]);
3191 assert_eq!(calls[3].args_str(), ["stash", "pop"]);
3192 }
3193
3194 #[tokio::test]
3197 async fn switch_with_stash_skips_stash_on_clean_tree() {
3198 let rec = RecordingRunner::new(
3199 ScriptedRunner::new()
3200 .on(["status"], Reply::ok(""))
3201 .on(["checkout"], Reply::ok("")),
3202 );
3203 let git = Git::with_runner(&rec);
3204 git.switch_with_stash(Path::new("/r"), "feature")
3205 .await
3206 .expect("switch");
3207 let calls = rec.calls();
3208 assert_eq!(calls.len(), 2);
3209 assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
3210 }
3211
3212 #[tokio::test]
3215 async fn switch_with_stash_restores_on_checkout_failure() {
3216 let rec = RecordingRunner::new(
3217 ScriptedRunner::new()
3218 .on(["status"], Reply::ok(" M a.rs\0"))
3219 .on(["stash", "push"], Reply::ok(""))
3220 .on(["checkout"], Reply::fail(1, "error: pathspec 'nope'"))
3221 .on(["stash", "pop"], Reply::ok("")),
3222 );
3223 let git = Git::with_runner(&rec);
3224 let err = git
3225 .switch_with_stash(Path::new("/r"), "nope")
3226 .await
3227 .expect_err("checkout error must surface");
3228 assert!(matches!(err, Error::Exit { .. }));
3229 let calls = rec.calls();
3230 assert_eq!(calls.len(), 4);
3231 assert_eq!(calls[3].args_str(), ["stash", "pop"], "restoring pop ran");
3232 }
3233
3234 #[tokio::test]
3237 async fn fetch_from_builds_args_and_retries() {
3238 let rec = RecordingRunner::replying(Reply::ok(""));
3239 let git = Git::with_runner(&rec);
3240 git.fetch_from(Path::new("/r"), "upstream")
3241 .await
3242 .expect("fetch_from");
3243 let call = rec.only_call();
3244 assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
3245 assert!(call.envs.iter().any(|(k, v)| {
3246 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3247 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3248 }));
3249
3250 let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
3251 let git = Git::with_runner(&failing);
3252 assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
3253 assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
3254 }
3255
3256 #[cfg(feature = "mock")]
3259 #[tokio::test]
3260 async fn consumer_mocks_the_interface() {
3261 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
3262 git.current_branch(Path::new(".")).await.unwrap() == want
3263 }
3264 let mut mock = MockGitApi::new();
3265 mock.expect_current_branch()
3266 .returning(|_| Ok("main".to_string()));
3267 assert!(on_branch(&mock, "main").await);
3268 }
3269}
3270
3271#[doc = include_str!("../docs/git.md")]
3273#[allow(rustdoc::broken_intra_doc_links)]
3274pub mod guide {
3275 #[doc = include_str!("../docs/security.md")]
3276 #[allow(rustdoc::broken_intra_doc_links)]
3277 pub mod security {}
3278 #[doc = include_str!("../docs/conflicts.md")]
3279 #[allow(rustdoc::broken_intra_doc_links)]
3280 pub mod conflicts {}
3281}