1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::{Path, PathBuf};
108use std::sync::Arc;
109use std::time::Duration;
110
111use processkit::{Command, ProcessRunner};
112pub use processkit::{Error, ProcessResult, Result};
116pub use processkit::CancellationToken;
119
120pub mod conflict;
121mod parse;
122pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
123pub use vcs_diff::{
127 ChangeKind, DiffLine, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
128};
129pub use vcs_cli_support::{
132 Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, RetryPolicy,
133 Secret, StaticCredential, is_lock_contention, is_merge_conflict, is_nothing_to_commit,
134 is_transient_fetch_error, provider_fn,
135};
136use vcs_cli_support::{ManagedClient, git_credential_helper};
137
138pub const BINARY: &str = "git";
140
141#[derive(Debug, Clone)]
145#[non_exhaustive]
146pub enum DiffSpec {
147 WorkingTree,
150 Rev(String),
152}
153
154#[derive(Debug, Clone)]
159#[non_exhaustive]
160pub struct WorktreeAdd {
161 pub path: PathBuf,
163 pub new_branch: Option<String>,
166 pub commitish: Option<String>,
168 pub no_checkout: bool,
171}
172
173impl WorktreeAdd {
174 pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
177 Self {
178 path: path.into(),
179 new_branch: None,
180 commitish: Some(commitish.into()),
181 no_checkout: false,
182 }
183 }
184
185 pub fn create_branch(
188 path: impl Into<PathBuf>,
189 name: impl Into<String>,
190 commitish: impl Into<String>,
191 ) -> Self {
192 Self {
193 path: path.into(),
194 new_branch: Some(name.into()),
195 commitish: Some(commitish.into()),
196 no_checkout: false,
197 }
198 }
199
200 pub fn no_checkout(mut self) -> Self {
203 self.no_checkout = true;
204 self
205 }
206}
207
208#[derive(Debug, Clone)]
213#[non_exhaustive]
214pub struct GitPush {
215 pub remote: String,
217 pub refspec: String,
219 pub set_upstream: bool,
221}
222
223impl GitPush {
224 pub fn branch(name: impl Into<String>) -> Self {
226 Self {
227 remote: "origin".to_string(),
228 refspec: name.into(),
229 set_upstream: false,
230 }
231 }
232
233 pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
236 Self {
237 remote: "origin".to_string(),
238 refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
239 set_upstream: false,
240 }
241 }
242
243 pub fn remote(mut self, remote: impl Into<String>) -> Self {
245 self.remote = remote.into();
246 self
247 }
248
249 pub fn set_upstream(mut self) -> Self {
251 self.set_upstream = true;
252 self
253 }
254}
255
256#[derive(Debug, Clone, Default)]
261#[non_exhaustive]
262pub struct CloneSpec {
263 pub branch: Option<String>,
265 pub depth: Option<u32>,
269 pub bare: bool,
271}
272
273impl CloneSpec {
274 pub fn new() -> Self {
276 Self::default()
277 }
278
279 pub fn branch(mut self, branch: impl Into<String>) -> Self {
281 self.branch = Some(branch.into());
282 self
283 }
284
285 pub fn depth(mut self, depth: u32) -> Self {
288 self.depth = Some(depth);
289 self
290 }
291
292 pub fn bare(mut self) -> Self {
294 self.bare = true;
295 self
296 }
297}
298
299#[derive(Debug, Clone)]
304#[non_exhaustive]
305pub struct CommitPaths {
306 pub paths: Vec<PathBuf>,
308 pub message: String,
310 pub amend: bool,
312}
313
314impl CommitPaths {
315 pub fn new(
318 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
319 message: impl Into<String>,
320 ) -> Self {
321 Self {
322 paths: paths.into_iter().map(Into::into).collect(),
323 message: message.into(),
324 amend: false,
325 }
326 }
327
328 pub fn amend(mut self) -> Self {
330 self.amend = true;
331 self
332 }
333}
334
335#[derive(Debug, Clone)]
340#[non_exhaustive]
341pub struct MergeCommit {
342 pub branch: String,
344 pub no_ff: bool,
347 pub message: Option<String>,
350}
351
352impl MergeCommit {
353 pub fn branch(name: impl Into<String>) -> Self {
356 Self {
357 branch: name.into(),
358 no_ff: false,
359 message: None,
360 }
361 }
362
363 pub fn no_ff(mut self) -> Self {
366 self.no_ff = true;
367 self
368 }
369
370 pub fn message(mut self, m: impl Into<String>) -> Self {
372 self.message = Some(m.into());
373 self
374 }
375}
376
377#[derive(Debug, Clone)]
382#[non_exhaustive]
383pub struct MergeNoCommit {
384 pub branch: String,
386 pub squash: bool,
389 pub no_ff: bool,
392}
393
394impl MergeNoCommit {
395 pub fn branch(name: impl Into<String>) -> Self {
397 Self {
398 branch: name.into(),
399 squash: false,
400 no_ff: false,
401 }
402 }
403
404 pub fn squash(mut self) -> Self {
406 self.squash = true;
407 self
408 }
409
410 pub fn no_ff(mut self) -> Self {
413 self.no_ff = true;
414 self
415 }
416}
417
418#[derive(Debug, Clone)]
423#[non_exhaustive]
424pub struct AnnotatedTag {
425 pub name: String,
427 pub message: String,
429 pub rev: Option<String>,
431}
432
433impl AnnotatedTag {
434 pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
437 Self {
438 name: name.into(),
439 message: message.into(),
440 rev: None,
441 }
442 }
443
444 pub fn rev(mut self, r: impl Into<String>) -> Self {
446 self.rev = Some(r.into());
447 self
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Hash)]
461pub struct RefName(String);
462
463impl RefName {
464 pub fn new(name: impl Into<String>) -> Result<Self> {
466 let name = name.into();
467 let bad = name.is_empty()
468 || name.starts_with('-')
469 || name.starts_with('.')
470 || name.ends_with('/')
471 || name.ends_with(".lock")
472 || name.contains("..")
473 || name
474 .chars()
475 .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
476 if bad {
477 return Err(Error::Spawn {
478 program: BINARY.to_string(),
479 source: std::io::Error::new(
480 std::io::ErrorKind::InvalidInput,
481 format!("invalid git reference name: {name:?}"),
482 ),
483 });
484 }
485 Ok(RefName(name))
486 }
487
488 pub fn as_str(&self) -> &str {
490 &self.0
491 }
492}
493
494impl std::fmt::Display for RefName {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 f.write_str(&self.0)
497 }
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Hash)]
506pub struct RevSpec(String);
507
508impl RevSpec {
509 pub fn new(rev: impl Into<String>) -> Result<Self> {
511 let rev = rev.into();
512 reject_flag_like("revision", &rev)?;
513 Ok(RevSpec(rev))
514 }
515
516 pub fn as_str(&self) -> &str {
518 &self.0
519 }
520}
521
522impl std::fmt::Display for RevSpec {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 f.write_str(&self.0)
525 }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532#[non_exhaustive]
533pub struct GitCapabilities {
534 pub version: GitVersion,
536}
537
538const MIN_SUPPORTED_MAJOR: u64 = 2;
544
545impl GitCapabilities {
546 pub fn is_supported(&self) -> bool {
548 self.version.major >= MIN_SUPPORTED_MAJOR
549 }
550
551 pub fn ensure_supported(&self) -> Result<()> {
554 if self.is_supported() {
555 return Ok(());
556 }
557 Err(Error::Spawn {
558 program: BINARY.to_string(),
559 source: std::io::Error::new(
560 std::io::ErrorKind::Unsupported,
561 format!(
562 "vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
563 found {}",
564 self.version
565 ),
566 ),
567 })
568 }
569}
570
571#[cfg_attr(feature = "mock", mockall::automock)]
583#[async_trait::async_trait]
584pub trait GitApi: Send + Sync {
585 async fn run(&self, args: &[String]) -> Result<String>;
588 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
591 async fn version(&self) -> Result<String>;
593 async fn capabilities(&self) -> Result<GitCapabilities>;
597 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
599 async fn status_text(&self, dir: &Path) -> Result<String>;
602 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
606 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
611 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
614 async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
622 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
624 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>>;
631 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
633 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
636 async fn init(&self, dir: &Path) -> Result<()>;
638 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
640 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
642 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
644 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
646 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
648 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
651 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
654 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
657 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
661
662 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
667 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
669 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
672 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
675 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
677 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
682 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
684 async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
687 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
690
691 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
695 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
698 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
700 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
702 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
704 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
706 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
709 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
712 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
714
715 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
719 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
722 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
724
725 async fn fetch(&self, dir: &Path) -> Result<()>;
730 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
734 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()>;
738 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
740 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
742 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
746 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
755 async fn merge_abort(&self, dir: &Path) -> Result<()>;
757 async fn merge_continue(&self, dir: &Path) -> Result<()>;
759 async fn reset_merge(&self, dir: &Path) -> Result<()>;
765 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
767 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
770 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
772 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
775 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
778 async fn stash_pop(&self, dir: &Path) -> Result<()>;
780
781 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
785 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
787 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
789 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
791 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
793
794 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
799 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
801 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
804 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
806 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
808 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
813 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
817 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
824 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
826 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
828 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
831
832 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
837 async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
839 async fn rebase_skip(&self, dir: &Path) -> Result<()>;
843}
844
845pub struct Git<R: ProcessRunner = processkit::JobRunner> {
851 core: ManagedClient<R>,
852}
853
854impl Git<processkit::JobRunner> {
855 pub fn new() -> Self {
857 Self {
858 core: ManagedClient::new(BINARY),
859 }
860 }
861}
862
863impl Default for Git<processkit::JobRunner> {
864 fn default() -> Self {
865 Self::new()
866 }
867}
868
869impl<R: ProcessRunner> Git<R> {
870 pub fn with_runner(runner: R) -> Self {
872 Self {
873 core: ManagedClient::with_runner(BINARY, runner),
874 }
875 }
876
877 pub fn default_timeout(mut self, timeout: Duration) -> Self {
879 self.core = self.core.default_timeout(timeout);
880 self
881 }
882
883 pub fn default_env(
885 mut self,
886 key: impl AsRef<std::ffi::OsStr>,
887 value: impl AsRef<std::ffi::OsStr>,
888 ) -> Self {
889 self.core = self.core.default_env(key, value);
890 self
891 }
892
893 pub fn default_env_remove(mut self, key: impl AsRef<std::ffi::OsStr>) -> Self {
895 self.core = self.core.default_env_remove(key);
896 self
897 }
898
899 pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
901 self.core = self.core.default_cancel_on(token);
902 self
903 }
904
905 pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
912 self.core = self.core.with_retry(policy);
913 self
914 }
915
916 #[must_use]
924 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
925 self.core = self.core.with_credentials(provider);
926 self
927 }
928
929 #[must_use]
936 pub fn with_token(self, token: impl Into<Secret>) -> Self {
937 self.with_credentials(Arc::new(StaticCredential::token(token)))
938 }
939
940 #[must_use]
944 pub fn with_env_token(self, var: impl Into<String>) -> Self {
945 self.with_credentials(Arc::new(EnvToken::new(var)))
946 }
947
948 async fn remote_credentials(&self) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
953 match self
954 .core
955 .resolve_credential(CredentialService::Git, None)
956 .await?
957 {
958 Some(cred) => {
959 let helper = git_credential_helper(&cred);
960 Ok((helper.config_args, helper.env))
961 }
962 None => Ok((Vec::new(), Vec::new())),
963 }
964 }
965}
966
967fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
970 envs.iter()
971 .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
972}
973
974#[async_trait::async_trait]
975impl<R: ProcessRunner> GitApi for Git<R> {
976 async fn run(&self, args: &[String]) -> Result<String> {
977 self.core.run(args).await
978 }
979
980 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
981 self.core.output(args).await
982 }
983
984 async fn version(&self) -> Result<String> {
985 self.core.run(["--version"]).await
986 }
987
988 async fn capabilities(&self) -> Result<GitCapabilities> {
989 let raw = self.version().await?;
990 let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
991 program: BINARY.to_string(),
992 message: format!("unrecognisable `git --version` output: {raw:?}"),
993 })?;
994 Ok(GitCapabilities { version })
995 }
996
997 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
998 self.core
999 .parse(
1000 self.core
1001 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1002 parse::parse_porcelain,
1003 )
1004 .await
1005 }
1006
1007 async fn status_text(&self, dir: &Path) -> Result<String> {
1008 self.core
1009 .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1010 .await
1011 }
1012
1013 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1014 self.core
1020 .parse(
1021 self.core
1022 .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1023 .env("GIT_OPTIONAL_LOCKS", "0"),
1024 parse::parse_porcelain_v2,
1025 )
1026 .await
1027 }
1028
1029 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1030 self.core
1031 .parse(
1032 self.core.command_in(
1033 dir,
1034 ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1035 ),
1036 parse::parse_porcelain,
1037 )
1038 .await
1039 }
1040
1041 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
1042 self.core
1044 .parse(
1045 self.core
1046 .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1047 parse::parse_nul_paths,
1048 )
1049 .await
1050 }
1051
1052 async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1053 let res = self
1062 .core
1063 .output(
1064 self.core
1065 .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1066 )
1067 .await?;
1068 match res.code() {
1069 Some(0) => Ok(Some(res.stdout().trim().to_string())),
1070 Some(1) => Ok(None), _ => {
1072 res.ensure_success()?;
1073 Ok(None) }
1075 }
1076 }
1077
1078 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1079 self.core
1082 .parse(
1083 self.core.command_in(dir, ["branch", "--no-column"]),
1084 parse::parse_branches,
1085 )
1086 .await
1087 }
1088
1089 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>> {
1090 reject_flag_like("revspec", revspec)?;
1091 let n = format!("-n{max}");
1092 self.core
1093 .parse(
1094 self.core.command_in(
1095 dir,
1096 [
1097 "log",
1098 revspec,
1099 n.as_str(),
1100 "-z",
1101 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1102 ],
1103 ),
1104 parse::parse_log,
1105 )
1106 .await
1107 }
1108
1109 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
1110 reject_flag_like("revision", rev)?;
1111 self.core
1112 .run(self.core.command_in(dir, ["rev-parse", rev]))
1113 .await
1114 }
1115
1116 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
1117 reject_flag_like("revision", rev)?;
1118 self.core
1119 .run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
1120 .await
1121 }
1122
1123 async fn init(&self, dir: &Path) -> Result<()> {
1124 self.core
1125 .run_unit(self.core.command_in(dir, ["init"]))
1126 .await
1127 }
1128
1129 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1130 let mut command = self.core.command_in(dir, ["add", "--"]);
1132 for path in paths {
1133 command = command.arg(path);
1134 }
1135 self.core.run_unit(command).await
1136 }
1137
1138 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1139 self.core
1141 .run_unit(c_locale(
1142 self.core.command_in(dir, ["commit", "-m", message]),
1143 ))
1144 .await
1145 }
1146
1147 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
1148 reject_flag_like("branch name", name)?;
1149 self.core
1150 .run_unit(self.core.command_in(dir, ["branch", name]))
1151 .await
1152 }
1153
1154 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
1155 reject_flag_like("reference", reference)?;
1156 self.core
1157 .run_unit(self.core.command_in(dir, ["checkout", reference]))
1158 .await
1159 }
1160
1161 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1162 reject_flag_like("commit", commit)?;
1163 self.core
1164 .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1165 .await
1166 }
1167
1168 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1169 let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1173 if spec.amend {
1174 command = command.arg("--amend");
1175 }
1176 command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1177 for path in &spec.paths {
1178 command = command.arg(path);
1179 }
1180 self.core.run_unit(command).await
1181 }
1182
1183 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1184 self.core
1185 .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1186 .await
1187 }
1188
1189 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1190 Ok(!self
1194 .core
1195 .probe(
1196 self.core
1197 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1198 )
1199 .await?)
1200 }
1201
1202 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1203 self.core
1206 .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1207 .await
1208 }
1209
1210 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1211 Ok(PathBuf::from(
1212 self.core
1213 .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1214 .await?,
1215 ))
1216 }
1217
1218 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1219 Ok(PathBuf::from(
1220 self.core
1221 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1222 .await?,
1223 ))
1224 }
1225
1226 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1227 reject_flag_like("revision", rev)?;
1228 let spec = format!("{rev}^{{commit}}");
1230 self.core
1231 .run(
1232 self.core
1233 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1234 )
1235 .await
1236 }
1237
1238 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1239 let res = self
1245 .core
1246 .output(
1247 self.core
1248 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1249 )
1250 .await?;
1251 match res.code() {
1252 Some(0) => {
1253 let out = res.stdout().trim();
1256 Ok(Some(
1257 out.strip_prefix("refs/remotes/origin/")
1258 .unwrap_or(out)
1259 .to_string(),
1260 ))
1261 }
1262 Some(1) => Ok(None), _ => {
1264 res.ensure_success()?;
1265 Ok(None) }
1267 }
1268 }
1269
1270 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1271 let refname = format!("refs/heads/{name}");
1272 self.core
1274 .probe(
1275 self.core
1276 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1277 )
1278 .await
1279 }
1280
1281 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1282 let refname = format!("refs/heads/{name}");
1292 let (pre, envs) = self.remote_credentials().await?;
1293 let mut args: Vec<String> = pre;
1294 args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1295 let cmd = apply_secret_env(
1296 self.core
1297 .command_in(dir, &args)
1298 .env("GIT_TERMINAL_PROMPT", "0")
1299 .timeout(Duration::from_secs(10)),
1300 &envs,
1301 );
1302 let res = self.core.output(cmd).await?;
1303 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1304 }
1305
1306 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1307 reject_flag_like("remote name", remote)?;
1308 self.core
1309 .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1310 .await
1311 }
1312
1313 async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1314 let res = self
1322 .core
1323 .output(self.core.command_in(
1324 dir,
1325 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1326 ))
1327 .await?;
1328 match res.code() {
1329 Some(0) => {
1330 let name = res.stdout().trim();
1331 Ok((!name.is_empty()).then(|| name.to_string()))
1332 }
1333 Some(_) => Ok(None), None => {
1335 res.ensure_success()?; Ok(None) }
1338 }
1339 }
1340
1341 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1342 reject_flag_like("remote name", remote)?;
1343 let (pre, envs) = self.remote_credentials().await?;
1347 let mut args: Vec<String> = pre;
1348 args.extend(["ls-remote", "--heads", remote].map(String::from));
1349 let cmd = apply_secret_env(
1350 self.core
1351 .command_in(dir, &args)
1352 .env("GIT_TERMINAL_PROMPT", "0"),
1353 &envs,
1354 );
1355 self.core.parse(cmd, parse::parse_ls_remote_heads).await
1356 }
1357
1358 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
1359 reject_flag_like("branch", branch)?;
1360 reject_flag_like("target", target)?;
1361 let out = self
1365 .core
1366 .run(
1367 self.core
1368 .command_in(dir, ["branch", "--merged", target, "--no-column"]),
1369 )
1370 .await?;
1371 Ok(out
1375 .lines()
1376 .filter_map(|line| line.get(2..))
1377 .any(|b| b == branch))
1378 }
1379
1380 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1381 reject_flag_like("branch name", branch)?;
1382 let flag = format!("--set-upstream-to={upstream}");
1383 self.core
1384 .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1385 .await
1386 }
1387
1388 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1389 reject_flag_like("branch name", name)?;
1390 let flag = if force { "-D" } else { "-d" };
1391 self.core
1392 .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1393 .await
1394 }
1395
1396 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1397 reject_flag_like("branch name", old)?;
1398 reject_flag_like("branch name", new)?;
1399 self.core
1400 .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1401 .await
1402 }
1403
1404 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1405 reject_flag_like("range", range)?;
1406 self.core
1407 .try_parse(
1408 self.core.command_in(dir, ["rev-list", "--count", range]),
1409 |s| {
1410 s.trim().parse::<usize>().map_err(|e| Error::Parse {
1411 program: BINARY.to_string(),
1412 message: e.to_string(),
1413 })
1414 },
1415 )
1416 .await
1417 }
1418
1419 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1420 reject_flag_like("range", range)?;
1421 self.core
1423 .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
1424 .await
1425 }
1426
1427 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1428 reject_flag_like("range", range)?;
1429 self.core
1430 .parse(
1431 self.core.command_in(dir, ["diff", "--shortstat", range]),
1432 parse::parse_shortstat,
1433 )
1434 .await
1435 }
1436
1437 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1438 let target = match spec {
1442 DiffSpec::WorkingTree => {
1443 if self.is_unborn(dir).await? {
1447 EMPTY_TREE.to_string()
1448 } else {
1449 "HEAD".to_string()
1450 }
1451 }
1452 DiffSpec::Rev(rev) => {
1453 reject_flag_like("revision", &rev)?;
1454 rev
1455 }
1456 };
1457 self.core
1462 .run(self.core.command_in(
1463 dir,
1464 [
1465 "diff",
1466 target.as_str(),
1467 "--no-color",
1468 "--no-ext-diff",
1469 "-M",
1470 "--src-prefix=a/",
1471 "--dst-prefix=b/",
1472 ],
1473 ))
1474 .await
1475 }
1476
1477 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1478 let text = self.diff_text(dir, spec).await?;
1479 Ok(parse_diff(&text))
1480 }
1481
1482 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1483 self.core
1485 .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1486 .await
1487 }
1488
1489 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1490 let git_dir = self.resolved_git_dir(dir).await?;
1491 Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
1492 }
1493
1494 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1495 Ok(self
1496 .resolved_git_dir(dir)
1497 .await?
1498 .join("MERGE_HEAD")
1499 .exists())
1500 }
1501
1502 async fn fetch(&self, dir: &Path) -> Result<()> {
1503 let (pre, envs) = self.remote_credentials().await?;
1511 let mut args: Vec<String> = pre;
1512 args.extend(["fetch", "--quiet"].map(String::from));
1513 let cmd = apply_secret_env(
1514 c_locale(self.core.command_in(dir, &args))
1515 .env("GIT_TERMINAL_PROMPT", "0")
1516 .timeout_grace(FETCH_TIMEOUT_GRACE)
1519 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1520 &envs,
1521 );
1522 self.core.run_unit(cmd).await
1523 }
1524
1525 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1526 reject_flag_like("remote", remote)?;
1530 let (pre, envs) = self.remote_credentials().await?;
1533 let mut args: Vec<String> = pre;
1534 args.extend(["fetch", "--quiet", remote].map(String::from));
1535 let cmd = apply_secret_env(
1536 c_locale(self.core.command_in(dir, &args))
1537 .env("GIT_TERMINAL_PROMPT", "0")
1538 .timeout_grace(FETCH_TIMEOUT_GRACE)
1539 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1540 &envs,
1541 );
1542 self.core.run_unit(cmd).await
1543 }
1544
1545 async fn fetch_remote_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1546 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1547 let (pre, envs) = self.remote_credentials().await?;
1548 let mut args: Vec<String> = pre;
1549 args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
1550 let cmd = apply_secret_env(
1551 c_locale(self.core.command_in(dir, &args))
1552 .env("GIT_TERMINAL_PROMPT", "0")
1553 .timeout_grace(FETCH_TIMEOUT_GRACE)
1554 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1555 &envs,
1556 );
1557 self.core.run_unit(cmd).await
1558 }
1559
1560 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1561 reject_flag_like("remote", &spec.remote)?;
1562 reject_flag_like("refspec", &spec.refspec)?;
1563 let (pre, envs) = self.remote_credentials().await?;
1564 let mut args: Vec<String> = pre;
1565 args.push("push".to_string());
1566 if spec.set_upstream {
1567 args.push("-u".to_string());
1568 }
1569 args.push(spec.remote.clone());
1570 args.push(spec.refspec.clone());
1571 let cmd = apply_secret_env(
1572 self.core
1573 .command_in(dir, &args)
1574 .env("GIT_TERMINAL_PROMPT", "0")
1575 .timeout_grace(FETCH_TIMEOUT_GRACE),
1580 &envs,
1581 );
1582 self.core.run_unit(cmd).await
1583 }
1584
1585 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1586 reject_flag_like("branch", branch)?;
1587 self.core
1588 .run_unit(self.core.command_in(dir, ["merge", "--squash", branch]))
1589 .await
1590 }
1591
1592 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1593 reject_flag_like("branch", &spec.branch)?;
1594 let mut args: Vec<&str> = vec!["merge"];
1595 if spec.no_ff {
1596 args.push("--no-ff");
1597 }
1598 if let Some(msg) = spec.message.as_deref() {
1599 args.push("-m");
1600 args.push(msg);
1601 } else {
1602 args.push("--no-edit");
1605 }
1606 args.push(&spec.branch);
1607 self.core
1609 .run_unit(c_locale(self.core.command_in(dir, args)))
1610 .await
1611 }
1612
1613 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1614 reject_flag_like("branch", &spec.branch)?;
1615 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1616 if spec.squash {
1619 args.push("--squash");
1620 } else if spec.no_ff {
1621 args.push("--no-ff");
1622 }
1623 args.push(&spec.branch);
1624 self.core
1626 .run_unit(c_locale(self.core.command_in(dir, args)))
1627 .await
1628 }
1629
1630 async fn merge_abort(&self, dir: &Path) -> Result<()> {
1631 self.core
1632 .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1633 .await
1634 }
1635
1636 async fn merge_continue(&self, dir: &Path) -> Result<()> {
1637 self.core
1642 .run_unit(no_editor(c_locale(
1643 self.core.command_in(dir, ["commit", "--no-edit"]),
1644 )))
1645 .await
1646 }
1647
1648 async fn reset_merge(&self, dir: &Path) -> Result<()> {
1649 self.core
1650 .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1651 .await
1652 }
1653
1654 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1655 reject_flag_like("revision", rev)?;
1656 self.core
1657 .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1658 .await
1659 }
1660
1661 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1662 reject_flag_like("rebase target", onto)?;
1663 self.core
1667 .run_unit(no_editor(c_locale(
1668 self.core.command_in(dir, ["rebase", onto]),
1669 )))
1670 .await
1671 }
1672
1673 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1674 self.core
1675 .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1676 .await
1677 }
1678
1679 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1680 self.core
1681 .run_unit(no_editor(c_locale(
1682 self.core.command_in(dir, ["rebase", "--continue"]),
1683 )))
1684 .await
1685 }
1686
1687 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1688 let mut command = self.core.command_in(dir, ["stash", "push"]);
1689 if include_untracked {
1690 command = command.arg("--include-untracked");
1691 }
1692 self.core.run_unit(command).await
1693 }
1694
1695 async fn stash_pop(&self, dir: &Path) -> Result<()> {
1696 self.core
1697 .run_unit(self.core.command_in(dir, ["stash", "pop"]))
1698 .await
1699 }
1700
1701 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1702 self.core
1703 .parse(
1704 self.core
1705 .command_in(dir, ["worktree", "list", "--porcelain"]),
1706 parse::parse_worktree_porcelain,
1707 )
1708 .await
1709 }
1710
1711 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1712 if let Some(name) = spec.new_branch.as_deref() {
1713 reject_flag_like("branch name", name)?;
1714 }
1715 if let Some(commitish) = spec.commitish.as_deref() {
1716 reject_flag_like("commit-ish", commitish)?;
1717 }
1718 let mut command = self.core.command_in(dir, ["worktree", "add"]);
1719 if let Some(name) = spec.new_branch.as_deref() {
1720 command = command.arg("-b").arg(name);
1721 }
1722 if spec.no_checkout {
1723 command = command.arg("--no-checkout");
1724 }
1725 command = command.arg(&spec.path);
1726 if let Some(commitish) = spec.commitish.as_deref() {
1727 command = command.arg(commitish);
1728 }
1729 self.core.run_unit(command).await
1730 }
1731
1732 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1733 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1734 if force {
1735 command = command.arg("--force");
1736 }
1737 command = command.arg(path);
1738 self.core.run_unit(command).await
1739 }
1740
1741 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1742 let command = self
1743 .core
1744 .command_in(dir, ["worktree", "move"])
1745 .arg(from)
1746 .arg(to);
1747 self.core.run_unit(command).await
1748 }
1749
1750 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1751 self.core
1752 .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1753 .await
1754 }
1755
1756 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1757 reject_flag_like("url", url)?;
1761 let (pre, envs) = self.remote_credentials().await?;
1765 let mut initial: Vec<String> = pre;
1766 initial.push("clone".to_string());
1767 let mut command = self.core.command(&initial);
1768 if let Some(branch) = spec.branch.as_deref() {
1769 command = command.arg("--branch").arg(branch);
1770 }
1771 if let Some(depth) = spec.depth {
1772 command = command.arg("--depth").arg(depth.to_string());
1773 }
1774 if spec.bare {
1775 command = command.arg("--bare");
1776 }
1777 let command = apply_secret_env(
1778 command
1779 .arg(url)
1780 .arg(dest)
1781 .env("GIT_TERMINAL_PROMPT", "0")
1782 .timeout_grace(FETCH_TIMEOUT_GRACE),
1786 &envs,
1787 );
1788 self.core.run_unit(command).await
1789 }
1790
1791 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1792 reject_flag_like("tag name", name)?;
1793 if let Some(rev) = rev.as_deref() {
1794 reject_flag_like("revision", rev)?;
1795 }
1796 let mut args = vec!["tag", name];
1797 if let Some(rev) = rev.as_deref() {
1798 args.push(rev);
1799 }
1800 self.core.run_unit(self.core.command_in(dir, args)).await
1801 }
1802
1803 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1804 reject_flag_like("tag name", &spec.name)?;
1805 if let Some(rev) = spec.rev.as_deref() {
1806 reject_flag_like("revision", rev)?;
1807 }
1808 let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1809 if let Some(rev) = spec.rev.as_deref() {
1810 args.push(rev);
1811 }
1812 self.core.run_unit(self.core.command_in(dir, args)).await
1813 }
1814
1815 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1816 let out = self
1819 .core
1820 .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1821 .await?;
1822 Ok(out.lines().map(str::to_string).collect())
1823 }
1824
1825 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1826 reject_flag_like("tag name", name)?;
1827 self.core
1828 .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1829 .await
1830 }
1831
1832 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1833 reject_flag_like("revision", rev)?;
1836 #[cfg(windows)]
1841 let path = path.replace('\\', "/");
1842 let spec = format!("{rev}:{path}");
1843 self.core
1844 .run(self.core.command_in(dir, ["show", spec.as_str()]))
1845 .await
1846 }
1847
1848 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
1849 reject_flag_like("config key", key)?;
1850 let res = self
1851 .core
1852 .output(self.core.command_in(dir, ["config", "--get", key]))
1853 .await?;
1854 match res.code() {
1855 Some(1) => Ok(None),
1857 Some(0) => Ok(Some(
1862 res.stdout().trim_end_matches(['\r', '\n']).to_string(),
1863 )),
1864 _ => {
1865 res.ensure_success()?;
1866 Ok(None) }
1868 }
1869 }
1870
1871 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
1872 reject_flag_like("config key", key)?;
1873 self.core
1874 .run_unit(self.core.command_in(dir, ["config", key, value]))
1875 .await
1876 }
1877
1878 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1879 reject_flag_like("remote name", name)?;
1880 reject_flag_like("url", url)?;
1881 self.core
1882 .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
1883 .await
1884 }
1885
1886 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1887 reject_flag_like("remote name", name)?;
1888 reject_flag_like("url", url)?;
1889 self.core
1890 .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
1891 .await
1892 }
1893
1894 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
1895 let mut args = vec!["blame", "--line-porcelain"];
1896 if let Some(rev) = rev.as_deref() {
1897 reject_flag_like("revision", rev)?;
1900 args.push(rev);
1901 }
1902 args.push("--");
1903 args.push(path);
1904 self.core
1905 .parse(
1906 self.core.command_in(dir, args),
1907 parse::parse_blame_porcelain,
1908 )
1909 .await
1910 }
1911
1912 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
1913 reject_flag_like("revision", rev)?;
1914 self.core
1917 .run_unit(no_editor(c_locale(
1918 self.core.command_in(dir, ["cherry-pick", rev]),
1919 )))
1920 .await
1921 }
1922
1923 async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
1924 reject_flag_like("revision", rev)?;
1925 self.core
1926 .run_unit(no_editor(c_locale(
1927 self.core.command_in(dir, ["revert", "--no-edit", rev]),
1928 )))
1929 .await
1930 }
1931
1932 async fn rebase_skip(&self, dir: &Path) -> Result<()> {
1933 self.core
1934 .run_unit(no_editor(c_locale(
1935 self.core.command_in(dir, ["rebase", "--skip"]),
1936 )))
1937 .await
1938 }
1939}
1940
1941pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1952
1953const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
1956const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
1957const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
1958
1959fn no_editor(cmd: processkit::Command) -> processkit::Command {
1963 cmd.env("GIT_EDITOR", "true")
1964 .env("GIT_SEQUENCE_EDITOR", "true")
1965}
1966
1967fn c_locale(cmd: processkit::Command) -> processkit::Command {
1973 cmd.env("LC_ALL", "C")
1974}
1975
1976fn reject_flag_like(what: &str, value: &str) -> Result<()> {
1980 vcs_cli_support::reject_flag_like(BINARY, what, value)
1981}
1982
1983impl<R: ProcessRunner> Git<R> {
1984 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1989 self.core.run(args).await
1990 }
1991
1992 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1995 self.core.output(args).await
1996 }
1997
1998 pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
2003 GitAt { git: self, dir }
2004 }
2005
2006 pub fn harden(self) -> Self {
2060 let removed = [
2061 "GIT_DIR",
2063 "GIT_WORK_TREE",
2064 "GIT_INDEX_FILE",
2065 "GIT_OBJECT_DIRECTORY",
2066 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
2067 "GIT_NAMESPACE",
2068 "GIT_CEILING_DIRECTORIES",
2069 "GIT_CONFIG_PARAMETERS",
2070 "GIT_CONFIG_GLOBAL",
2071 "GIT_CONFIG_SYSTEM",
2072 "GIT_SSH_COMMAND",
2074 "GIT_SSH",
2075 "GIT_ASKPASS",
2076 "GIT_EXTERNAL_DIFF",
2077 "GIT_PAGER",
2078 "GIT_EDITOR",
2079 "GIT_SEQUENCE_EDITOR",
2080 ];
2081 let mut hardened = self;
2082 for key in removed {
2083 hardened = hardened.default_env_remove(key);
2084 }
2085 hardened
2086 .default_env("GIT_CONFIG_NOSYSTEM", "1")
2087 .default_env("GIT_TERMINAL_PROMPT", "0")
2088 .default_env("GIT_CONFIG_COUNT", "3")
2093 .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
2094 .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
2101 .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
2102 .default_env("GIT_CONFIG_VALUE_1", "false")
2103 .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
2109 .default_env("GIT_CONFIG_VALUE_2", "")
2110 }
2111
2112 pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
2130 if self.status(dir).await?.is_empty() {
2133 return self.checkout(dir, branch).await;
2134 }
2135 self.stash_push(dir, true).await?;
2136 match self.checkout(dir, branch).await {
2137 Ok(()) => self.stash_pop(dir).await,
2138 Err(err) => {
2139 let _ = self.stash_pop(dir).await;
2143 Err(err)
2144 }
2145 }
2146 }
2147
2148 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
2151 let git_dir = PathBuf::from(
2152 self.core
2153 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
2154 .await?,
2155 );
2156 Ok(if git_dir.is_absolute() {
2157 git_dir
2158 } else {
2159 dir.join(git_dir)
2160 })
2161 }
2162}
2163
2164impl Git {
2165 pub fn hardened() -> Self {
2168 Self::new().harden()
2169 }
2170}
2171
2172pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
2177 git: &'a Git<R>,
2178 dir: &'a Path,
2179}
2180
2181impl<R: ProcessRunner> Clone for GitAt<'_, R> {
2186 fn clone(&self) -> Self {
2187 *self
2188 }
2189}
2190impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
2191
2192macro_rules! git_at_forwarders {
2195 (
2196 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
2197 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
2198 ) => {
2199 impl<'a, R: ProcessRunner> GitAt<'a, R> {
2200 $(
2201 #[doc = concat!("Bound form of [`Git`]'s `", stringify!($bn), "`.")]
2202 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
2203 self.git.$bn($($ba),*).await
2204 }
2205 )*
2206 $(
2207 #[doc = concat!("Bound form of [`Git`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
2208 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
2209 self.git.$dn(self.dir, $($da),*).await
2210 }
2211 )*
2212 }
2213 };
2214}
2215
2216git_at_forwarders! {
2217 bare {
2218 fn run(args: &[String]) -> Result<String>;
2219 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
2220 fn run_args(args: &[&str]) -> Result<String>;
2221 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
2222 fn version() -> Result<String>;
2223 fn capabilities() -> Result<GitCapabilities>;
2224 fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
2225 }
2226 dir {
2227 fn status() -> Result<Vec<StatusEntry>>;
2228 fn status_text() -> Result<String>;
2229 fn status_tracked() -> Result<Vec<StatusEntry>>;
2230 fn branch_status() -> Result<BranchStatus>;
2231 fn conflicted_files() -> Result<Vec<String>>;
2232 fn current_branch() -> Result<Option<String>>;
2233 fn branches() -> Result<Vec<Branch>>;
2234 fn log(revspec: &str, max: usize) -> Result<Vec<Commit>>;
2235 fn rev_parse(rev: &str) -> Result<String>;
2236 fn rev_parse_short(rev: &str) -> Result<String>;
2237 fn init() -> Result<()>;
2238 fn add(paths: &[PathBuf]) -> Result<()>;
2239 fn commit(message: &str) -> Result<()>;
2240 fn create_branch(name: &str) -> Result<()>;
2241 fn checkout(reference: &str) -> Result<()>;
2242 fn checkout_detach(commit: &str) -> Result<()>;
2243 fn commit_paths(spec: CommitPaths) -> Result<()>;
2244 fn last_commit_message() -> Result<String>;
2245 fn is_unborn() -> Result<bool>;
2246 fn diff_is_empty() -> Result<bool>;
2247 fn common_dir() -> Result<PathBuf>;
2248 fn git_dir() -> Result<PathBuf>;
2249 fn resolve_commit(rev: &str) -> Result<String>;
2250 fn remote_head_branch() -> Result<Option<String>>;
2251 fn branch_exists(name: &str) -> Result<bool>;
2252 fn remote_branch_exists(name: &str) -> Result<bool>;
2253 fn remote_url(remote: &str) -> Result<String>;
2254 fn upstream() -> Result<Option<String>>;
2255 fn remote_branches(remote: &str) -> Result<Vec<String>>;
2256 fn is_merged(branch: &str, target: &str) -> Result<bool>;
2257 fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
2258 fn delete_branch(name: &str, force: bool) -> Result<()>;
2259 fn rename_branch(old: &str, new: &str) -> Result<()>;
2260 fn rev_list_count(range: &str) -> Result<usize>;
2261 fn diff_range_is_empty(range: &str) -> Result<bool>;
2262 fn diff_stat(range: &str) -> Result<DiffStat>;
2263 fn diff_text(spec: DiffSpec) -> Result<String>;
2264 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
2265 fn staged_is_empty() -> Result<bool>;
2266 fn is_rebase_in_progress() -> Result<bool>;
2267 fn is_merge_in_progress() -> Result<bool>;
2268 fn fetch() -> Result<()>;
2269 fn fetch_from(remote: &str) -> Result<()>;
2270 fn fetch_remote_branch(branch: &str) -> Result<()>;
2271 fn push(spec: GitPush) -> Result<()>;
2272 fn merge_squash(branch: &str) -> Result<()>;
2273 fn merge_commit(spec: MergeCommit) -> Result<()>;
2274 fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
2275 fn merge_abort() -> Result<()>;
2276 fn merge_continue() -> Result<()>;
2277 fn reset_merge() -> Result<()>;
2278 fn reset_hard(rev: &str) -> Result<()>;
2279 fn rebase(onto: &str) -> Result<()>;
2280 fn rebase_abort() -> Result<()>;
2281 fn rebase_continue() -> Result<()>;
2282 fn stash_push(include_untracked: bool) -> Result<()>;
2283 fn stash_pop() -> Result<()>;
2284 fn switch_with_stash(branch: &str) -> Result<()>;
2285 fn worktree_list() -> Result<Vec<Worktree>>;
2286 fn worktree_add(spec: WorktreeAdd) -> Result<()>;
2287 fn worktree_remove(path: &Path, force: bool) -> Result<()>;
2288 fn worktree_move(from: &Path, to: &Path) -> Result<()>;
2289 fn worktree_prune() -> Result<()>;
2290 fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
2291 fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2292 fn tag_list() -> Result<Vec<String>>;
2293 fn tag_delete(name: &str) -> Result<()>;
2294 fn show_file(rev: &str, path: &str) -> Result<String>;
2295 fn config_get(key: &str) -> Result<Option<String>>;
2296 fn config_set(key: &str, value: &str) -> Result<()>;
2297 fn remote_add(name: &str, url: &str) -> Result<()>;
2298 fn remote_set_url(name: &str, url: &str) -> Result<()>;
2299 fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2300 fn cherry_pick(rev: &str) -> Result<()>;
2301 fn revert(rev: &str) -> Result<()>;
2302 fn rebase_skip() -> Result<()>;
2303 }
2304}
2305
2306pub mod blocking {
2310 use std::path::Path;
2311 use std::process::Command;
2312
2313 pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2315 let mut cmd = Command::new(super::BINARY);
2316 cmd.current_dir(dir).args(["worktree", "remove"]);
2317 if force {
2318 cmd.arg("--force");
2319 }
2320 cmd.arg(path);
2321 let status = cmd.status()?;
2322 if status.success() {
2323 Ok(())
2324 } else {
2325 Err(std::io::Error::other(format!(
2326 "`git worktree remove` exited with {status}"
2327 )))
2328 }
2329 }
2330}
2331
2332#[cfg(test)]
2333mod tests {
2334 use super::*;
2335 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
2336
2337 #[test]
2338 fn binary_name_is_git() {
2339 assert_eq!(BINARY, "git");
2340 }
2341
2342 #[allow(dead_code)]
2346 fn bound_view_is_copy_for_default_runner() {
2347 fn assert_copy<T: Copy>() {}
2348 assert_copy::<GitAt<'static, processkit::JobRunner>>();
2349 }
2350
2351 #[tokio::test]
2355 async fn bound_view_matches_dir_taking_calls() {
2356 let dir = Path::new("/repo");
2357 let rec = RecordingRunner::replying(Reply::ok(""));
2358 let git = Git::with_runner(&rec);
2359
2360 git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2362 .await
2363 .unwrap();
2364 git.at(dir)
2365 .merge_commit(MergeCommit::branch("feat").no_ff())
2366 .await
2367 .unwrap();
2368 git.worktree_remove(dir, Path::new("/wt"), true)
2370 .await
2371 .unwrap();
2372 git.at(dir)
2373 .worktree_remove(Path::new("/wt"), true)
2374 .await
2375 .unwrap();
2376 git.conflicted_files(dir).await.unwrap();
2378 git.at(dir).conflicted_files().await.unwrap();
2379 git.tag_delete(dir, "v1").await.unwrap();
2381 git.at(dir).tag_delete("v1").await.unwrap();
2382
2383 let calls = rec.calls();
2384 assert_eq!(calls[0].args_str(), calls[1].args_str());
2385 assert_eq!(calls[2].args_str(), calls[3].args_str());
2386 assert_eq!(calls[4].args_str(), calls[5].args_str());
2387 assert_eq!(calls[6].args_str(), calls[7].args_str());
2388 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2390 assert_eq!(calls[3].cwd.as_deref(), Some(dir));
2391 }
2392
2393 #[tokio::test]
2396 async fn status_parses_scripted_output() {
2397 let git = Git::with_runner(
2399 ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
2400 );
2401 let entries = git.status(Path::new(".")).await.expect("status");
2402 assert_eq!(entries.len(), 2);
2403 assert_eq!(entries[0].code, " M");
2404 assert_eq!(entries[1].path, "b.rs");
2405 }
2406
2407 #[tokio::test]
2409 async fn status_tracked_excludes_untracked_flag() {
2410 let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2411 let git = Git::with_runner(&rec);
2412 let entries = git.status_tracked(Path::new(".")).await.expect("status");
2413 assert_eq!(entries.len(), 1);
2414 assert_eq!(entries[0].code, " M");
2415 assert_eq!(
2416 rec.only_call().args_str(),
2417 ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2418 );
2419 }
2420
2421 #[tokio::test]
2424 async fn branch_status_builds_v2_branch_args_and_parses() {
2425 let out = concat!(
2426 "# branch.oid abc\0",
2427 "# branch.head main\0",
2428 "# branch.upstream origin/main\0",
2429 "# branch.ab +1 -0\0",
2430 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2431 "? new.txt\0",
2432 );
2433 let rec = RecordingRunner::replying(Reply::ok(out));
2434 let git = Git::with_runner(&rec);
2435 let s = git
2436 .branch_status(Path::new("."))
2437 .await
2438 .expect("branch_status");
2439 assert_eq!(
2440 rec.only_call().args_str(),
2441 ["status", "--porcelain=v2", "--branch", "-z"]
2442 );
2443 assert!(rec.only_call().envs.iter().any(|(k, v)| {
2446 k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2447 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2448 }));
2449 assert_eq!(s.branch.as_deref(), Some("main"));
2450 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2451 assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2452 assert_eq!(s.tracked_changes, 1);
2453 assert_eq!(s.untracked, 1);
2454 assert!(s.is_dirty());
2455 }
2456
2457 #[tokio::test]
2459 async fn conflicted_files_builds_args_and_parses_nul_list() {
2460 let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2461 let git = Git::with_runner(&rec);
2462 let paths = git
2463 .conflicted_files(Path::new("."))
2464 .await
2465 .expect("conflicted_files");
2466 assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2467 assert_eq!(
2468 rec.only_call().args_str(),
2469 ["diff", "--name-only", "--diff-filter=U", "-z"]
2470 );
2471 }
2472
2473 #[tokio::test]
2474 async fn rev_parse_short_builds_short_flag() {
2475 let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2476 let git = Git::with_runner(&rec);
2477 let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2478 assert_eq!(out, "a1b2c3d");
2479 assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
2480 }
2481
2482 #[tokio::test]
2484 async fn nonzero_exit_is_structured_error() {
2485 let git = Git::with_runner(
2486 ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
2487 );
2488 match git.status(Path::new(".")).await.unwrap_err() {
2489 Error::Exit { code, stderr, .. } => {
2490 assert_eq!(code, 128);
2491 assert!(stderr.contains("not a git repository"), "{stderr}");
2492 }
2493 other => panic!("expected Exit, got {other:?}"),
2494 }
2495 }
2496
2497 #[tokio::test]
2500 async fn diff_is_empty_maps_exit_codes() {
2501 let clean =
2502 Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
2503 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2504
2505 let dirty = Git::with_runner(
2506 ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
2507 );
2508 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2509
2510 let broken = Git::with_runner(ScriptedRunner::new().on(
2511 ["git", "diff", "--quiet"],
2512 Reply::fail(128, "fatal: not a repo"),
2513 ));
2514 assert!(matches!(
2515 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2516 Error::Exit { code: 128, .. }
2517 ));
2518 }
2519
2520 #[tokio::test]
2523 async fn add_inserts_pathspec_separator() {
2524 let git = Git::with_runner(ScriptedRunner::new().on(["git", "add", "--"], Reply::ok("")));
2525 git.add(Path::new("."), &[PathBuf::from("f.rs")])
2526 .await
2527 .expect("add should build `add -- <paths>`");
2528 }
2529
2530 #[tokio::test]
2531 async fn worktree_list_parses_porcelain() {
2532 let git = Git::with_runner(ScriptedRunner::new().on(
2533 ["git", "worktree", "list"],
2534 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2535 ));
2536 let wts = git.worktree_list(Path::new(".")).await.expect("list");
2537 assert_eq!(wts.len(), 1);
2538 assert_eq!(wts[0].branch.as_deref(), Some("main"));
2539 assert_eq!(wts[0].head.as_deref(), Some("abc"));
2540 }
2541
2542 #[tokio::test]
2545 async fn worktree_add_builds_branch_path_and_base() {
2546 let rec = RecordingRunner::replying(Reply::ok(""));
2547 let git = Git::with_runner(&rec);
2548 git.worktree_add(
2549 Path::new("/repo"),
2550 WorktreeAdd::create_branch("/wt", "feature", "main"),
2551 )
2552 .await
2553 .expect("worktree add");
2554 assert_eq!(
2555 rec.only_call().args_str(),
2556 ["worktree", "add", "-b", "feature", "/wt", "main"]
2557 );
2558 }
2559
2560 #[tokio::test]
2561 async fn worktree_remove_passes_force_then_path() {
2562 let rec = RecordingRunner::replying(Reply::ok(""));
2563 let git = Git::with_runner(&rec);
2564 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2565 .await
2566 .expect("remove");
2567 assert_eq!(
2568 rec.only_call().args_str(),
2569 ["worktree", "remove", "--force", "/wt"]
2570 );
2571 }
2572
2573 #[tokio::test]
2575 async fn worktree_add_no_checkout_inserts_flag() {
2576 let rec = RecordingRunner::replying(Reply::ok(""));
2577 let git = Git::with_runner(&rec);
2578 git.worktree_add(
2579 Path::new("/repo"),
2580 WorktreeAdd::checkout("/wt", "main").no_checkout(),
2581 )
2582 .await
2583 .expect("worktree add");
2584 assert_eq!(
2585 rec.only_call().args_str(),
2586 ["worktree", "add", "--no-checkout", "/wt", "main"]
2587 );
2588 }
2589
2590 #[tokio::test]
2591 async fn checkout_detach_builds_args() {
2592 let rec = RecordingRunner::replying(Reply::ok(""));
2593 let git = Git::with_runner(&rec);
2594 git.checkout_detach(Path::new("."), "abc123")
2595 .await
2596 .expect("detach");
2597 assert_eq!(
2598 rec.only_call().args_str(),
2599 ["checkout", "--detach", "abc123"]
2600 );
2601 }
2602
2603 #[tokio::test]
2607 async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
2608 let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
2610 let on_branch = Git::with_runner(&rec);
2611 assert_eq!(
2612 on_branch.current_branch(Path::new(".")).await.unwrap(),
2613 Some("feature/x".to_string())
2614 );
2615 assert_eq!(
2616 rec.only_call().args_str(),
2617 ["symbolic-ref", "--quiet", "--short", "HEAD"]
2618 );
2619 let unborn = Git::with_runner(
2622 ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
2623 );
2624 assert_eq!(
2625 unborn.current_branch(Path::new(".")).await.unwrap(),
2626 Some("main".to_string())
2627 );
2628 let detached =
2630 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2631 assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
2632 let not_repo = Git::with_runner(ScriptedRunner::new().on(
2634 ["git", "symbolic-ref"],
2635 Reply::fail(128, "fatal: not a git repository"),
2636 ));
2637 assert!(not_repo.current_branch(Path::new(".")).await.is_err());
2638 }
2639
2640 #[tokio::test]
2642 async fn commit_paths_builds_only_amend_args() {
2643 let rec = RecordingRunner::replying(Reply::ok(""));
2644 let git = Git::with_runner(&rec);
2645 git.commit_paths(
2646 Path::new("."),
2647 CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2648 )
2649 .await
2650 .expect("commit_paths");
2651 assert_eq!(
2652 rec.only_call().args_str(),
2653 [
2654 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2655 ]
2656 );
2657 }
2658
2659 #[tokio::test]
2662 async fn is_unborn_maps_exit_codes() {
2663 let born =
2664 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
2665 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2666 let unborn =
2667 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
2668 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2669 let broken = Git::with_runner(
2670 ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
2671 );
2672 assert!(matches!(
2673 broken.is_unborn(Path::new(".")).await.unwrap_err(),
2674 Error::Exit { code: 128, .. }
2675 ));
2676 }
2677
2678 #[tokio::test]
2679 async fn log_builds_revspec_and_format() {
2680 let rec = RecordingRunner::replying(Reply::ok(""));
2681 let git = Git::with_runner(&rec);
2682 git.log(Path::new("."), "main..HEAD", 5).await.expect("log");
2683 assert_eq!(
2684 rec.only_call().args_str(),
2685 [
2686 "log",
2687 "main..HEAD",
2688 "-n5",
2689 "-z",
2690 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2691 ]
2692 );
2693 }
2694
2695 #[tokio::test]
2696 async fn stash_push_adds_include_untracked() {
2697 let rec = RecordingRunner::replying(Reply::ok(""));
2698 let git = Git::with_runner(&rec);
2699 git.stash_push(Path::new("."), true).await.expect("stash");
2700 assert_eq!(
2701 rec.only_call().args_str(),
2702 ["stash", "push", "--include-untracked"]
2703 );
2704 }
2705
2706 #[tokio::test]
2709 async fn diff_text_builds_working_tree_args() {
2710 let rec = RecordingRunner::replying(Reply::ok(""));
2713 let git = Git::with_runner(&rec);
2714 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2715 .await
2716 .expect("diff_text");
2717 assert_eq!(
2718 rec.calls().last().unwrap().args_str(),
2719 [
2720 "diff",
2721 "HEAD",
2722 "--no-color",
2723 "--no-ext-diff",
2724 "-M",
2725 "--src-prefix=a/",
2728 "--dst-prefix=b/",
2729 ]
2730 );
2731 }
2732
2733 #[tokio::test]
2737 async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
2738 let git = Git::with_runner(
2739 ScriptedRunner::new()
2740 .on(["git", "rev-parse"], Reply::fail(1, "")) .on(["git", "diff", EMPTY_TREE], Reply::ok("EMPTY")),
2742 );
2743 let out = git
2744 .diff_text(Path::new("."), DiffSpec::WorkingTree)
2745 .await
2746 .expect("diff_text");
2747 assert_eq!(out, "EMPTY");
2748 }
2749
2750 #[tokio::test]
2753 async fn diff_parses_scripted_output() {
2754 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2755 let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
2756 let files = git
2757 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
2758 .await
2759 .expect("diff");
2760 assert_eq!(files.len(), 1);
2761 assert_eq!(files[0].path, "m");
2762 assert_eq!(files[0].change, ChangeKind::Modified);
2763 }
2764
2765 #[tokio::test]
2766 async fn branch_exists_maps_exit_codes() {
2767 let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
2768 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
2769 let no =
2770 Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
2771 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
2772 }
2773
2774 #[tokio::test]
2777 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
2778 let simple = Git::with_runner(ScriptedRunner::new().on(
2779 ["git", "symbolic-ref"],
2780 Reply::ok("refs/remotes/origin/main\n"),
2781 ));
2782 assert_eq!(
2783 simple
2784 .remote_head_branch(Path::new("."))
2785 .await
2786 .unwrap()
2787 .as_deref(),
2788 Some("main")
2789 );
2790
2791 let slashed = Git::with_runner(ScriptedRunner::new().on(
2792 ["git", "symbolic-ref"],
2793 Reply::ok("refs/remotes/origin/release/v2\n"),
2794 ));
2795 assert_eq!(
2796 slashed
2797 .remote_head_branch(Path::new("."))
2798 .await
2799 .unwrap()
2800 .as_deref(),
2801 Some("release/v2")
2802 );
2803
2804 let unset =
2805 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2806 assert!(
2807 unset
2808 .remote_head_branch(Path::new("."))
2809 .await
2810 .unwrap()
2811 .is_none()
2812 );
2813 }
2814
2815 #[tokio::test]
2818 async fn remote_branch_exists_sets_env_and_reads_stdout() {
2819 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
2820 let git = Git::with_runner(&rec);
2821 assert!(
2822 git.remote_branch_exists(Path::new("/repo"), "main")
2823 .await
2824 .unwrap()
2825 );
2826 let call = rec.only_call();
2827 assert!(call.envs.iter().any(|(k, v)| {
2828 k.to_str() == Some("GIT_TERMINAL_PROMPT")
2829 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2830 }));
2831 assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
2833
2834 let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
2835 assert!(
2836 !empty
2837 .remote_branch_exists(Path::new("."), "x")
2838 .await
2839 .unwrap()
2840 );
2841 }
2842
2843 #[tokio::test]
2844 async fn diff_stat_parses_counts() {
2845 let git = Git::with_runner(ScriptedRunner::new().on(
2846 ["git", "diff", "--shortstat"],
2847 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
2848 ));
2849 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
2850 assert_eq!(
2851 (stat.files_changed, stat.insertions, stat.deletions),
2852 (2, 5, 1)
2853 );
2854 }
2855
2856 #[tokio::test]
2857 async fn status_text_returns_raw_porcelain() {
2858 let git = Git::with_runner(ScriptedRunner::new().on(
2859 ["git", "status", "--porcelain=v1"],
2860 Reply::ok(" M a.rs\n?? b.rs\n"),
2861 ));
2862 let text = git.status_text(Path::new(".")).await.expect("status_text");
2863 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
2864 }
2865
2866 #[tokio::test]
2867 async fn run_args_forwards_str_slices() {
2868 let git =
2869 Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
2870 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
2871 }
2872
2873 #[tokio::test]
2874 async fn merge_commit_builds_no_ff_and_message() {
2875 let rec = RecordingRunner::replying(Reply::ok(""));
2876 let git = Git::with_runner(&rec);
2877 git.merge_commit(
2878 Path::new("/r"),
2879 MergeCommit::branch("feature").no_ff().message("merge it"),
2880 )
2881 .await
2882 .unwrap();
2883 assert_eq!(
2884 rec.only_call().args_str(),
2885 ["merge", "--no-ff", "-m", "merge it", "feature"]
2886 );
2887 }
2888
2889 #[tokio::test]
2891 async fn merge_commit_without_message_uses_no_edit() {
2892 let rec = RecordingRunner::replying(Reply::ok(""));
2893 let git = Git::with_runner(&rec);
2894 git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
2895 .await
2896 .unwrap();
2897 assert_eq!(
2898 rec.only_call().args_str(),
2899 ["merge", "--no-edit", "feature"]
2900 );
2901 }
2902
2903 #[tokio::test]
2905 async fn rebase_suppresses_editor() {
2906 let rec = RecordingRunner::replying(Reply::ok(""));
2907 let git = Git::with_runner(&rec);
2908 git.rebase(Path::new("/r"), "main").await.unwrap();
2909 let call = rec.only_call();
2910 assert_eq!(call.args_str(), ["rebase", "main"]);
2911 assert!(call.envs.iter().any(|(k, v)| {
2912 k.to_str() == Some("GIT_EDITOR")
2913 && v.as_deref().and_then(|o| o.to_str()) == Some("true")
2914 }));
2915 }
2916
2917 #[tokio::test]
2918 async fn push_builds_set_upstream_remote_refspec() {
2919 let rec = RecordingRunner::replying(Reply::ok(""));
2920 let git = Git::with_runner(&rec);
2921 git.push(
2922 Path::new("/r"),
2923 GitPush::refspec("feat", "feature").set_upstream(),
2924 )
2925 .await
2926 .unwrap();
2927 assert_eq!(
2928 rec.only_call().args_str(),
2929 ["push", "-u", "origin", "feat:feature"]
2930 );
2931 }
2932
2933 #[tokio::test]
2936 async fn push_bare_branch_builds_origin_branch_prompt_off() {
2937 let rec = RecordingRunner::replying(Reply::ok(""));
2938 let git = Git::with_runner(&rec);
2939 git.push(Path::new("/r"), GitPush::branch("feature"))
2940 .await
2941 .unwrap();
2942 let call = rec.only_call();
2943 assert_eq!(call.args_str(), ["push", "origin", "feature"]);
2944 assert!(call.envs.iter().any(|(k, v)| {
2945 k.to_str() == Some("GIT_TERMINAL_PROMPT")
2946 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2947 }));
2948 }
2949
2950 #[tokio::test]
2952 async fn push_remote_override_swaps_remote() {
2953 let rec = RecordingRunner::replying(Reply::ok(""));
2954 let git = Git::with_runner(&rec);
2955 git.push(
2956 Path::new("/r"),
2957 GitPush::branch("feature").remote("upstream"),
2958 )
2959 .await
2960 .unwrap();
2961 assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
2962 }
2963
2964 #[tokio::test]
2968 async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
2969 let rec = RecordingRunner::replying(Reply::ok(""));
2970 let git = Git::with_runner(&rec)
2971 .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
2972 git.push(Path::new("/r"), GitPush::branch("feature"))
2973 .await
2974 .unwrap();
2975 let call = rec.only_call();
2976 let args = call.args_str();
2977 assert_eq!(args[0], "-c", "config flag leads the argv");
2979 assert!(
2980 args.iter().any(|a| a == "credential.helper="),
2981 "inherited helpers are cleared first: {args:?}"
2982 );
2983 assert!(
2984 args.iter()
2985 .any(|a| a.contains("credential.helper=!f()")
2986 && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
2987 "inline helper references the secret by env-var name: {args:?}"
2988 );
2989 assert!(
2990 args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
2991 "the real subcommand still runs: {args:?}"
2992 );
2993 assert!(
2995 !args.iter().any(|a| a.contains("ghp_secret123")),
2996 "secret leaked into argv: {args:?}"
2997 );
2998 let pw = call
3000 .envs
3001 .iter()
3002 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3003 .and_then(|(_, v)| v.as_ref())
3004 .and_then(|v| v.to_str());
3005 assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
3006 }
3007
3008 #[tokio::test]
3011 async fn default_client_injects_no_credential_helper() {
3012 let rec = RecordingRunner::replying(Reply::ok(""));
3013 let git = Git::with_runner(&rec);
3014 git.push(Path::new("/r"), GitPush::branch("feature"))
3015 .await
3016 .unwrap();
3017 let call = rec.only_call();
3018 assert_eq!(
3019 call.args_str(),
3020 ["push", "origin", "feature"],
3021 "no credential `-c` args without a provider"
3022 );
3023 assert!(
3024 !call
3025 .envs
3026 .iter()
3027 .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
3028 "no secret env without a provider"
3029 );
3030 }
3031
3032 #[tokio::test]
3036 async fn with_credentials_clone_puts_config_flags_before_subcommand() {
3037 let rec = RecordingRunner::replying(Reply::ok(""));
3038 let git =
3039 Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
3040 git.clone_repo(
3041 "https://example.com/r.git",
3042 Path::new("/dest"),
3043 CloneSpec::default().branch("main"),
3044 )
3045 .await
3046 .unwrap();
3047 let call = rec.only_call();
3048 let args = call.args_str();
3049 assert_eq!(args[0], "-c", "config flags lead the clone argv");
3050 let clone_at = args
3051 .iter()
3052 .position(|a| a == "clone")
3053 .expect("clone present");
3054 assert!(
3056 args[..clone_at]
3057 .iter()
3058 .all(|a| a == "-c" || a.starts_with("credential.helper")),
3059 "only credential -c flags precede `clone`: {args:?}"
3060 );
3061 let tail = &args[clone_at..];
3063 assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
3064 assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
3065 assert!(
3066 !args.iter().any(|a| a.contains("s3cr3t")),
3067 "secret not in argv"
3068 );
3069 }
3070
3071 #[tokio::test]
3074 async fn with_credentials_userpass_threads_username_through_env() {
3075 let rec = RecordingRunner::replying(Reply::ok(""));
3076 let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
3077 Credential::userpass("alice", "s3cr3t"),
3078 )));
3079 git.fetch(Path::new("/r")).await.unwrap();
3080 let call = rec.only_call();
3081 let user = call
3082 .envs
3083 .iter()
3084 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
3085 .and_then(|(_, v)| v.as_ref())
3086 .and_then(|v| v.to_str());
3087 assert_eq!(user, Some("alice"), "userpass username reaches the env");
3088 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
3089 assert!(call.args_str().contains(&"fetch".to_string()));
3090 }
3091
3092 #[tokio::test]
3095 async fn default_client_no_helper_on_fetch_and_clone() {
3096 let rec = RecordingRunner::replying(Reply::ok(""));
3097 Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
3098 assert_eq!(
3099 rec.only_call().args_str(),
3100 ["fetch", "--quiet"],
3101 "fetch unchanged without a provider"
3102 );
3103
3104 let rec = RecordingRunner::replying(Reply::ok(""));
3105 Git::with_runner(&rec)
3106 .clone_repo(
3107 "https://example.com/r.git",
3108 Path::new("/dest"),
3109 CloneSpec::default(),
3110 )
3111 .await
3112 .unwrap();
3113 assert_eq!(
3114 rec.only_call().args_str()[0],
3115 "clone",
3116 "clone leads with the subcommand (no `-c`) without a provider"
3117 );
3118 }
3119
3120 #[tokio::test]
3123 async fn with_token_convenience_authenticates_https_remote() {
3124 let rec = RecordingRunner::replying(Reply::ok(""));
3125 let git = Git::with_runner(&rec).with_token("ghp_conv");
3126 git.fetch(Path::new("/r")).await.unwrap();
3127 let call = rec.only_call();
3128 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
3129 let pw = call
3130 .envs
3131 .iter()
3132 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3133 .and_then(|(_, v)| v.as_ref())
3134 .and_then(|v| v.to_str());
3135 assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
3136 assert!(
3137 !call.args_str().iter().any(|a| a.contains("ghp_conv")),
3138 "secret not in argv"
3139 );
3140 }
3141
3142 #[tokio::test]
3143 async fn upstream_maps_unset_to_none() {
3144 let set = Git::with_runner(
3145 ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("origin/main\n")),
3146 );
3147 assert_eq!(
3148 set.upstream(Path::new(".")).await.unwrap().as_deref(),
3149 Some("origin/main")
3150 );
3151 let unset =
3154 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "")));
3155 assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
3156 let timed_out =
3159 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::timeout()));
3160 assert!(timed_out.upstream(Path::new(".")).await.is_err());
3161 }
3162
3163 #[tokio::test]
3167 async fn remote_head_branch_maps_exit_codes() {
3168 let set = Git::with_runner(ScriptedRunner::new().on(
3169 ["git", "symbolic-ref"],
3170 Reply::ok("refs/remotes/origin/release/v2\n"),
3171 ));
3172 assert_eq!(
3173 set.remote_head_branch(Path::new("."))
3174 .await
3175 .unwrap()
3176 .as_deref(),
3177 Some("release/v2"),
3178 "the full ref prefix is stripped, slashes preserved"
3179 );
3180 let unset =
3181 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3182 assert!(
3183 unset
3184 .remote_head_branch(Path::new("."))
3185 .await
3186 .unwrap()
3187 .is_none()
3188 );
3189 let err = Git::with_runner(ScriptedRunner::new().on(
3191 ["git", "symbolic-ref"],
3192 Reply::fail(128, "fatal: not a git repository"),
3193 ));
3194 assert!(err.remote_head_branch(Path::new(".")).await.is_err());
3195 let timed_out =
3197 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
3198 assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
3199 }
3200
3201 #[tokio::test]
3202 async fn set_upstream_builds_branch_flag() {
3203 let rec = RecordingRunner::replying(Reply::ok(""));
3204 let git = Git::with_runner(&rec);
3205 git.set_upstream(Path::new("/r"), "feat", "origin/feature")
3206 .await
3207 .unwrap();
3208 assert_eq!(
3209 rec.only_call().args_str(),
3210 ["branch", "--set-upstream-to=origin/feature", "feat"]
3211 );
3212 }
3213
3214 #[tokio::test]
3215 async fn remote_branches_parses_ls_remote() {
3216 let git = Git::with_runner(ScriptedRunner::new().on(
3217 ["git", "ls-remote"],
3218 Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
3219 ));
3220 let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
3221 assert_eq!(branches, ["main", "feat/x"]);
3222 }
3223
3224 #[tokio::test]
3225 async fn delete_branch_force_uses_capital_d() {
3226 let rec = RecordingRunner::replying(Reply::ok(""));
3227 let git = Git::with_runner(&rec);
3228 git.delete_branch(Path::new("/r"), "old", true)
3229 .await
3230 .unwrap();
3231 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
3232 }
3233
3234 #[tokio::test]
3237 async fn is_merged_strips_branch_markers() {
3238 let git = Git::with_runner(ScriptedRunner::new().on(
3239 ["git", "branch", "--merged"],
3240 Reply::ok(" main\n* feature\n+ wt-branch\n"),
3241 ));
3242 for name in ["main", "feature", "wt-branch"] {
3243 assert!(
3244 git.is_merged(Path::new("."), name, "main").await.unwrap(),
3245 "{name} should be reported merged"
3246 );
3247 }
3248 assert!(
3249 !git.is_merged(Path::new("."), "absent", "main")
3250 .await
3251 .unwrap()
3252 );
3253 }
3254
3255 #[tokio::test]
3258 async fn fetch_disables_terminal_prompt() {
3259 let rec = RecordingRunner::replying(Reply::ok(""));
3260 let git = Git::with_runner(&rec);
3261 git.fetch(Path::new("/r")).await.unwrap();
3262 let call = rec.only_call();
3263 assert_eq!(call.args_str(), ["fetch", "--quiet"]);
3264 assert!(call.envs.iter().any(|(k, v)| {
3265 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3266 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3267 }));
3268 }
3269
3270 #[tokio::test]
3272 async fn fetch_retries_transient_failures() {
3273 let rec = RecordingRunner::replying(Reply::fail(
3274 128,
3275 "fatal: unable to access: Could not resolve host: example.com",
3276 ));
3277 let git = Git::with_runner(&rec);
3278 assert!(git.fetch(Path::new("/r")).await.is_err());
3279 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
3280 }
3281
3282 #[tokio::test]
3287 async fn with_retry_retries_lock_contention_on_a_mutation() {
3288 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3289 ["git", "commit"],
3290 [
3291 Reply::fail(
3292 128,
3293 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3294 ),
3295 Reply::ok(""),
3296 ],
3297 ));
3298 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3299 git.commit(Path::new("/r"), "msg")
3300 .await
3301 .expect("retried past the lock");
3302 assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
3303 }
3304
3305 #[tokio::test]
3307 async fn default_client_does_not_retry_lock_contention() {
3308 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3309 ["git", "commit"],
3310 [
3311 Reply::fail(
3312 128,
3313 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3314 ),
3315 Reply::ok(""),
3316 ],
3317 ));
3318 let git = Git::with_runner(&rec);
3319 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3320 assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
3321 }
3322
3323 #[tokio::test]
3326 async fn with_retry_does_not_retry_a_real_failure() {
3327 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3328 ["git", "commit"],
3329 [
3330 Reply::fail(1, "error: pathspec 'x' did not match"),
3331 Reply::ok(""),
3332 ],
3333 ));
3334 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3335 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3336 assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
3337 }
3338
3339 #[tokio::test]
3341 async fn fetch_does_not_retry_permanent_failures() {
3342 let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
3343 let git = Git::with_runner(&rec);
3344 assert!(git.fetch(Path::new("/r")).await.is_err());
3345 assert_eq!(rec.calls().len(), 1);
3346 }
3347
3348 #[tokio::test(start_paused = true)]
3355 async fn fetch_cancels_and_does_not_retry() {
3356 use processkit::CancellationToken;
3357 let token = CancellationToken::new();
3358 let rec =
3359 RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
3360 let git = Git::with_runner(&rec).default_cancel_on(token.clone());
3361 let call = git.fetch(Path::new("/r"));
3362 tokio::pin!(call);
3363 assert!(
3364 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
3365 .await
3366 .is_err(),
3367 "fetch must park until the token fires"
3368 );
3369 token.cancel();
3370 assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
3371 assert_eq!(
3372 rec.calls().len(),
3373 1,
3374 "cancellation is terminal — the fetch-retry must not replay it"
3375 );
3376 }
3377
3378 #[tokio::test]
3381 async fn flag_like_positionals_are_rejected_before_spawning() {
3382 let rec = RecordingRunner::replying(Reply::ok(""));
3383 let git = Git::with_runner(&rec);
3384 let dir = Path::new("/r");
3385
3386 assert!(git.checkout(dir, "-evil").await.is_err());
3387 assert!(git.create_branch(dir, "--force").await.is_err());
3388 assert!(git.delete_branch(dir, "-D", false).await.is_err());
3389 assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
3390 assert!(
3391 git.merge_commit(dir, MergeCommit::branch("-evil"))
3392 .await
3393 .is_err()
3394 );
3395 assert!(
3396 git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
3397 .await
3398 .is_err()
3399 );
3400 assert!(git.merge_squash(dir, "-evil").await.is_err());
3401 assert!(git.rebase(dir, "-i").await.is_err());
3402 assert!(git.cherry_pick(dir, "-n").await.is_err());
3403 assert!(git.revert(dir, "-evil").await.is_err());
3404 assert!(git.tag_create(dir, "-d", None).await.is_err());
3405 assert!(
3406 git.tag_create(dir, "ok", Some("-evil".into()))
3407 .await
3408 .is_err()
3409 );
3410 assert!(git.tag_delete(dir, "-evil").await.is_err());
3411 assert!(git.remote_add(dir, "-evil", "url").await.is_err());
3412 assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
3413 assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
3414 assert!(git.log(dir, "-evil", 5).await.is_err());
3415 assert!(git.rev_list_count(dir, "-evil").await.is_err());
3416 assert!(git.diff_stat(dir, "-evil").await.is_err());
3417 assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
3418 assert!(
3419 git.diff_text(dir, DiffSpec::Rev("-evil".into()))
3420 .await
3421 .is_err()
3422 );
3423 assert!(git.rev_parse(dir, "-evil").await.is_err());
3424 assert!(git.rev_parse_short(dir, "-evil").await.is_err());
3425 assert!(git.resolve_commit(dir, "-evil").await.is_err());
3426 assert!(git.reset_hard(dir, "-evil").await.is_err());
3427 assert!(git.checkout_detach(dir, "-evil").await.is_err());
3428 assert!(git.config_set(dir, "-evil", "v").await.is_err());
3429 assert!(
3430 git.push(dir, GitPush::branch("-evil")).await.is_err(),
3431 "refspec guard"
3432 );
3433 assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
3435 assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
3436 assert!(git.remote_url(dir, "-evil").await.is_err());
3437 assert!(git.remote_branches(dir, "-evil").await.is_err());
3438 assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
3439 assert!(
3441 git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
3442 .await
3443 .is_err()
3444 );
3445 assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
3446 assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
3447 assert!(git.is_merged(dir, "-evil", "main").await.is_err());
3448 assert!(git.config_get(dir, "-evil").await.is_err());
3449 assert!(
3450 git.worktree_add(
3451 dir,
3452 WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
3453 )
3454 .await
3455 .is_err()
3456 );
3457 assert!(git.checkout(dir, "").await.is_err());
3459
3460 assert!(
3461 rec.calls().is_empty(),
3462 "nothing may spawn: {:?}",
3463 rec.calls()
3464 );
3465
3466 git.checkout(dir, "feature/x").await.expect("checkout");
3468 assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x"]);
3469 }
3470
3471 #[tokio::test]
3474 async fn harden_applies_env_profile_to_every_command() {
3475 let rec = RecordingRunner::replying(Reply::ok(""));
3476 let git = Git::with_runner(&rec).harden();
3477 git.status(Path::new("/r")).await.expect("status");
3478 git.fetch(Path::new("/r")).await.expect("fetch");
3479
3480 for call in rec.calls() {
3481 let has = |k: &str, v: &str| {
3482 call.envs.iter().any(|(key, val)| {
3483 key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
3484 })
3485 };
3486 let removed = |k: &str| {
3487 call.envs
3488 .iter()
3489 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3490 };
3491 assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
3492 assert!(has("GIT_CONFIG_COUNT", "3"));
3493 assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
3494 assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
3495 assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
3496 assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
3498 assert!(has("GIT_CONFIG_VALUE_2", ""));
3499 assert!(has("GIT_TERMINAL_PROMPT", "0"));
3500 assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
3501 assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
3502 assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
3504 assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
3505 assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
3506 assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
3507 }
3508 }
3509
3510 #[test]
3512 fn ref_name_and_rev_spec_validate() {
3513 for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
3514 assert!(RefName::new(ok).is_ok(), "{ok}");
3515 }
3516 for bad in [
3517 "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
3518 "a\\b", "end/", "x.lock",
3519 ] {
3520 assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
3521 }
3522 assert!(RevSpec::new("HEAD~2").is_ok());
3523 assert!(RevSpec::new("main..feature").is_ok());
3524 assert!(RevSpec::new("-evil").is_err());
3525 assert!(RevSpec::new("").is_err());
3526 }
3527
3528 #[tokio::test]
3531 async fn capabilities_parse_and_gate_versions() {
3532 let gh = Git::with_runner(ScriptedRunner::new().on(
3533 ["git", "--version"],
3534 Reply::ok("git version 2.54.0.windows.1\n"),
3535 ));
3536 let caps = gh.capabilities().await.expect("capabilities");
3537 assert_eq!(caps.version.to_string(), "2.54.0");
3538 assert!(caps.is_supported());
3539 caps.ensure_supported().expect("supported");
3540
3541 let old = Git::with_runner(
3544 ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
3545 );
3546 let caps = old.capabilities().await.expect("capabilities");
3547 assert_eq!(
3548 caps.version,
3549 GitVersion {
3550 major: 1,
3551 minor: 9,
3552 patch: 0
3553 }
3554 );
3555 let err = caps.ensure_supported().expect_err("unsupported");
3556 let Error::Spawn { source, .. } = &err else {
3558 panic!("expected Spawn, got {err:?}");
3559 };
3560 let message = source.to_string();
3561 assert!(message.contains(">= 2"), "names the floor: {message}");
3562 assert!(
3563 message.contains("1.9.0"),
3564 "names the found version: {message}"
3565 );
3566
3567 let garbage = Git::with_runner(
3569 ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
3570 );
3571 assert!(matches!(
3572 garbage.capabilities().await.unwrap_err(),
3573 Error::Parse { .. }
3574 ));
3575 }
3576
3577 #[tokio::test]
3579 async fn clone_repo_builds_flags_and_runs_dirless() {
3580 let rec = RecordingRunner::replying(Reply::ok(""));
3581 let git = Git::with_runner(&rec);
3582 git.clone_repo(
3583 "https://example.com/r.git",
3584 Path::new("/dest"),
3585 CloneSpec::new().branch("main").depth(1).bare(),
3586 )
3587 .await
3588 .expect("clone");
3589 let call = rec.only_call();
3590 assert_eq!(
3591 call.args_str(),
3592 [
3593 "clone",
3594 "--branch",
3595 "main",
3596 "--depth",
3597 "1",
3598 "--bare",
3599 "https://example.com/r.git",
3600 "/dest"
3601 ]
3602 );
3603 assert_eq!(call.cwd, None, "clone runs without a working directory");
3604
3605 let bare = RecordingRunner::replying(Reply::ok(""));
3606 let git = Git::with_runner(&bare);
3607 git.clone_repo("u", Path::new("/d"), CloneSpec::new())
3608 .await
3609 .expect("clone");
3610 assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
3611 }
3612
3613 #[tokio::test]
3614 async fn tag_methods_build_args() {
3615 let rec = RecordingRunner::replying(Reply::ok(""));
3616 let git = Git::with_runner(&rec);
3617 git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
3618 git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
3619 .await
3620 .unwrap();
3621 git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
3622 .await
3623 .unwrap();
3624 git.tag_delete(Path::new("/r"), "v1").await.unwrap();
3625 let calls = rec.calls();
3626 assert_eq!(calls[0].args_str(), ["tag", "v1"]);
3627 assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
3628 assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
3629 assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
3630 }
3631
3632 #[tokio::test]
3633 async fn tag_list_splits_lines() {
3634 let git = Git::with_runner(
3635 ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
3636 );
3637 assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
3638 }
3639
3640 #[tokio::test]
3643 async fn list_commands_disable_column_output() {
3644 let rec = RecordingRunner::replying(Reply::ok(""));
3645 let git = Git::with_runner(&rec);
3646 git.branches(Path::new(".")).await.unwrap();
3647 git.is_merged(Path::new("."), "b", "main").await.unwrap();
3648 git.tag_list(Path::new(".")).await.unwrap();
3649 let calls = rec.calls();
3650 assert_eq!(calls[0].args_str(), ["branch", "--no-column"]);
3651 assert_eq!(
3652 calls[1].args_str(),
3653 ["branch", "--merged", "main", "--no-column"]
3654 );
3655 assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
3656 }
3657
3658 #[tokio::test]
3661 async fn classified_commands_force_c_locale() {
3662 let rec = RecordingRunner::replying(Reply::ok(""));
3663 let git = Git::with_runner(&rec);
3664 git.commit(Path::new("."), "msg").await.unwrap();
3665 git.merge_commit(Path::new("."), MergeCommit::branch("b"))
3666 .await
3667 .unwrap();
3668 git.cherry_pick(Path::new("."), "abc").await.unwrap();
3669 git.fetch(Path::new(".")).await.unwrap();
3670 for call in rec.calls() {
3671 assert!(
3672 call.envs.iter().any(|(k, v)| {
3673 k.to_str() == Some("LC_ALL")
3674 && v.as_deref().and_then(|o| o.to_str()) == Some("C")
3675 }),
3676 "{:?} should force LC_ALL=C",
3677 call.args_str()
3678 );
3679 }
3680 }
3681
3682 #[cfg(windows)]
3685 #[tokio::test]
3686 async fn show_file_normalises_path_separators() {
3687 let rec = RecordingRunner::replying(Reply::ok("content\n"));
3688 let git = Git::with_runner(&rec);
3689 let out = git
3690 .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
3691 .await
3692 .expect("show_file");
3693 assert_eq!(out, "content");
3694 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
3695 }
3696
3697 #[cfg(not(windows))]
3700 #[tokio::test]
3701 async fn show_file_keeps_backslashes_on_unix() {
3702 let rec = RecordingRunner::replying(Reply::ok("content\n"));
3703 let git = Git::with_runner(&rec);
3704 git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
3705 .await
3706 .expect("show_file");
3707 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
3708 }
3709
3710 #[tokio::test]
3712 async fn config_get_maps_exit_codes() {
3713 let set = Git::with_runner(
3714 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
3715 );
3716 assert_eq!(
3717 set.config_get(Path::new("."), "user.name").await.unwrap(),
3718 Some("Alice".to_string())
3719 );
3720 let spaced = Git::with_runner(
3723 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix: \r\n")),
3724 );
3725 assert_eq!(
3726 spaced.config_get(Path::new("."), "x.y").await.unwrap(),
3727 Some("prefix: ".to_string())
3728 );
3729 let unset = Git::with_runner(
3730 ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
3731 );
3732 assert_eq!(
3733 unset.config_get(Path::new("."), "user.name").await.unwrap(),
3734 None
3735 );
3736 let multi = Git::with_runner(ScriptedRunner::new().on(
3738 ["git", "config", "--get"],
3739 Reply::fail(2, "multiple values"),
3740 ));
3741 assert!(
3742 multi
3743 .config_get(Path::new("."), "remote.all")
3744 .await
3745 .is_err()
3746 );
3747 }
3748
3749 #[tokio::test]
3750 async fn blame_builds_rev_before_pathspec_separator() {
3751 let rec = RecordingRunner::replying(Reply::ok(""));
3752 let git = Git::with_runner(&rec);
3753 git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
3754 .await
3755 .unwrap();
3756 git.blame(Path::new("/r"), "src/lib.rs", None)
3757 .await
3758 .unwrap();
3759 let calls = rec.calls();
3760 assert_eq!(
3761 calls[0].args_str(),
3762 ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
3763 );
3764 assert_eq!(
3765 calls[1].args_str(),
3766 ["blame", "--line-porcelain", "--", "src/lib.rs"]
3767 );
3768 }
3769
3770 #[tokio::test]
3772 async fn sequencer_methods_suppress_editors() {
3773 let rec = RecordingRunner::replying(Reply::ok(""));
3774 let git = Git::with_runner(&rec);
3775 git.revert(Path::new("/r"), "abc").await.unwrap();
3776 git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
3777 git.rebase_skip(Path::new("/r")).await.unwrap();
3778 let calls = rec.calls();
3779 assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
3780 assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
3781 assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
3782 for call in &calls {
3783 assert!(
3784 call.envs
3785 .iter()
3786 .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
3787 "editor suppressed on {:?}",
3788 call.args_str()
3789 );
3790 }
3791 }
3792
3793 #[tokio::test]
3802 async fn hardened_sequencer_keeps_its_no_op_editor() {
3803 let rec = RecordingRunner::replying(Reply::ok(""));
3804 let git = Git::with_runner(&rec).harden();
3805 git.revert(Path::new("/r"), "abc").await.unwrap();
3806 let call = rec.only_call();
3807 let effective = |var: &str| {
3809 call.envs
3810 .iter()
3811 .rfind(|(k, _)| k.to_str() == Some(var))
3812 .and_then(|(_, v)| v.as_deref())
3813 .and_then(|v| v.to_str())
3814 };
3815 assert_eq!(
3818 effective("GIT_EDITOR"),
3819 Some("true"),
3820 "the per-command no-op editor must survive harden()'s scrub"
3821 );
3822 assert_eq!(
3823 effective("GIT_SEQUENCE_EDITOR"),
3824 Some("true"),
3825 "the per-command no-op sequence editor must survive harden()'s scrub"
3826 );
3827 }
3828
3829 #[tokio::test]
3830 async fn remote_add_and_set_url_build_args() {
3831 let rec = RecordingRunner::replying(Reply::ok(""));
3832 let git = Git::with_runner(&rec);
3833 git.remote_add(Path::new("/r"), "up", "https://x/y.git")
3834 .await
3835 .unwrap();
3836 git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
3837 .await
3838 .unwrap();
3839 let calls = rec.calls();
3840 assert_eq!(
3841 calls[0].args_str(),
3842 ["remote", "add", "up", "https://x/y.git"]
3843 );
3844 assert_eq!(
3845 calls[1].args_str(),
3846 ["remote", "set-url", "up", "https://x/z.git"]
3847 );
3848 }
3849
3850 #[tokio::test]
3852 async fn switch_with_stash_round_trips_dirty_tree() {
3853 let rec = RecordingRunner::new(
3854 ScriptedRunner::new()
3855 .on(["git", "status"], Reply::ok(" M a.rs\0"))
3856 .on(["git", "stash", "push"], Reply::ok(""))
3857 .on(["git", "checkout"], Reply::ok(""))
3858 .on(["git", "stash", "pop"], Reply::ok("")),
3859 );
3860 let git = Git::with_runner(&rec);
3861 git.switch_with_stash(Path::new("/r"), "feature")
3862 .await
3863 .expect("switch");
3864 let calls = rec.calls();
3865 assert_eq!(calls.len(), 4);
3866 assert_eq!(
3867 calls[1].args_str(),
3868 ["stash", "push", "--include-untracked"]
3869 );
3870 assert_eq!(calls[2].args_str(), ["checkout", "feature"]);
3871 assert_eq!(calls[3].args_str(), ["stash", "pop"]);
3872 }
3873
3874 #[tokio::test]
3877 async fn switch_with_stash_skips_stash_on_clean_tree() {
3878 let rec = RecordingRunner::new(
3879 ScriptedRunner::new()
3880 .on(["git", "status"], Reply::ok(""))
3881 .on(["git", "checkout"], Reply::ok("")),
3882 );
3883 let git = Git::with_runner(&rec);
3884 git.switch_with_stash(Path::new("/r"), "feature")
3885 .await
3886 .expect("switch");
3887 let calls = rec.calls();
3888 assert_eq!(calls.len(), 2);
3889 assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
3890 }
3891
3892 #[tokio::test]
3895 async fn switch_with_stash_restores_on_checkout_failure() {
3896 let rec = RecordingRunner::new(
3897 ScriptedRunner::new()
3898 .on(["git", "status"], Reply::ok(" M a.rs\0"))
3899 .on(["git", "stash", "push"], Reply::ok(""))
3900 .on(
3901 ["git", "checkout"],
3902 Reply::fail(1, "error: pathspec 'nope'"),
3903 )
3904 .on(["git", "stash", "pop"], Reply::ok("")),
3905 );
3906 let git = Git::with_runner(&rec);
3907 let err = git
3908 .switch_with_stash(Path::new("/r"), "nope")
3909 .await
3910 .expect_err("checkout error must surface");
3911 assert!(matches!(err, Error::Exit { .. }));
3912 let calls = rec.calls();
3913 assert_eq!(calls.len(), 4);
3914 assert_eq!(calls[3].args_str(), ["stash", "pop"], "restoring pop ran");
3915 }
3916
3917 #[tokio::test]
3920 async fn fetch_from_builds_args_and_retries() {
3921 let rec = RecordingRunner::replying(Reply::ok(""));
3922 let git = Git::with_runner(&rec);
3923 git.fetch_from(Path::new("/r"), "upstream")
3924 .await
3925 .expect("fetch_from");
3926 let call = rec.only_call();
3927 assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
3928 assert!(call.envs.iter().any(|(k, v)| {
3929 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3930 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3931 }));
3932
3933 let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
3934 let git = Git::with_runner(&failing);
3935 assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
3936 assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
3937 }
3938
3939 #[cfg(feature = "mock")]
3942 #[tokio::test]
3943 async fn consumer_mocks_the_interface() {
3944 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
3945 git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
3946 }
3947 let mut mock = MockGitApi::new();
3948 mock.expect_current_branch()
3949 .returning(|_| Ok(Some("main".to_string())));
3950 assert!(on_branch(&mock, "main").await);
3951 }
3952}
3953
3954#[doc = include_str!("../docs/git.md")]
3956#[allow(rustdoc::broken_intra_doc_links)]
3957pub mod guide {
3958 #[doc = include_str!("../docs/security.md")]
3959 #[allow(rustdoc::broken_intra_doc_links)]
3960 pub mod security {}
3961 #[doc = include_str!("../docs/conflicts.md")]
3962 #[allow(rustdoc::broken_intra_doc_links)]
3963 pub mod conflicts {}
3964}