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;
112pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
117pub use processkit::CancellationToken;
120
121pub mod conflict;
122mod parse;
123pub use parse::{BlameLine, Branch, BranchStatus, Commit, StatusEntry, Worktree};
124pub use vcs_diff::{
128 ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as GitVersion, parse_diff,
129};
130use vcs_cli_support::git_credential_helper;
133pub use vcs_cli_support::{
134 Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, RetryPolicy,
135 Secret, StaticCredential, is_lock_contention, is_merge_conflict, is_nothing_to_commit,
136 is_transient_fetch_error, provider_fn,
137};
138
139pub const BINARY: &str = "git";
141
142#[derive(Debug, Clone)]
147#[non_exhaustive]
148pub struct WorktreeAdd {
149 pub path: PathBuf,
151 pub new_branch: Option<String>,
154 pub commitish: Option<String>,
156 pub no_checkout: bool,
159}
160
161impl WorktreeAdd {
162 pub fn checkout(path: impl Into<PathBuf>, commitish: impl Into<String>) -> Self {
165 Self {
166 path: path.into(),
167 new_branch: None,
168 commitish: Some(commitish.into()),
169 no_checkout: false,
170 }
171 }
172
173 pub fn create_branch(
176 path: impl Into<PathBuf>,
177 name: impl Into<String>,
178 commitish: impl Into<String>,
179 ) -> Self {
180 Self {
181 path: path.into(),
182 new_branch: Some(name.into()),
183 commitish: Some(commitish.into()),
184 no_checkout: false,
185 }
186 }
187
188 pub fn no_checkout(mut self) -> Self {
191 self.no_checkout = true;
192 self
193 }
194}
195
196#[derive(Debug, Clone)]
201#[non_exhaustive]
202pub struct GitPush {
203 pub remote: String,
205 pub refspec: String,
207 pub set_upstream: bool,
209}
210
211impl GitPush {
212 pub fn branch(name: impl Into<String>) -> Self {
214 Self {
215 remote: "origin".to_string(),
216 refspec: name.into(),
217 set_upstream: false,
218 }
219 }
220
221 pub fn refspec(local: impl AsRef<str>, remote_branch: impl AsRef<str>) -> Self {
224 Self {
225 remote: "origin".to_string(),
226 refspec: format!("{}:{}", local.as_ref(), remote_branch.as_ref()),
227 set_upstream: false,
228 }
229 }
230
231 pub fn remote(mut self, remote: impl Into<String>) -> Self {
233 self.remote = remote.into();
234 self
235 }
236
237 pub fn set_upstream(mut self) -> Self {
239 self.set_upstream = true;
240 self
241 }
242}
243
244#[derive(Debug, Clone, Default)]
249#[non_exhaustive]
250pub struct CloneSpec {
251 pub branch: Option<String>,
253 pub depth: Option<u32>,
257 pub bare: bool,
259}
260
261impl CloneSpec {
262 pub fn new() -> Self {
264 Self::default()
265 }
266
267 pub fn branch(mut self, branch: impl Into<String>) -> Self {
269 self.branch = Some(branch.into());
270 self
271 }
272
273 pub fn depth(mut self, depth: u32) -> Self {
276 self.depth = Some(depth);
277 self
278 }
279
280 pub fn bare(mut self) -> Self {
282 self.bare = true;
283 self
284 }
285}
286
287#[derive(Debug, Clone)]
292#[non_exhaustive]
293pub struct CommitPaths {
294 pub paths: Vec<PathBuf>,
296 pub message: String,
298 pub amend: bool,
300}
301
302impl CommitPaths {
303 pub fn new(
306 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
307 message: impl Into<String>,
308 ) -> Self {
309 Self {
310 paths: paths.into_iter().map(Into::into).collect(),
311 message: message.into(),
312 amend: false,
313 }
314 }
315
316 pub fn amend(mut self) -> Self {
318 self.amend = true;
319 self
320 }
321}
322
323#[derive(Debug, Clone)]
326pub struct MergeCheckPartial {
327 branch: String,
328}
329
330impl MergeCheckPartial {
331 pub fn into_base(self, base: impl Into<String>) -> MergeCheck {
333 MergeCheck {
334 branch: self.branch,
335 base: base.into(),
336 }
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
346#[non_exhaustive]
347pub struct MergeCheck {
348 pub branch: String,
350 pub base: String,
352}
353
354impl MergeCheck {
355 pub fn branch(name: impl Into<String>) -> MergeCheckPartial {
357 MergeCheckPartial {
358 branch: name.into(),
359 }
360 }
361}
362
363#[derive(Debug, Clone)]
368#[non_exhaustive]
369pub struct MergeCommit {
370 pub branch: String,
372 pub no_ff: bool,
375 pub message: Option<String>,
378}
379
380impl MergeCommit {
381 pub fn branch(name: impl Into<String>) -> Self {
384 Self {
385 branch: name.into(),
386 no_ff: false,
387 message: None,
388 }
389 }
390
391 pub fn no_ff(mut self) -> Self {
394 self.no_ff = true;
395 self
396 }
397
398 pub fn message(mut self, m: impl Into<String>) -> Self {
400 self.message = Some(m.into());
401 self
402 }
403}
404
405#[derive(Debug, Clone)]
410#[non_exhaustive]
411pub struct MergeNoCommit {
412 pub branch: String,
414 pub squash: bool,
417 pub no_ff: bool,
420}
421
422impl MergeNoCommit {
423 pub fn branch(name: impl Into<String>) -> Self {
425 Self {
426 branch: name.into(),
427 squash: false,
428 no_ff: false,
429 }
430 }
431
432 pub fn squash(mut self) -> Self {
434 self.squash = true;
435 self
436 }
437
438 pub fn no_ff(mut self) -> Self {
441 self.no_ff = true;
442 self
443 }
444}
445
446#[derive(Debug, Clone)]
451#[non_exhaustive]
452pub struct AnnotatedTag {
453 pub name: String,
455 pub message: String,
457 pub rev: Option<String>,
459}
460
461impl AnnotatedTag {
462 pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
465 Self {
466 name: name.into(),
467 message: message.into(),
468 rev: None,
469 }
470 }
471
472 pub fn rev(mut self, r: impl Into<String>) -> Self {
474 self.rev = Some(r.into());
475 self
476 }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Hash)]
489pub struct RefName(String);
490
491impl RefName {
492 pub fn new(name: impl Into<String>) -> Result<Self> {
494 let name = name.into();
495 let bad = name.is_empty()
496 || name.starts_with('-')
497 || name.starts_with('.')
498 || name.ends_with('/')
499 || name.ends_with(".lock")
500 || name.contains("..")
501 || name
502 .chars()
503 .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
504 if bad {
505 return Err(Error::Spawn {
506 program: BINARY.to_string(),
507 source: std::io::Error::new(
508 std::io::ErrorKind::InvalidInput,
509 format!("invalid git reference name: {name:?}"),
510 ),
511 });
512 }
513 Ok(RefName(name))
514 }
515
516 pub fn as_str(&self) -> &str {
518 &self.0
519 }
520}
521
522impl std::fmt::Display for RefName {
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, PartialEq, Eq, Hash)]
534pub struct RevSpec(String);
535
536impl RevSpec {
537 pub fn new(rev: impl Into<String>) -> Result<Self> {
539 let rev = rev.into();
540 reject_flag_like("revision", &rev)?;
541 Ok(RevSpec(rev))
542 }
543
544 pub fn as_str(&self) -> &str {
546 &self.0
547 }
548}
549
550impl std::fmt::Display for RevSpec {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 f.write_str(&self.0)
553 }
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560#[non_exhaustive]
561pub struct GitCapabilities {
562 pub version: GitVersion,
564}
565
566const MIN_SUPPORTED_MAJOR: u64 = 2;
576const MIN_SUPPORTED_MINOR: u64 = 31;
577
578impl GitCapabilities {
579 pub fn is_supported(&self) -> bool {
581 (self.version.major, self.version.minor) >= (MIN_SUPPORTED_MAJOR, MIN_SUPPORTED_MINOR)
582 }
583
584 pub fn ensure_supported(&self) -> Result<()> {
587 if self.is_supported() {
588 return Ok(());
589 }
590 Err(Error::Spawn {
591 program: BINARY.to_string(),
592 source: std::io::Error::new(
593 std::io::ErrorKind::Unsupported,
594 format!(
595 "vcs-git requires git >= {MIN_SUPPORTED_MAJOR}.{MIN_SUPPORTED_MINOR} \
596 (validated on 2.54), found {}",
597 self.version
598 ),
599 ),
600 })
601 }
602}
603
604#[cfg_attr(feature = "mock", mockall::automock)]
616#[async_trait::async_trait]
617pub trait GitApi: Send + Sync {
618 async fn run(&self, args: &[String]) -> Result<String>;
625 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
628 async fn version(&self) -> Result<String>;
630 async fn capabilities(&self) -> Result<GitCapabilities>;
634 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
636 async fn status_text(&self, dir: &Path) -> Result<String>;
639 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
643 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
648 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
651 async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
659 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
661 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>>;
668 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
672 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
675 async fn init(&self, dir: &Path) -> Result<()>;
677 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
679 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
681 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
683 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
685 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
687 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
690 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
693 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
696 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
700
701 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
706 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
708 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
711 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
714 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
716 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
721 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
723 async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
726 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
729
730 async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>;
737 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
740 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
742 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
744 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
746 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
748 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
751 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
756 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
758
759 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
763 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
766 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
768 async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
772
773 async fn fetch(&self, dir: &Path) -> Result<()>;
778 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
782 async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
786 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
788 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
790 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
794 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
803 async fn merge_abort(&self, dir: &Path) -> Result<()>;
805 async fn merge_continue(&self, dir: &Path) -> Result<()>;
807 async fn reset_merge(&self, dir: &Path) -> Result<()>;
813 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
815 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
818 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
820 async fn am_abort(&self, dir: &Path) -> Result<()>;
822 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
825 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
828 async fn stash_pop(&self, dir: &Path) -> Result<()>;
830
831 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
835 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
837 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
839 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
841 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
843
844 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
849 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
851 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
854 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
856 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
858 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
864 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
868 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
875 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
877 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
879 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
882
883 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
888 async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
890 async fn rebase_skip(&self, dir: &Path) -> Result<()>;
894}
895
896vcs_cli_support::managed_client! {
897 pub struct Git => BINARY, scrub_env = [
910 "GIT_DIR",
911 "GIT_WORK_TREE",
912 "GIT_INDEX_FILE",
913 "GIT_COMMON_DIR",
914 "GIT_OBJECT_DIRECTORY",
915 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
916 "GIT_NAMESPACE",
917 ]
918}
919
920impl<R: ProcessRunner> Git<R> {
921 pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
928 self.core = self.core.with_retry(policy);
929 self
930 }
931
932 #[must_use]
940 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
941 self.core = self.core.with_credentials(provider);
942 self
943 }
944
945 #[must_use]
952 pub fn with_token(self, token: impl Into<Secret>) -> Self {
953 self.with_credentials(Arc::new(StaticCredential::token(token)))
954 }
955
956 #[must_use]
960 pub fn with_env_token(self, var: impl Into<String>) -> Self {
961 self.with_credentials(Arc::new(EnvToken::new(var)))
962 }
963
964 async fn remote_credentials(
974 &self,
975 expect_host: Option<&str>,
976 ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
977 match self
978 .core
979 .resolve_credential(CredentialService::Git, None)
980 .await?
981 {
982 Some(cred) => {
983 let helper = git_credential_helper(&cred, expect_host);
984 Ok((helper.config_args, helper.env))
985 }
986 None => Ok((Vec::new(), Vec::new())),
987 }
988 }
989}
990
991fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
994 envs.iter()
995 .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
996}
997
998#[async_trait::async_trait]
999impl<R: ProcessRunner> GitApi for Git<R> {
1000 async fn run(&self, args: &[String]) -> Result<String> {
1001 self.core.run(args).await
1002 }
1003
1004 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1005 self.core.output_string(args).await
1006 }
1007
1008 async fn version(&self) -> Result<String> {
1009 self.core.run(["--version"]).await
1010 }
1011
1012 async fn capabilities(&self) -> Result<GitCapabilities> {
1013 let raw = self.version().await?;
1014 let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
1015 program: BINARY.to_string(),
1016 message: format!("unrecognisable `git --version` output: {raw:?}"),
1017 })?;
1018 Ok(GitCapabilities { version })
1019 }
1020
1021 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1022 self.core
1023 .parse(
1024 self.core
1025 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1026 parse::parse_porcelain,
1027 )
1028 .await
1029 }
1030
1031 async fn status_text(&self, dir: &Path) -> Result<String> {
1032 self.core
1033 .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1034 .await
1035 }
1036
1037 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1038 self.core
1044 .parse(
1045 self.core
1046 .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1047 .env("GIT_OPTIONAL_LOCKS", "0"),
1048 parse::parse_porcelain_v2,
1049 )
1050 .await
1051 }
1052
1053 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1054 self.core
1055 .parse(
1056 self.core.command_in(
1057 dir,
1058 ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1059 ),
1060 parse::parse_porcelain,
1061 )
1062 .await
1063 }
1064
1065 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
1066 self.core
1068 .parse(
1069 self.core
1070 .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1071 parse::parse_nul_paths,
1072 )
1073 .await
1074 }
1075
1076 async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1077 let res = self
1086 .core
1087 .output_string(
1088 self.core
1089 .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1090 )
1091 .await?;
1092 match res.code() {
1093 Some(0) => Ok(Some(res.stdout().trim().to_string())),
1094 Some(1) => Ok(None), _ => {
1096 let _ = res.ensure_success()?;
1097 Ok(None) }
1099 }
1100 }
1101
1102 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1103 self.core
1108 .parse(
1109 self.core
1110 .command_in(dir, ["branch", "--no-column", "--no-color"]),
1111 parse::parse_branches,
1112 )
1113 .await
1114 }
1115
1116 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>> {
1117 reject_flag_like("revspec", revspec)?;
1118 let n = format!("-n{max}");
1119 self.core
1120 .parse(
1121 self.core.command_in(
1122 dir,
1123 [
1124 "log",
1125 revspec,
1126 n.as_str(),
1127 "-z",
1128 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1129 ],
1130 ),
1131 parse::parse_log,
1132 )
1133 .await
1134 }
1135
1136 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
1137 reject_flag_like("revision", rev)?;
1138 self.core
1144 .run(self.core.command_in(dir, ["rev-parse", "--verify", rev]))
1145 .await
1146 }
1147
1148 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
1149 reject_flag_like("revision", rev)?;
1150 self.core
1158 .run(
1159 self.core
1160 .command_in(dir, ["rev-parse", "--verify", "--short", rev]),
1161 )
1162 .await
1163 }
1164
1165 async fn init(&self, dir: &Path) -> Result<()> {
1166 self.core
1167 .run_unit(self.core.command_in(dir, ["init"]))
1168 .await
1169 }
1170
1171 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1172 let mut command = self.core.command_in(dir, ["add", "--"]);
1174 for path in paths {
1175 command = command.arg(path);
1176 }
1177 self.core.run_unit(command).await
1178 }
1179
1180 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1181 self.core
1183 .run_unit(c_locale(
1184 self.core.command_in(dir, ["commit", "-m", message]),
1185 ))
1186 .await
1187 }
1188
1189 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
1190 reject_flag_like("branch name", name)?;
1191 self.core
1192 .run_unit(self.core.command_in(dir, ["branch", name]))
1193 .await
1194 }
1195
1196 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
1197 reject_flag_like("reference", reference)?;
1198 self.core
1205 .run_unit(self.core.command_in(dir, ["checkout", reference, "--"]))
1206 .await
1207 }
1208
1209 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1210 reject_flag_like("commit", commit)?;
1211 self.core
1212 .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1213 .await
1214 }
1215
1216 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1217 let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1221 if spec.amend {
1222 command = command.arg("--amend");
1223 }
1224 command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1225 for path in &spec.paths {
1226 command = command.arg(path);
1227 }
1228 self.core.run_unit(command).await
1229 }
1230
1231 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1232 self.core
1233 .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1234 .await
1235 }
1236
1237 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1238 Ok(!self
1242 .core
1243 .probe(
1244 self.core
1245 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1246 )
1247 .await?)
1248 }
1249
1250 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1251 self.core
1254 .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1255 .await
1256 }
1257
1258 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1259 Ok(PathBuf::from(
1260 self.core
1261 .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1262 .await?,
1263 ))
1264 }
1265
1266 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1267 Ok(PathBuf::from(
1268 self.core
1269 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1270 .await?,
1271 ))
1272 }
1273
1274 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1275 reject_flag_like("revision", rev)?;
1276 let spec = format!("{rev}^{{commit}}");
1278 self.core
1279 .run(
1280 self.core
1281 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1282 )
1283 .await
1284 }
1285
1286 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1287 let res = self
1293 .core
1294 .output_string(
1295 self.core
1296 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1297 )
1298 .await?;
1299 match res.code() {
1300 Some(0) => {
1301 let out = res.stdout().trim();
1304 Ok(Some(
1305 out.strip_prefix("refs/remotes/origin/")
1306 .unwrap_or(out)
1307 .to_string(),
1308 ))
1309 }
1310 Some(1) => Ok(None), _ => {
1312 let _ = res.ensure_success()?;
1313 Ok(None) }
1315 }
1316 }
1317
1318 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1319 let refname = format!("refs/heads/{name}");
1320 self.core
1322 .probe(
1323 self.core
1324 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1325 )
1326 .await
1327 }
1328
1329 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1330 let refname = format!("refs/heads/{name}");
1340 let (pre, envs) = self.remote_credentials(None).await?;
1341 let mut args: Vec<String> = pre;
1342 args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1343 let cmd = apply_secret_env(
1344 self.core
1345 .command_in(dir, &args)
1346 .env("GIT_TERMINAL_PROMPT", "0")
1347 .timeout(Duration::from_secs(10)),
1348 &envs,
1349 );
1350 let res = self.core.output_string(cmd).await?;
1351 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1352 }
1353
1354 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1355 reject_flag_like("remote name", remote)?;
1356 self.core
1357 .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1358 .await
1359 }
1360
1361 async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1362 let res = self
1370 .core
1371 .output_string(self.core.command_in(
1372 dir,
1373 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1374 ))
1375 .await?;
1376 match res.code() {
1377 Some(0) => {
1378 let name = res.stdout().trim();
1379 Ok((!name.is_empty()).then(|| name.to_string()))
1380 }
1381 Some(_) => Ok(None), None => {
1383 let _ = res.ensure_success()?; Ok(None) }
1386 }
1387 }
1388
1389 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1390 reject_flag_like("remote name", remote)?;
1391 let (pre, envs) = self.remote_credentials(None).await?;
1395 let mut args: Vec<String> = pre;
1396 args.extend(["ls-remote", "--heads", remote].map(String::from));
1397 let cmd = apply_secret_env(
1398 self.core
1399 .command_in(dir, &args)
1400 .env("GIT_TERMINAL_PROMPT", "0"),
1401 &envs,
1402 );
1403 self.core.parse(cmd, parse::parse_ls_remote_heads).await
1404 }
1405
1406 async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
1407 reject_flag_like("branch", &spec.branch)?;
1408 reject_flag_like("base", &spec.base)?;
1409 let out = self
1414 .core
1415 .run(self.core.command_in(
1416 dir,
1417 [
1418 "branch",
1419 "--merged",
1420 spec.base.as_str(),
1421 "--no-column",
1422 "--no-color",
1423 ],
1424 ))
1425 .await?;
1426 Ok(out
1430 .lines()
1431 .filter_map(|line| line.get(2..))
1432 .any(|b| b == spec.branch.as_str()))
1433 }
1434
1435 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1436 reject_flag_like("branch name", branch)?;
1437 let flag = format!("--set-upstream-to={upstream}");
1438 self.core
1439 .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1440 .await
1441 }
1442
1443 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1444 reject_flag_like("branch name", name)?;
1445 let flag = if force { "-D" } else { "-d" };
1446 self.core
1447 .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1448 .await
1449 }
1450
1451 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1452 reject_flag_like("branch name", old)?;
1453 reject_flag_like("branch name", new)?;
1454 self.core
1455 .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1456 .await
1457 }
1458
1459 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1460 reject_flag_like("range", range)?;
1461 self.core
1462 .try_parse(
1463 self.core.command_in(dir, ["rev-list", "--count", range]),
1464 |s| {
1465 s.trim().parse::<usize>().map_err(|e| Error::Parse {
1466 program: BINARY.to_string(),
1467 message: e.to_string(),
1468 })
1469 },
1470 )
1471 .await
1472 }
1473
1474 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1475 reject_flag_like("range", range)?;
1476 self.core
1484 .probe(self.core.command_in(dir, ["diff", "--quiet", range, "--"]))
1485 .await
1486 }
1487
1488 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1489 reject_flag_like("range", range)?;
1490 self.core
1498 .parse(
1499 c_locale(
1500 self.core
1501 .command_in(dir, ["diff", "--shortstat", range, "--"]),
1502 ),
1503 parse::parse_shortstat,
1504 )
1505 .await
1506 }
1507
1508 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1509 let target = match spec {
1513 DiffSpec::WorkingTree => {
1514 if self.is_unborn(dir).await? {
1518 EMPTY_TREE.to_string()
1519 } else {
1520 "HEAD".to_string()
1521 }
1522 }
1523 DiffSpec::Rev(rev) => {
1524 reject_flag_like("revision", &rev)?;
1525 rev
1526 }
1527 };
1528 self.core
1541 .run_untrimmed(self.core.command_in(
1542 dir,
1543 [
1544 "diff",
1545 target.as_str(),
1546 "--no-color",
1547 "--no-ext-diff",
1548 "-M",
1549 "--src-prefix=a/",
1550 "--dst-prefix=b/",
1551 "--",
1552 ],
1553 ))
1554 .await
1555 }
1556
1557 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1558 let text = self.diff_text(dir, spec).await?;
1559 Ok(parse_diff(&text))
1560 }
1561
1562 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1563 self.core
1565 .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1566 .await
1567 }
1568
1569 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1570 let git_dir = self.resolved_git_dir(dir).await?;
1571 let rebase_apply = git_dir.join("rebase-apply");
1576 let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
1577 Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
1578 }
1579
1580 async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
1581 Ok(self
1584 .resolved_git_dir(dir)
1585 .await?
1586 .join("rebase-apply")
1587 .join("applying")
1588 .exists())
1589 }
1590
1591 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1592 Ok(self
1593 .resolved_git_dir(dir)
1594 .await?
1595 .join("MERGE_HEAD")
1596 .exists())
1597 }
1598
1599 async fn fetch(&self, dir: &Path) -> Result<()> {
1600 let (pre, envs) = self.remote_credentials(None).await?;
1608 let mut args: Vec<String> = pre;
1609 args.extend(["fetch", "--quiet"].map(String::from));
1610 let cmd = apply_secret_env(
1611 c_locale(self.core.command_in(dir, &args))
1612 .env("GIT_TERMINAL_PROMPT", "0")
1613 .timeout_grace(FETCH_TIMEOUT_GRACE)
1616 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1617 &envs,
1618 );
1619 self.core.run_unit(cmd).await
1620 }
1621
1622 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1623 reject_flag_like("remote", remote)?;
1627 let (pre, envs) = self.remote_credentials(None).await?;
1630 let mut args: Vec<String> = pre;
1631 args.extend(["fetch", "--quiet", remote].map(String::from));
1632 let cmd = apply_secret_env(
1633 c_locale(self.core.command_in(dir, &args))
1634 .env("GIT_TERMINAL_PROMPT", "0")
1635 .timeout_grace(FETCH_TIMEOUT_GRACE)
1636 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1637 &envs,
1638 );
1639 self.core.run_unit(cmd).await
1640 }
1641
1642 async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1643 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1644 let (pre, envs) = self.remote_credentials(None).await?;
1645 let mut args: Vec<String> = pre;
1646 args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
1647 let cmd = apply_secret_env(
1648 c_locale(self.core.command_in(dir, &args))
1649 .env("GIT_TERMINAL_PROMPT", "0")
1650 .timeout_grace(FETCH_TIMEOUT_GRACE)
1651 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1652 &envs,
1653 );
1654 self.core.run_unit(cmd).await
1655 }
1656
1657 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1658 reject_flag_like("remote", &spec.remote)?;
1659 reject_flag_like("refspec", &spec.refspec)?;
1660 let sides: Vec<&str> = spec.refspec.split(':').collect();
1669 if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
1670 return Err(processkit::Error::Spawn {
1671 program: BINARY.to_string(),
1672 source: std::io::Error::new(
1673 std::io::ErrorKind::InvalidInput,
1674 format!(
1675 "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
1676 metacharacter — pass a plain branch or `local:remote`, or use \
1677 `run([\"push\", …])` for a force-push",
1678 spec.refspec
1679 ),
1680 ),
1681 });
1682 }
1683 let (pre, envs) = self.remote_credentials(None).await?;
1684 let mut args: Vec<String> = pre;
1685 args.push("push".to_string());
1686 if spec.set_upstream {
1687 args.push("-u".to_string());
1688 }
1689 args.push(spec.remote.clone());
1690 args.push(spec.refspec.clone());
1691 let cmd = apply_secret_env(
1692 self.core
1693 .command_in(dir, &args)
1694 .env("GIT_TERMINAL_PROMPT", "0")
1695 .timeout_grace(FETCH_TIMEOUT_GRACE),
1700 &envs,
1701 );
1702 self.core.run_unit(cmd).await
1703 }
1704
1705 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1706 reject_flag_like("branch", branch)?;
1707 self.core
1710 .run_unit(c_locale(
1711 self.core.command_in(dir, ["merge", "--squash", branch]),
1712 ))
1713 .await
1714 }
1715
1716 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1717 reject_flag_like("branch", &spec.branch)?;
1718 let mut args: Vec<&str> = vec!["merge"];
1719 if spec.no_ff {
1720 args.push("--no-ff");
1721 }
1722 if let Some(msg) = spec.message.as_deref() {
1723 args.push("-m");
1724 args.push(msg);
1725 } else {
1726 args.push("--no-edit");
1729 }
1730 args.push(&spec.branch);
1731 self.core
1733 .run_unit(c_locale(self.core.command_in(dir, args)))
1734 .await
1735 }
1736
1737 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1738 reject_flag_like("branch", &spec.branch)?;
1739 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1740 if spec.squash {
1743 args.push("--squash");
1744 } else if spec.no_ff {
1745 args.push("--no-ff");
1746 }
1747 args.push(&spec.branch);
1748 self.core
1750 .run_unit(c_locale(self.core.command_in(dir, args)))
1751 .await
1752 }
1753
1754 async fn merge_abort(&self, dir: &Path) -> Result<()> {
1755 self.core
1756 .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1757 .await
1758 }
1759
1760 async fn merge_continue(&self, dir: &Path) -> Result<()> {
1761 self.core
1766 .run_unit(no_editor(c_locale(
1767 self.core.command_in(dir, ["commit", "--no-edit"]),
1768 )))
1769 .await
1770 }
1771
1772 async fn reset_merge(&self, dir: &Path) -> Result<()> {
1773 self.core
1774 .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1775 .await
1776 }
1777
1778 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1779 reject_flag_like("revision", rev)?;
1780 self.core
1781 .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1782 .await
1783 }
1784
1785 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1786 reject_flag_like("rebase target", onto)?;
1787 self.core
1791 .run_unit(no_editor(c_locale(
1792 self.core.command_in(dir, ["rebase", onto]),
1793 )))
1794 .await
1795 }
1796
1797 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1798 self.core
1799 .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1800 .await
1801 }
1802
1803 async fn am_abort(&self, dir: &Path) -> Result<()> {
1804 self.core
1805 .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
1806 .await
1807 }
1808
1809 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1810 self.core
1811 .run_unit(no_editor(c_locale(
1812 self.core.command_in(dir, ["rebase", "--continue"]),
1813 )))
1814 .await
1815 }
1816
1817 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1818 let mut command = self.core.command_in(dir, ["stash", "push"]);
1819 if include_untracked {
1820 command = command.arg("--include-untracked");
1821 }
1822 self.core.run_unit(command).await
1823 }
1824
1825 async fn stash_pop(&self, dir: &Path) -> Result<()> {
1826 self.core
1830 .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
1831 .await
1832 }
1833
1834 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1835 self.core
1836 .parse(
1837 self.core
1838 .command_in(dir, ["worktree", "list", "--porcelain"]),
1839 parse::parse_worktree_porcelain,
1840 )
1841 .await
1842 }
1843
1844 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1845 if let Some(name) = spec.new_branch.as_deref() {
1846 reject_flag_like("branch name", name)?;
1847 }
1848 if let Some(commitish) = spec.commitish.as_deref() {
1849 reject_flag_like("commit-ish", commitish)?;
1850 }
1851 let mut command = self.core.command_in(dir, ["worktree", "add"]);
1852 if let Some(name) = spec.new_branch.as_deref() {
1853 command = command.arg("-b").arg(name);
1854 }
1855 if spec.no_checkout {
1856 command = command.arg("--no-checkout");
1857 }
1858 command = command.arg(&spec.path);
1859 if let Some(commitish) = spec.commitish.as_deref() {
1860 command = command.arg(commitish);
1861 }
1862 self.core.run_unit(command).await
1863 }
1864
1865 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1866 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1867 if force {
1868 command = command.arg("--force");
1869 }
1870 command = command.arg(path);
1871 self.core.run_unit(command).await
1872 }
1873
1874 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1875 let command = self
1876 .core
1877 .command_in(dir, ["worktree", "move"])
1878 .arg(from)
1879 .arg(to);
1880 self.core.run_unit(command).await
1881 }
1882
1883 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1884 self.core
1885 .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1886 .await
1887 }
1888
1889 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1890 reject_flag_like("url", url)?;
1894 let (pre, envs) = self
1900 .remote_credentials(vcs_cli_support::https_host(url).as_deref())
1901 .await?;
1902 let mut initial: Vec<String> = pre;
1903 initial.push("clone".to_string());
1904 let mut command = self.core.command(&initial);
1905 if let Some(branch) = spec.branch.as_deref() {
1906 command = command.arg("--branch").arg(branch);
1907 }
1908 if let Some(depth) = spec.depth {
1909 command = command.arg("--depth").arg(depth.to_string());
1910 }
1911 if spec.bare {
1912 command = command.arg("--bare");
1913 }
1914 let command = apply_secret_env(
1915 command
1916 .arg(url)
1917 .arg(dest)
1918 .env("GIT_TERMINAL_PROMPT", "0")
1919 .timeout_grace(FETCH_TIMEOUT_GRACE),
1922 &envs,
1923 );
1924
1925 let cleanable = match std::fs::read_dir(dest) {
1937 Err(_) => true, Ok(mut entries) => entries.next().is_none(), };
1940 let result = self.core.run_unit(command).await;
1941 if result.is_err() && cleanable {
1942 let _ = std::fs::remove_dir_all(dest);
1943 }
1944 result
1945 }
1946
1947 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1948 reject_flag_like("tag name", name)?;
1949 if let Some(rev) = rev.as_deref() {
1950 reject_flag_like("revision", rev)?;
1951 }
1952 let mut args = vec!["tag", name];
1953 if let Some(rev) = rev.as_deref() {
1954 args.push(rev);
1955 }
1956 self.core.run_unit(self.core.command_in(dir, args)).await
1957 }
1958
1959 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1960 reject_flag_like("tag name", &spec.name)?;
1961 if let Some(rev) = spec.rev.as_deref() {
1962 reject_flag_like("revision", rev)?;
1963 }
1964 let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1965 if let Some(rev) = spec.rev.as_deref() {
1966 args.push(rev);
1967 }
1968 self.core.run_unit(self.core.command_in(dir, args)).await
1969 }
1970
1971 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1972 let out = self
1975 .core
1976 .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1977 .await?;
1978 Ok(out.lines().map(str::to_string).collect())
1979 }
1980
1981 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1982 reject_flag_like("tag name", name)?;
1983 self.core
1984 .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1985 .await
1986 }
1987
1988 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1989 reject_flag_like("revision", rev)?;
1992 #[cfg(windows)]
1997 let path = path.replace('\\', "/");
1998 let spec = format!("{rev}:{path}");
1999 self.core
2002 .run_untrimmed(self.core.command_in(dir, ["show", spec.as_str()]))
2003 .await
2004 }
2005
2006 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
2007 reject_flag_like("config key", key)?;
2008 let res = self
2009 .core
2010 .output_string(self.core.command_in(dir, ["config", "--get", key]))
2011 .await?;
2012 match res.code() {
2013 Some(1) => Ok(None),
2015 Some(0) => Ok(Some(
2020 res.stdout().trim_end_matches(['\r', '\n']).to_string(),
2021 )),
2022 _ => {
2023 let _ = res.ensure_success()?;
2024 Ok(None) }
2026 }
2027 }
2028
2029 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
2030 reject_flag_like("config key", key)?;
2031 self.core
2032 .run_unit(self.core.command_in(dir, ["config", key, value]))
2033 .await
2034 }
2035
2036 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2037 reject_flag_like("remote name", name)?;
2038 reject_flag_like("url", url)?;
2039 self.core
2040 .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
2041 .await
2042 }
2043
2044 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2045 reject_flag_like("remote name", name)?;
2046 reject_flag_like("url", url)?;
2047 self.core
2048 .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
2049 .await
2050 }
2051
2052 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
2053 let mut args = vec!["blame", "--line-porcelain"];
2054 if let Some(rev) = rev.as_deref() {
2055 reject_flag_like("revision", rev)?;
2058 args.push(rev);
2059 }
2060 args.push("--");
2061 args.push(path);
2062 self.core
2063 .parse(
2064 self.core.command_in(dir, args),
2065 parse::parse_blame_porcelain,
2066 )
2067 .await
2068 }
2069
2070 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
2071 reject_flag_like("revision", rev)?;
2072 self.core
2075 .run_unit(no_editor(c_locale(
2076 self.core.command_in(dir, ["cherry-pick", rev]),
2077 )))
2078 .await
2079 }
2080
2081 async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
2082 reject_flag_like("revision", rev)?;
2083 self.core
2084 .run_unit(no_editor(c_locale(
2085 self.core.command_in(dir, ["revert", "--no-edit", rev]),
2086 )))
2087 .await
2088 }
2089
2090 async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2091 self.core
2092 .run_unit(no_editor(c_locale(
2093 self.core.command_in(dir, ["rebase", "--skip"]),
2094 )))
2095 .await
2096 }
2097}
2098
2099pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2110
2111const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2114const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2115const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2116
2117fn no_editor(cmd: processkit::Command) -> processkit::Command {
2121 cmd.env("GIT_EDITOR", "true")
2122 .env("GIT_SEQUENCE_EDITOR", "true")
2123}
2124
2125fn c_locale(cmd: processkit::Command) -> processkit::Command {
2131 cmd.env("LC_ALL", "C")
2132}
2133
2134fn reject_flag_like(what: &str, value: &str) -> Result<()> {
2138 vcs_cli_support::reject_flag_like(BINARY, what, value)
2139}
2140
2141impl<R: ProcessRunner> Git<R> {
2142 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
2147 self.core.run(args).await
2148 }
2149
2150 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
2153 self.core.output_string(args).await
2154 }
2155
2156 pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
2161 GitAt { git: self, dir }
2162 }
2163
2164 pub fn harden(self) -> Self {
2234 let removed = [
2235 "GIT_DIR",
2240 "GIT_WORK_TREE",
2241 "GIT_INDEX_FILE",
2242 "GIT_COMMON_DIR",
2243 "GIT_OBJECT_DIRECTORY",
2244 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
2245 "GIT_NAMESPACE",
2246 "GIT_CEILING_DIRECTORIES",
2247 "GIT_CONFIG_PARAMETERS",
2248 "GIT_CONFIG_GLOBAL",
2249 "GIT_CONFIG_SYSTEM",
2250 "GIT_SSH_COMMAND",
2252 "GIT_SSH",
2253 "GIT_ASKPASS",
2254 "GIT_EXTERNAL_DIFF",
2255 "GIT_PAGER",
2256 "GIT_EDITOR",
2257 "GIT_SEQUENCE_EDITOR",
2258 "GIT_PROXY_COMMAND",
2263 "GIT_EXEC_PATH",
2264 "GIT_TEMPLATE_DIR",
2265 "GIT_LITERAL_PATHSPECS",
2268 "GIT_GLOB_PATHSPECS",
2269 "GIT_NOGLOB_PATHSPECS",
2270 "GIT_ICASE_PATHSPECS",
2271 ];
2272 let mut hardened = self;
2273 for key in removed {
2274 hardened = hardened.default_env_remove(key);
2275 }
2276 hardened
2277 .default_env("GIT_CONFIG_NOSYSTEM", "1")
2278 .default_env("GIT_TERMINAL_PROMPT", "0")
2279 .default_env("GIT_CONFIG_COUNT", "3")
2284 .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
2285 .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
2292 .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
2293 .default_env("GIT_CONFIG_VALUE_1", "false")
2294 .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
2300 .default_env("GIT_CONFIG_VALUE_2", "")
2301 }
2302
2303 pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
2326 if self.status(dir).await?.is_empty() {
2329 return self.checkout(dir, branch).await;
2330 }
2331 let depth_before = self.stash_depth(dir).await?;
2338 self.stash_push(dir, true).await?;
2339 if self.stash_depth(dir).await? <= depth_before {
2340 return self.checkout(dir, branch).await;
2342 }
2343 match self.checkout(dir, branch).await {
2346 Ok(()) => self.stash_pop_index(dir).await,
2347 Err(err) => {
2348 let _ = self.stash_pop_index(dir).await;
2352 Err(err)
2353 }
2354 }
2355 }
2356
2357 async fn stash_depth(&self, dir: &Path) -> Result<usize> {
2361 let out = self
2362 .core
2363 .run(self.core.command_in(dir, ["stash", "list"]))
2364 .await?;
2365 Ok(out.lines().filter(|l| !l.is_empty()).count())
2366 }
2367
2368 async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
2372 self.core
2373 .run_unit(c_locale(
2374 self.core.command_in(dir, ["stash", "pop", "--index"]),
2375 ))
2376 .await
2377 }
2378
2379 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
2382 let git_dir = PathBuf::from(
2383 self.core
2384 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
2385 .await?,
2386 );
2387 Ok(if git_dir.is_absolute() {
2388 git_dir
2389 } else {
2390 dir.join(git_dir)
2391 })
2392 }
2393}
2394
2395impl Git {
2396 pub fn hardened() -> Self {
2399 Self::new().harden()
2400 }
2401}
2402
2403pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
2408 git: &'a Git<R>,
2409 dir: &'a Path,
2410}
2411
2412impl<R: ProcessRunner> Clone for GitAt<'_, R> {
2417 fn clone(&self) -> Self {
2418 *self
2419 }
2420}
2421impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
2422
2423vcs_cli_support::at_forwarders! {
2427 GitAt, git, "Git",
2428 bare {
2429 fn run(args: &[String]) -> Result<String>;
2430 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
2431 fn run_args(args: &[&str]) -> Result<String>;
2432 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
2433 fn version() -> Result<String>;
2434 fn capabilities() -> Result<GitCapabilities>;
2435 fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
2436 }
2437 dir {
2438 fn status() -> Result<Vec<StatusEntry>>;
2439 fn status_text() -> Result<String>;
2440 fn status_tracked() -> Result<Vec<StatusEntry>>;
2441 fn branch_status() -> Result<BranchStatus>;
2442 fn conflicted_files() -> Result<Vec<String>>;
2443 fn current_branch() -> Result<Option<String>>;
2444 fn branches() -> Result<Vec<Branch>>;
2445 fn log(revspec: &str, max: usize) -> Result<Vec<Commit>>;
2446 fn rev_parse(rev: &str) -> Result<String>;
2447 fn rev_parse_short(rev: &str) -> Result<String>;
2448 fn init() -> Result<()>;
2449 fn add(paths: &[PathBuf]) -> Result<()>;
2450 fn commit(message: &str) -> Result<()>;
2451 fn create_branch(name: &str) -> Result<()>;
2452 fn checkout(reference: &str) -> Result<()>;
2453 fn checkout_detach(commit: &str) -> Result<()>;
2454 fn commit_paths(spec: CommitPaths) -> Result<()>;
2455 fn last_commit_message() -> Result<String>;
2456 fn is_unborn() -> Result<bool>;
2457 fn diff_is_empty() -> Result<bool>;
2458 fn common_dir() -> Result<PathBuf>;
2459 fn git_dir() -> Result<PathBuf>;
2460 fn resolve_commit(rev: &str) -> Result<String>;
2461 fn remote_head_branch() -> Result<Option<String>>;
2462 fn branch_exists(name: &str) -> Result<bool>;
2463 fn remote_branch_exists(name: &str) -> Result<bool>;
2464 fn remote_url(remote: &str) -> Result<String>;
2465 fn upstream() -> Result<Option<String>>;
2466 fn remote_branches(remote: &str) -> Result<Vec<String>>;
2467 fn is_merged(spec: MergeCheck) -> Result<bool>;
2468 fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
2469 fn delete_branch(name: &str, force: bool) -> Result<()>;
2470 fn rename_branch(old: &str, new: &str) -> Result<()>;
2471 fn rev_list_count(range: &str) -> Result<usize>;
2472 fn diff_range_is_empty(range: &str) -> Result<bool>;
2473 fn diff_stat(range: &str) -> Result<DiffStat>;
2474 fn diff_text(spec: DiffSpec) -> Result<String>;
2475 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
2476 fn staged_is_empty() -> Result<bool>;
2477 fn is_rebase_in_progress() -> Result<bool>;
2478 fn is_merge_in_progress() -> Result<bool>;
2479 fn is_am_in_progress() -> Result<bool>;
2480 fn fetch() -> Result<()>;
2481 fn fetch_from(remote: &str) -> Result<()>;
2482 fn fetch_branch(branch: &str) -> Result<()>;
2483 fn push(spec: GitPush) -> Result<()>;
2484 fn merge_squash(branch: &str) -> Result<()>;
2485 fn merge_commit(spec: MergeCommit) -> Result<()>;
2486 fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
2487 fn merge_abort() -> Result<()>;
2488 fn merge_continue() -> Result<()>;
2489 fn reset_merge() -> Result<()>;
2490 fn reset_hard(rev: &str) -> Result<()>;
2491 fn rebase(onto: &str) -> Result<()>;
2492 fn rebase_abort() -> Result<()>;
2493 fn am_abort() -> Result<()>;
2494 fn rebase_continue() -> Result<()>;
2495 fn stash_push(include_untracked: bool) -> Result<()>;
2496 fn stash_pop() -> Result<()>;
2497 fn switch_with_stash(branch: &str) -> Result<()>;
2498 fn worktree_list() -> Result<Vec<Worktree>>;
2499 fn worktree_add(spec: WorktreeAdd) -> Result<()>;
2500 fn worktree_remove(path: &Path, force: bool) -> Result<()>;
2501 fn worktree_move(from: &Path, to: &Path) -> Result<()>;
2502 fn worktree_prune() -> Result<()>;
2503 fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
2504 fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2505 fn tag_list() -> Result<Vec<String>>;
2506 fn tag_delete(name: &str) -> Result<()>;
2507 fn show_file(rev: &str, path: &str) -> Result<String>;
2508 fn config_get(key: &str) -> Result<Option<String>>;
2509 fn config_set(key: &str, value: &str) -> Result<()>;
2510 fn remote_add(name: &str, url: &str) -> Result<()>;
2511 fn remote_set_url(name: &str, url: &str) -> Result<()>;
2512 fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2513 fn cherry_pick(rev: &str) -> Result<()>;
2514 fn revert(rev: &str) -> Result<()>;
2515 fn rebase_skip() -> Result<()>;
2516 }
2517}
2518
2519pub mod blocking {
2523 use std::path::Path;
2524 use std::process::Command;
2525
2526 pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2528 let mut cmd = Command::new(super::BINARY);
2529 cmd.current_dir(dir).args(["worktree", "remove"]);
2530 if force {
2531 cmd.arg("--force");
2532 }
2533 cmd.arg(path);
2534 let status = cmd.status()?;
2535 if status.success() {
2536 Ok(())
2537 } else {
2538 Err(std::io::Error::other(format!(
2539 "`git worktree remove` exited with {status}"
2540 )))
2541 }
2542 }
2543}
2544
2545#[cfg(test)]
2546mod tests {
2547 use super::*;
2548 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
2549
2550 #[test]
2551 fn binary_name_is_git() {
2552 assert_eq!(BINARY, "git");
2553 }
2554
2555 #[allow(dead_code)]
2559 fn bound_view_is_copy_for_default_runner() {
2560 fn assert_copy<T: Copy>() {}
2561 assert_copy::<GitAt<'static, processkit::JobRunner>>();
2562 }
2563
2564 #[tokio::test]
2568 async fn bound_view_matches_dir_taking_calls() {
2569 let dir = Path::new("/repo");
2570 let rec = RecordingRunner::replying(Reply::ok(""));
2571 let git = Git::with_runner(&rec);
2572
2573 git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2575 .await
2576 .unwrap();
2577 git.at(dir)
2578 .merge_commit(MergeCommit::branch("feat").no_ff())
2579 .await
2580 .unwrap();
2581 git.worktree_remove(dir, Path::new("/wt"), true)
2583 .await
2584 .unwrap();
2585 git.at(dir)
2586 .worktree_remove(Path::new("/wt"), true)
2587 .await
2588 .unwrap();
2589 git.conflicted_files(dir).await.unwrap();
2591 git.at(dir).conflicted_files().await.unwrap();
2592 git.tag_delete(dir, "v1").await.unwrap();
2594 git.at(dir).tag_delete("v1").await.unwrap();
2595
2596 let calls = rec.calls();
2597 assert_eq!(calls[0].args_str(), calls[1].args_str());
2598 assert_eq!(calls[2].args_str(), calls[3].args_str());
2599 assert_eq!(calls[4].args_str(), calls[5].args_str());
2600 assert_eq!(calls[6].args_str(), calls[7].args_str());
2601 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2603 assert_eq!(calls[3].cwd.as_deref(), Some(dir));
2604 }
2605
2606 #[tokio::test]
2609 async fn status_parses_scripted_output() {
2610 let git = Git::with_runner(
2612 ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
2613 );
2614 let entries = git.status(Path::new(".")).await.expect("status");
2615 assert_eq!(entries.len(), 2);
2616 assert_eq!(entries[0].code, " M");
2617 assert_eq!(entries[1].path, "b.rs");
2618 }
2619
2620 #[tokio::test]
2622 async fn status_tracked_excludes_untracked_flag() {
2623 let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2624 let git = Git::with_runner(&rec);
2625 let entries = git.status_tracked(Path::new(".")).await.expect("status");
2626 assert_eq!(entries.len(), 1);
2627 assert_eq!(entries[0].code, " M");
2628 assert_eq!(
2629 rec.only_call().args_str(),
2630 ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2631 );
2632 }
2633
2634 #[tokio::test]
2637 async fn branch_status_builds_v2_branch_args_and_parses() {
2638 let out = concat!(
2639 "# branch.oid abc\0",
2640 "# branch.head main\0",
2641 "# branch.upstream origin/main\0",
2642 "# branch.ab +1 -0\0",
2643 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2644 "? new.txt\0",
2645 );
2646 let rec = RecordingRunner::replying(Reply::ok(out));
2647 let git = Git::with_runner(&rec);
2648 let s = git
2649 .branch_status(Path::new("."))
2650 .await
2651 .expect("branch_status");
2652 assert_eq!(
2653 rec.only_call().args_str(),
2654 ["status", "--porcelain=v2", "--branch", "-z"]
2655 );
2656 assert!(rec.only_call().envs.iter().any(|(k, v)| {
2659 k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2660 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2661 }));
2662 assert_eq!(s.branch.as_deref(), Some("main"));
2663 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2664 assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2665 assert_eq!(s.tracked_changes, 1);
2666 assert_eq!(s.untracked, 1);
2667 assert!(s.is_dirty());
2668 }
2669
2670 #[tokio::test]
2672 async fn conflicted_files_builds_args_and_parses_nul_list() {
2673 let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2674 let git = Git::with_runner(&rec);
2675 let paths = git
2676 .conflicted_files(Path::new("."))
2677 .await
2678 .expect("conflicted_files");
2679 assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2680 assert_eq!(
2681 rec.only_call().args_str(),
2682 ["diff", "--name-only", "--diff-filter=U", "-z"]
2683 );
2684 }
2685
2686 #[tokio::test]
2687 async fn rev_parse_short_builds_short_flag() {
2688 let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2689 let git = Git::with_runner(&rec);
2690 let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2691 assert_eq!(out, "a1b2c3d");
2692 assert_eq!(
2693 rec.only_call().args_str(),
2694 ["rev-parse", "--verify", "--short", "HEAD"]
2695 );
2696 }
2697
2698 #[tokio::test]
2701 async fn rev_parse_verifies_the_revision() {
2702 let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
2703 let git = Git::with_runner(&rec);
2704 let out = git.rev_parse(Path::new("/r"), "HEAD").await.unwrap();
2705 assert_eq!(out, "deadbeef");
2706 assert_eq!(
2707 rec.only_call().args_str(),
2708 ["rev-parse", "--verify", "HEAD"]
2709 );
2710 }
2711
2712 #[tokio::test]
2716 async fn distinguishes_git_am_from_an_apply_backend_rebase() {
2717 use vcs_testkit::TempDir;
2718 let gd = TempDir::new("m20-am");
2719 let git = Git::with_runner(ScriptedRunner::new().on(
2720 ["git", "rev-parse", "--git-dir"],
2721 Reply::ok(gd.path().to_str().unwrap()),
2722 ));
2723 let apply = gd.path().join("rebase-apply");
2724 std::fs::create_dir_all(&apply).unwrap();
2725
2726 std::fs::write(apply.join("applying"), b"").unwrap();
2728 assert!(
2729 git.is_am_in_progress(Path::new("/r")).await.unwrap(),
2730 "am detected"
2731 );
2732 assert!(
2733 !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2734 "a git am is NOT reported as a rebase"
2735 );
2736
2737 std::fs::remove_file(apply.join("applying")).unwrap();
2739 assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
2740 assert!(
2741 git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2742 "a bare rebase-apply dir is a rebase"
2743 );
2744 }
2745
2746 #[tokio::test]
2748 async fn nonzero_exit_is_structured_error() {
2749 let git = Git::with_runner(
2750 ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
2751 );
2752 match git.status(Path::new(".")).await.unwrap_err() {
2753 Error::Exit { code, stderr, .. } => {
2754 assert_eq!(code, 128);
2755 assert!(stderr.contains("not a git repository"), "{stderr}");
2756 }
2757 other => panic!("expected Exit, got {other:?}"),
2758 }
2759 }
2760
2761 #[tokio::test]
2764 async fn diff_is_empty_maps_exit_codes() {
2765 let clean =
2766 Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
2767 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2768
2769 let dirty = Git::with_runner(
2770 ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
2771 );
2772 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2773
2774 let broken = Git::with_runner(ScriptedRunner::new().on(
2775 ["git", "diff", "--quiet"],
2776 Reply::fail(128, "fatal: not a repo"),
2777 ));
2778 assert!(matches!(
2779 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2780 Error::Exit { code: 128, .. }
2781 ));
2782 }
2783
2784 #[tokio::test]
2787 async fn add_inserts_pathspec_separator() {
2788 let git = Git::with_runner(ScriptedRunner::new().on(["git", "add", "--"], Reply::ok("")));
2789 git.add(Path::new("."), &[PathBuf::from("f.rs")])
2790 .await
2791 .expect("add should build `add -- <paths>`");
2792 }
2793
2794 #[tokio::test]
2795 async fn worktree_list_parses_porcelain() {
2796 let git = Git::with_runner(ScriptedRunner::new().on(
2797 ["git", "worktree", "list"],
2798 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2799 ));
2800 let wts = git.worktree_list(Path::new(".")).await.expect("list");
2801 assert_eq!(wts.len(), 1);
2802 assert_eq!(wts[0].branch.as_deref(), Some("main"));
2803 assert_eq!(wts[0].head.as_deref(), Some("abc"));
2804 }
2805
2806 #[tokio::test]
2809 async fn worktree_add_builds_branch_path_and_base() {
2810 let rec = RecordingRunner::replying(Reply::ok(""));
2811 let git = Git::with_runner(&rec);
2812 git.worktree_add(
2813 Path::new("/repo"),
2814 WorktreeAdd::create_branch("/wt", "feature", "main"),
2815 )
2816 .await
2817 .expect("worktree add");
2818 assert_eq!(
2819 rec.only_call().args_str(),
2820 ["worktree", "add", "-b", "feature", "/wt", "main"]
2821 );
2822 }
2823
2824 #[tokio::test]
2825 async fn worktree_remove_passes_force_then_path() {
2826 let rec = RecordingRunner::replying(Reply::ok(""));
2827 let git = Git::with_runner(&rec);
2828 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2829 .await
2830 .expect("remove");
2831 assert_eq!(
2832 rec.only_call().args_str(),
2833 ["worktree", "remove", "--force", "/wt"]
2834 );
2835 }
2836
2837 #[tokio::test]
2839 async fn worktree_add_no_checkout_inserts_flag() {
2840 let rec = RecordingRunner::replying(Reply::ok(""));
2841 let git = Git::with_runner(&rec);
2842 git.worktree_add(
2843 Path::new("/repo"),
2844 WorktreeAdd::checkout("/wt", "main").no_checkout(),
2845 )
2846 .await
2847 .expect("worktree add");
2848 assert_eq!(
2849 rec.only_call().args_str(),
2850 ["worktree", "add", "--no-checkout", "/wt", "main"]
2851 );
2852 }
2853
2854 #[tokio::test]
2855 async fn checkout_detach_builds_args() {
2856 let rec = RecordingRunner::replying(Reply::ok(""));
2857 let git = Git::with_runner(&rec);
2858 git.checkout_detach(Path::new("."), "abc123")
2859 .await
2860 .expect("detach");
2861 assert_eq!(
2862 rec.only_call().args_str(),
2863 ["checkout", "--detach", "abc123"]
2864 );
2865 }
2866
2867 #[tokio::test]
2871 async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
2872 let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
2874 let on_branch = Git::with_runner(&rec);
2875 assert_eq!(
2876 on_branch.current_branch(Path::new(".")).await.unwrap(),
2877 Some("feature/x".to_string())
2878 );
2879 assert_eq!(
2880 rec.only_call().args_str(),
2881 ["symbolic-ref", "--quiet", "--short", "HEAD"]
2882 );
2883 let unborn = Git::with_runner(
2886 ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
2887 );
2888 assert_eq!(
2889 unborn.current_branch(Path::new(".")).await.unwrap(),
2890 Some("main".to_string())
2891 );
2892 let detached =
2894 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2895 assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
2896 let not_repo = Git::with_runner(ScriptedRunner::new().on(
2898 ["git", "symbolic-ref"],
2899 Reply::fail(128, "fatal: not a git repository"),
2900 ));
2901 assert!(not_repo.current_branch(Path::new(".")).await.is_err());
2902 }
2903
2904 #[tokio::test]
2906 async fn commit_paths_builds_only_amend_args() {
2907 let rec = RecordingRunner::replying(Reply::ok(""));
2908 let git = Git::with_runner(&rec);
2909 git.commit_paths(
2910 Path::new("."),
2911 CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2912 )
2913 .await
2914 .expect("commit_paths");
2915 assert_eq!(
2916 rec.only_call().args_str(),
2917 [
2918 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2919 ]
2920 );
2921 }
2922
2923 #[tokio::test]
2926 async fn is_unborn_maps_exit_codes() {
2927 let born =
2928 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
2929 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2930 let unborn =
2931 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
2932 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2933 let broken = Git::with_runner(
2934 ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
2935 );
2936 assert!(matches!(
2937 broken.is_unborn(Path::new(".")).await.unwrap_err(),
2938 Error::Exit { code: 128, .. }
2939 ));
2940 }
2941
2942 #[tokio::test]
2943 async fn log_builds_revspec_and_format() {
2944 let rec = RecordingRunner::replying(Reply::ok(""));
2945 let git = Git::with_runner(&rec);
2946 git.log(Path::new("."), "main..HEAD", 5).await.expect("log");
2947 assert_eq!(
2948 rec.only_call().args_str(),
2949 [
2950 "log",
2951 "main..HEAD",
2952 "-n5",
2953 "-z",
2954 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2955 ]
2956 );
2957 }
2958
2959 #[tokio::test]
2960 async fn stash_push_adds_include_untracked() {
2961 let rec = RecordingRunner::replying(Reply::ok(""));
2962 let git = Git::with_runner(&rec);
2963 git.stash_push(Path::new("."), true).await.expect("stash");
2964 assert_eq!(
2965 rec.only_call().args_str(),
2966 ["stash", "push", "--include-untracked"]
2967 );
2968 }
2969
2970 #[tokio::test]
2973 async fn diff_text_builds_working_tree_args() {
2974 let rec = RecordingRunner::replying(Reply::ok(""));
2977 let git = Git::with_runner(&rec);
2978 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2979 .await
2980 .expect("diff_text");
2981 assert_eq!(
2982 rec.calls().last().unwrap().args_str(),
2983 [
2984 "diff",
2985 "HEAD",
2986 "--no-color",
2987 "--no-ext-diff",
2988 "-M",
2989 "--src-prefix=a/",
2992 "--dst-prefix=b/",
2993 "--",
2995 ]
2996 );
2997 }
2998
2999 #[tokio::test]
3003 async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
3004 let git = Git::with_runner(
3005 ScriptedRunner::new()
3006 .on(["git", "rev-parse"], Reply::fail(1, "")) .on(["git", "diff", EMPTY_TREE], Reply::ok("EMPTY")),
3008 );
3009 let out = git
3010 .diff_text(Path::new("."), DiffSpec::WorkingTree)
3011 .await
3012 .expect("diff_text");
3013 assert_eq!(out, "EMPTY");
3014 }
3015
3016 #[tokio::test]
3019 async fn diff_parses_scripted_output() {
3020 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
3021 let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
3022 let files = git
3023 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
3024 .await
3025 .expect("diff");
3026 assert_eq!(files.len(), 1);
3027 assert_eq!(files[0].path, "m");
3028 assert_eq!(files[0].change, ChangeKind::Modified);
3029 }
3030
3031 #[tokio::test]
3032 async fn branch_exists_maps_exit_codes() {
3033 let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
3034 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
3035 let no =
3036 Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
3037 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
3038 }
3039
3040 #[tokio::test]
3043 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
3044 let simple = Git::with_runner(ScriptedRunner::new().on(
3045 ["git", "symbolic-ref"],
3046 Reply::ok("refs/remotes/origin/main\n"),
3047 ));
3048 assert_eq!(
3049 simple
3050 .remote_head_branch(Path::new("."))
3051 .await
3052 .unwrap()
3053 .as_deref(),
3054 Some("main")
3055 );
3056
3057 let slashed = Git::with_runner(ScriptedRunner::new().on(
3058 ["git", "symbolic-ref"],
3059 Reply::ok("refs/remotes/origin/release/v2\n"),
3060 ));
3061 assert_eq!(
3062 slashed
3063 .remote_head_branch(Path::new("."))
3064 .await
3065 .unwrap()
3066 .as_deref(),
3067 Some("release/v2")
3068 );
3069
3070 let unset =
3071 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3072 assert!(
3073 unset
3074 .remote_head_branch(Path::new("."))
3075 .await
3076 .unwrap()
3077 .is_none()
3078 );
3079 }
3080
3081 #[tokio::test]
3084 async fn remote_branch_exists_sets_env_and_reads_stdout() {
3085 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
3086 let git = Git::with_runner(&rec);
3087 assert!(
3088 git.remote_branch_exists(Path::new("/repo"), "main")
3089 .await
3090 .unwrap()
3091 );
3092 let call = rec.only_call();
3093 assert!(call.envs.iter().any(|(k, v)| {
3094 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3095 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3096 }));
3097 assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
3099
3100 let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
3101 assert!(
3102 !empty
3103 .remote_branch_exists(Path::new("."), "x")
3104 .await
3105 .unwrap()
3106 );
3107 }
3108
3109 #[tokio::test]
3110 async fn diff_stat_parses_counts() {
3111 let git = Git::with_runner(ScriptedRunner::new().on(
3112 ["git", "diff", "--shortstat"],
3113 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
3114 ));
3115 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
3116 assert_eq!(
3117 (stat.files_changed, stat.insertions, stat.deletions),
3118 (2, 5, 1)
3119 );
3120 }
3121
3122 #[tokio::test]
3126 async fn diff_range_verbs_terminate_revisions_with_dashes() {
3127 let rec = RecordingRunner::replying(Reply::ok(""));
3128 let git = Git::with_runner(&rec);
3129 git.diff_range_is_empty(Path::new("/r"), "main..HEAD")
3130 .await
3131 .expect("diff_range_is_empty");
3132 assert_eq!(
3133 rec.only_call().args_str(),
3134 ["diff", "--quiet", "main..HEAD", "--"]
3135 );
3136
3137 let rec = RecordingRunner::replying(Reply::ok(" 0 files changed\n"));
3138 let git = Git::with_runner(&rec);
3139 git.diff_stat(Path::new("/r"), "main..HEAD")
3140 .await
3141 .expect("diff_stat");
3142 assert_eq!(
3143 rec.only_call().args_str(),
3144 ["diff", "--shortstat", "main..HEAD", "--"]
3145 );
3146 }
3147
3148 #[tokio::test]
3149 async fn status_text_returns_raw_porcelain() {
3150 let git = Git::with_runner(ScriptedRunner::new().on(
3151 ["git", "status", "--porcelain=v1"],
3152 Reply::ok(" M a.rs\n?? b.rs\n"),
3153 ));
3154 let text = git.status_text(Path::new(".")).await.expect("status_text");
3155 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
3156 }
3157
3158 #[tokio::test]
3159 async fn run_args_forwards_str_slices() {
3160 let git =
3161 Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
3162 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
3163 }
3164
3165 #[tokio::test]
3166 async fn merge_commit_builds_no_ff_and_message() {
3167 let rec = RecordingRunner::replying(Reply::ok(""));
3168 let git = Git::with_runner(&rec);
3169 git.merge_commit(
3170 Path::new("/r"),
3171 MergeCommit::branch("feature").no_ff().message("merge it"),
3172 )
3173 .await
3174 .unwrap();
3175 assert_eq!(
3176 rec.only_call().args_str(),
3177 ["merge", "--no-ff", "-m", "merge it", "feature"]
3178 );
3179 }
3180
3181 #[tokio::test]
3183 async fn merge_commit_without_message_uses_no_edit() {
3184 let rec = RecordingRunner::replying(Reply::ok(""));
3185 let git = Git::with_runner(&rec);
3186 git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
3187 .await
3188 .unwrap();
3189 assert_eq!(
3190 rec.only_call().args_str(),
3191 ["merge", "--no-edit", "feature"]
3192 );
3193 }
3194
3195 #[tokio::test]
3197 async fn rebase_suppresses_editor() {
3198 let rec = RecordingRunner::replying(Reply::ok(""));
3199 let git = Git::with_runner(&rec);
3200 git.rebase(Path::new("/r"), "main").await.unwrap();
3201 let call = rec.only_call();
3202 assert_eq!(call.args_str(), ["rebase", "main"]);
3203 assert!(call.envs.iter().any(|(k, v)| {
3204 k.to_str() == Some("GIT_EDITOR")
3205 && v.as_deref().and_then(|o| o.to_str()) == Some("true")
3206 }));
3207 }
3208
3209 #[tokio::test]
3210 async fn push_builds_set_upstream_remote_refspec() {
3211 let rec = RecordingRunner::replying(Reply::ok(""));
3212 let git = Git::with_runner(&rec);
3213 git.push(
3214 Path::new("/r"),
3215 GitPush::refspec("feat", "feature").set_upstream(),
3216 )
3217 .await
3218 .unwrap();
3219 assert_eq!(
3220 rec.only_call().args_str(),
3221 ["push", "-u", "origin", "feat:feature"]
3222 );
3223 }
3224
3225 #[tokio::test]
3228 async fn push_bare_branch_builds_origin_branch_prompt_off() {
3229 let rec = RecordingRunner::replying(Reply::ok(""));
3230 let git = Git::with_runner(&rec);
3231 git.push(Path::new("/r"), GitPush::branch("feature"))
3232 .await
3233 .unwrap();
3234 let call = rec.only_call();
3235 assert_eq!(call.args_str(), ["push", "origin", "feature"]);
3236 assert!(call.envs.iter().any(|(k, v)| {
3237 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3238 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3239 }));
3240 }
3241
3242 #[tokio::test]
3245 async fn push_rejects_force_and_multiref_metacharacters() {
3246 let rec = RecordingRunner::replying(Reply::ok(""));
3247 let git = Git::with_runner(&rec);
3248 for bad in ["+main", "+main:main", "a:b:c"] {
3249 assert!(
3250 git.push(Path::new("/r"), GitPush::branch(bad))
3251 .await
3252 .is_err(),
3253 "{bad:?} must be rejected"
3254 );
3255 }
3256 assert!(
3258 git.push(Path::new("/r"), GitPush::refspec("main", "prod"))
3259 .await
3260 .is_ok()
3261 );
3262 assert!(
3263 rec.calls()
3264 .iter()
3265 .all(|c| c.args_str().last().unwrap() != "+main")
3266 );
3267 }
3268
3269 #[tokio::test]
3271 async fn push_remote_override_swaps_remote() {
3272 let rec = RecordingRunner::replying(Reply::ok(""));
3273 let git = Git::with_runner(&rec);
3274 git.push(
3275 Path::new("/r"),
3276 GitPush::branch("feature").remote("upstream"),
3277 )
3278 .await
3279 .unwrap();
3280 assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
3281 }
3282
3283 #[tokio::test]
3287 async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
3288 let rec = RecordingRunner::replying(Reply::ok(""));
3289 let git = Git::with_runner(&rec)
3290 .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
3291 git.push(Path::new("/r"), GitPush::branch("feature"))
3292 .await
3293 .unwrap();
3294 let call = rec.only_call();
3295 let args = call.args_str();
3296 assert_eq!(args[0], "-c", "config flag leads the argv");
3298 assert!(
3299 args.iter().any(|a| a == "credential.helper="),
3300 "inherited helpers are cleared first: {args:?}"
3301 );
3302 assert!(
3303 args.iter()
3304 .any(|a| a.contains("credential.helper=!f()")
3305 && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
3306 "inline helper references the secret by env-var name: {args:?}"
3307 );
3308 assert!(
3309 args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
3310 "the real subcommand still runs: {args:?}"
3311 );
3312 assert!(
3314 !args.iter().any(|a| a.contains("ghp_secret123")),
3315 "secret leaked into argv: {args:?}"
3316 );
3317 let pw = call
3319 .envs
3320 .iter()
3321 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3322 .and_then(|(_, v)| v.as_ref())
3323 .and_then(|v| v.to_str());
3324 assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
3325 }
3326
3327 #[tokio::test]
3330 async fn default_client_injects_no_credential_helper() {
3331 let rec = RecordingRunner::replying(Reply::ok(""));
3332 let git = Git::with_runner(&rec);
3333 git.push(Path::new("/r"), GitPush::branch("feature"))
3334 .await
3335 .unwrap();
3336 let call = rec.only_call();
3337 assert_eq!(
3338 call.args_str(),
3339 ["push", "origin", "feature"],
3340 "no credential `-c` args without a provider"
3341 );
3342 assert!(
3343 !call
3344 .envs
3345 .iter()
3346 .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
3347 "no secret env without a provider"
3348 );
3349 }
3350
3351 #[tokio::test]
3355 async fn with_credentials_clone_puts_config_flags_before_subcommand() {
3356 let rec = RecordingRunner::replying(Reply::ok(""));
3357 let git =
3358 Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
3359 git.clone_repo(
3360 "https://example.com/r.git",
3361 Path::new("/dest"),
3362 CloneSpec::default().branch("main"),
3363 )
3364 .await
3365 .unwrap();
3366 let call = rec.only_call();
3367 let args = call.args_str();
3368 assert_eq!(args[0], "-c", "config flags lead the clone argv");
3369 let clone_at = args
3370 .iter()
3371 .position(|a| a == "clone")
3372 .expect("clone present");
3373 assert!(
3375 args[..clone_at]
3376 .iter()
3377 .all(|a| a == "-c" || a.starts_with("credential.helper")),
3378 "only credential -c flags precede `clone`: {args:?}"
3379 );
3380 let tail = &args[clone_at..];
3382 assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
3383 assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
3384 assert!(
3385 !args.iter().any(|a| a.contains("s3cr3t")),
3386 "secret not in argv"
3387 );
3388 let host = call
3391 .envs
3392 .iter()
3393 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
3394 .and_then(|(_, v)| v.as_ref())
3395 .and_then(|v| v.to_str());
3396 assert_eq!(
3397 host,
3398 Some("example.com"),
3399 "the clone URL's host scopes the credential helper"
3400 );
3401 assert!(
3404 args[..clone_at].iter().all(|a| !a.contains("example.com")),
3405 "host stays in env, not the credential config args: {:?}",
3406 &args[..clone_at]
3407 );
3408 }
3409
3410 #[tokio::test]
3413 async fn with_credentials_userpass_threads_username_through_env() {
3414 let rec = RecordingRunner::replying(Reply::ok(""));
3415 let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
3416 Credential::userpass("alice", "s3cr3t"),
3417 )));
3418 git.fetch(Path::new("/r")).await.unwrap();
3419 let call = rec.only_call();
3420 let user = call
3421 .envs
3422 .iter()
3423 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
3424 .and_then(|(_, v)| v.as_ref())
3425 .and_then(|v| v.to_str());
3426 assert_eq!(user, Some("alice"), "userpass username reaches the env");
3427 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
3428 assert!(call.args_str().contains(&"fetch".to_string()));
3429 }
3430
3431 #[tokio::test]
3434 async fn default_client_no_helper_on_fetch_and_clone() {
3435 let rec = RecordingRunner::replying(Reply::ok(""));
3436 Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
3437 assert_eq!(
3438 rec.only_call().args_str(),
3439 ["fetch", "--quiet"],
3440 "fetch unchanged without a provider"
3441 );
3442
3443 let rec = RecordingRunner::replying(Reply::ok(""));
3444 Git::with_runner(&rec)
3445 .clone_repo(
3446 "https://example.com/r.git",
3447 Path::new("/dest"),
3448 CloneSpec::default(),
3449 )
3450 .await
3451 .unwrap();
3452 assert_eq!(
3453 rec.only_call().args_str()[0],
3454 "clone",
3455 "clone leads with the subcommand (no `-c`) without a provider"
3456 );
3457 }
3458
3459 #[tokio::test]
3462 async fn with_token_convenience_authenticates_https_remote() {
3463 let rec = RecordingRunner::replying(Reply::ok(""));
3464 let git = Git::with_runner(&rec).with_token("ghp_conv");
3465 git.fetch(Path::new("/r")).await.unwrap();
3466 let call = rec.only_call();
3467 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
3468 let pw = call
3469 .envs
3470 .iter()
3471 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3472 .and_then(|(_, v)| v.as_ref())
3473 .and_then(|v| v.to_str());
3474 assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
3475 assert!(
3476 !call.args_str().iter().any(|a| a.contains("ghp_conv")),
3477 "secret not in argv"
3478 );
3479 }
3480
3481 #[tokio::test]
3482 async fn upstream_maps_unset_to_none() {
3483 let set = Git::with_runner(
3484 ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("origin/main\n")),
3485 );
3486 assert_eq!(
3487 set.upstream(Path::new(".")).await.unwrap().as_deref(),
3488 Some("origin/main")
3489 );
3490 let unset =
3493 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "")));
3494 assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
3495 let timed_out =
3498 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::timeout()));
3499 assert!(timed_out.upstream(Path::new(".")).await.is_err());
3500 }
3501
3502 #[tokio::test]
3506 async fn remote_head_branch_maps_exit_codes() {
3507 let set = Git::with_runner(ScriptedRunner::new().on(
3508 ["git", "symbolic-ref"],
3509 Reply::ok("refs/remotes/origin/release/v2\n"),
3510 ));
3511 assert_eq!(
3512 set.remote_head_branch(Path::new("."))
3513 .await
3514 .unwrap()
3515 .as_deref(),
3516 Some("release/v2"),
3517 "the full ref prefix is stripped, slashes preserved"
3518 );
3519 let unset =
3520 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3521 assert!(
3522 unset
3523 .remote_head_branch(Path::new("."))
3524 .await
3525 .unwrap()
3526 .is_none()
3527 );
3528 let err = Git::with_runner(ScriptedRunner::new().on(
3530 ["git", "symbolic-ref"],
3531 Reply::fail(128, "fatal: not a git repository"),
3532 ));
3533 assert!(err.remote_head_branch(Path::new(".")).await.is_err());
3534 let timed_out =
3536 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
3537 assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
3538 }
3539
3540 #[tokio::test]
3541 async fn set_upstream_builds_branch_flag() {
3542 let rec = RecordingRunner::replying(Reply::ok(""));
3543 let git = Git::with_runner(&rec);
3544 git.set_upstream(Path::new("/r"), "feat", "origin/feature")
3545 .await
3546 .unwrap();
3547 assert_eq!(
3548 rec.only_call().args_str(),
3549 ["branch", "--set-upstream-to=origin/feature", "feat"]
3550 );
3551 }
3552
3553 #[tokio::test]
3554 async fn remote_branches_parses_ls_remote() {
3555 let git = Git::with_runner(ScriptedRunner::new().on(
3556 ["git", "ls-remote"],
3557 Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
3558 ));
3559 let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
3560 assert_eq!(branches, ["main", "feat/x"]);
3561 }
3562
3563 #[tokio::test]
3564 async fn delete_branch_force_uses_capital_d() {
3565 let rec = RecordingRunner::replying(Reply::ok(""));
3566 let git = Git::with_runner(&rec);
3567 git.delete_branch(Path::new("/r"), "old", true)
3568 .await
3569 .unwrap();
3570 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
3571 }
3572
3573 #[tokio::test]
3576 async fn is_merged_strips_branch_markers() {
3577 let git = Git::with_runner(ScriptedRunner::new().on(
3578 ["git", "branch", "--merged"],
3579 Reply::ok(" main\n* feature\n+ wt-branch\n"),
3580 ));
3581 for name in ["main", "feature", "wt-branch"] {
3582 assert!(
3583 git.is_merged(Path::new("."), MergeCheck::branch(name).into_base("main"))
3584 .await
3585 .unwrap(),
3586 "{name} should be reported merged"
3587 );
3588 }
3589 assert!(
3590 !git.is_merged(
3591 Path::new("."),
3592 MergeCheck::branch("absent").into_base("main")
3593 )
3594 .await
3595 .unwrap()
3596 );
3597 }
3598
3599 #[tokio::test]
3603 async fn merge_check_names_branch_and_base_without_transposition() {
3604 use processkit::testing::RecordingRunner;
3605 let spec = MergeCheck::branch("feature").into_base("main");
3606 assert_eq!(spec.branch, "feature");
3607 assert_eq!(spec.base, "main");
3608
3609 let rec = RecordingRunner::replying(Reply::ok(" feature\n* main\n"));
3610 let merged = Git::with_runner(&rec)
3611 .is_merged(
3612 Path::new("/repo"),
3613 MergeCheck::branch("feature").into_base("main"),
3614 )
3615 .await
3616 .unwrap();
3617 assert!(merged, "feature is listed as merged into main");
3620 assert_eq!(
3621 rec.only_call().args_str(),
3622 ["branch", "--merged", "main", "--no-column", "--no-color"]
3623 );
3624 }
3625
3626 #[tokio::test]
3629 async fn fetch_disables_terminal_prompt() {
3630 let rec = RecordingRunner::replying(Reply::ok(""));
3631 let git = Git::with_runner(&rec);
3632 git.fetch(Path::new("/r")).await.unwrap();
3633 let call = rec.only_call();
3634 assert_eq!(call.args_str(), ["fetch", "--quiet"]);
3635 assert!(call.envs.iter().any(|(k, v)| {
3636 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3637 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3638 }));
3639 }
3640
3641 #[tokio::test]
3643 async fn fetch_retries_transient_failures() {
3644 let rec = RecordingRunner::replying(Reply::fail(
3645 128,
3646 "fatal: unable to access: Could not resolve host: example.com",
3647 ));
3648 let git = Git::with_runner(&rec);
3649 assert!(git.fetch(Path::new("/r")).await.is_err());
3650 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
3651 }
3652
3653 #[tokio::test]
3667 async fn with_retry_retries_lock_contention_on_a_mutation() {
3668 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3669 ["git", "commit"],
3670 [
3671 Reply::fail(
3672 128,
3673 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3674 ),
3675 Reply::ok(""),
3676 ],
3677 ));
3678 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3679 git.commit(Path::new("/r"), "msg")
3680 .await
3681 .expect("retried past the lock");
3682 assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
3683 }
3684
3685 #[tokio::test]
3687 async fn default_client_does_not_retry_lock_contention() {
3688 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3689 ["git", "commit"],
3690 [
3691 Reply::fail(
3692 128,
3693 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3694 ),
3695 Reply::ok(""),
3696 ],
3697 ));
3698 let git = Git::with_runner(&rec);
3699 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3700 assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
3701 }
3702
3703 #[tokio::test]
3706 async fn with_retry_does_not_retry_a_real_failure() {
3707 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3708 ["git", "commit"],
3709 [
3710 Reply::fail(1, "error: pathspec 'x' did not match"),
3711 Reply::ok(""),
3712 ],
3713 ));
3714 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3715 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3716 assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
3717 }
3718
3719 #[tokio::test]
3721 async fn fetch_does_not_retry_permanent_failures() {
3722 let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
3723 let git = Git::with_runner(&rec);
3724 assert!(git.fetch(Path::new("/r")).await.is_err());
3725 assert_eq!(rec.calls().len(), 1);
3726 }
3727
3728 #[tokio::test(start_paused = true)]
3735 async fn fetch_cancels_and_does_not_retry() {
3736 use processkit::CancellationToken;
3737 let token = CancellationToken::new();
3738 let rec =
3739 RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
3740 let git = Git::with_runner(&rec).default_cancel_on(token.clone());
3741 let call = git.fetch(Path::new("/r"));
3742 tokio::pin!(call);
3743 assert!(
3744 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
3745 .await
3746 .is_err(),
3747 "fetch must park until the token fires"
3748 );
3749 token.cancel();
3750 assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
3751 assert_eq!(
3752 rec.calls().len(),
3753 1,
3754 "cancellation is terminal — the fetch-retry must not replay it"
3755 );
3756 }
3757
3758 #[tokio::test]
3761 async fn flag_like_positionals_are_rejected_before_spawning() {
3762 let rec = RecordingRunner::replying(Reply::ok(""));
3763 let git = Git::with_runner(&rec);
3764 let dir = Path::new("/r");
3765
3766 assert!(git.checkout(dir, "-evil").await.is_err());
3767 assert!(git.create_branch(dir, "--force").await.is_err());
3768 assert!(git.delete_branch(dir, "-D", false).await.is_err());
3769 assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
3770 assert!(
3771 git.merge_commit(dir, MergeCommit::branch("-evil"))
3772 .await
3773 .is_err()
3774 );
3775 assert!(
3776 git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
3777 .await
3778 .is_err()
3779 );
3780 assert!(git.merge_squash(dir, "-evil").await.is_err());
3781 assert!(git.rebase(dir, "-i").await.is_err());
3782 assert!(git.cherry_pick(dir, "-n").await.is_err());
3783 assert!(git.revert(dir, "-evil").await.is_err());
3784 assert!(git.tag_create(dir, "-d", None).await.is_err());
3785 assert!(
3786 git.tag_create(dir, "ok", Some("-evil".into()))
3787 .await
3788 .is_err()
3789 );
3790 assert!(git.tag_delete(dir, "-evil").await.is_err());
3791 assert!(git.remote_add(dir, "-evil", "url").await.is_err());
3792 assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
3793 assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
3794 assert!(git.log(dir, "-evil", 5).await.is_err());
3795 assert!(git.rev_list_count(dir, "-evil").await.is_err());
3796 assert!(git.diff_stat(dir, "-evil").await.is_err());
3797 assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
3798 assert!(
3799 git.diff_text(dir, DiffSpec::Rev("-evil".into()))
3800 .await
3801 .is_err()
3802 );
3803 assert!(git.rev_parse(dir, "-evil").await.is_err());
3804 assert!(git.rev_parse_short(dir, "-evil").await.is_err());
3805 assert!(git.resolve_commit(dir, "-evil").await.is_err());
3806 assert!(git.reset_hard(dir, "-evil").await.is_err());
3807 assert!(git.checkout_detach(dir, "-evil").await.is_err());
3808 assert!(git.config_set(dir, "-evil", "v").await.is_err());
3809 assert!(
3810 git.push(dir, GitPush::branch("-evil")).await.is_err(),
3811 "refspec guard"
3812 );
3813 assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
3815 assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
3816 assert!(git.remote_url(dir, "-evil").await.is_err());
3817 assert!(git.remote_branches(dir, "-evil").await.is_err());
3818 assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
3819 assert!(
3821 git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
3822 .await
3823 .is_err()
3824 );
3825 assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
3826 assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
3827 assert!(
3828 git.is_merged(dir, MergeCheck::branch("-evil").into_base("main"))
3829 .await
3830 .is_err()
3831 );
3832 assert!(git.config_get(dir, "-evil").await.is_err());
3833 assert!(
3834 git.worktree_add(
3835 dir,
3836 WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
3837 )
3838 .await
3839 .is_err()
3840 );
3841 assert!(git.checkout(dir, "").await.is_err());
3843
3844 assert!(
3845 rec.calls().is_empty(),
3846 "nothing may spawn: {:?}",
3847 rec.calls()
3848 );
3849
3850 git.checkout(dir, "feature/x").await.expect("checkout");
3853 assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
3854 }
3855
3856 #[tokio::test]
3859 async fn harden_applies_env_profile_to_every_command() {
3860 let rec = RecordingRunner::replying(Reply::ok(""));
3861 let git = Git::with_runner(&rec).harden();
3862 git.status(Path::new("/r")).await.expect("status");
3863 git.fetch(Path::new("/r")).await.expect("fetch");
3864
3865 for call in rec.calls() {
3866 let has = |k: &str, v: &str| {
3867 call.envs.iter().any(|(key, val)| {
3868 key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
3869 })
3870 };
3871 let removed = |k: &str| {
3872 call.envs
3873 .iter()
3874 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3875 };
3876 assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
3877 assert!(has("GIT_CONFIG_COUNT", "3"));
3878 assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
3879 assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
3880 assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
3881 assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
3883 assert!(has("GIT_CONFIG_VALUE_2", ""));
3884 assert!(has("GIT_TERMINAL_PROMPT", "0"));
3885 assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
3886 assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
3887 assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
3889 assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
3890 assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
3891 assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
3892 assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
3894 assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
3895 assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
3896 assert!(
3897 removed("GIT_ICASE_PATHSPECS"),
3898 "GIT_ICASE_PATHSPECS scrubbed"
3899 );
3900 }
3901 }
3902
3903 #[tokio::test]
3909 async fn default_client_scrubs_repo_redirector_env() {
3910 let rec = RecordingRunner::replying(Reply::ok(""));
3911 let git = Git::with_runner(&rec); git.status(Path::new("/r")).await.expect("status");
3913 let call = rec.only_call();
3914 let removed = |k: &str| {
3915 call.envs
3916 .iter()
3917 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3918 };
3919 let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
3920 for var in [
3921 "GIT_DIR",
3922 "GIT_WORK_TREE",
3923 "GIT_INDEX_FILE",
3924 "GIT_COMMON_DIR",
3925 "GIT_OBJECT_DIRECTORY",
3926 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3927 "GIT_NAMESPACE",
3928 ] {
3929 assert!(removed(var), "{var} must be scrubbed on the default client");
3930 }
3931 assert!(
3933 !has_key("GIT_SSH_COMMAND"),
3934 "command-hook scrub is harden()-only"
3935 );
3936 assert!(
3937 !has_key("GIT_CONFIG_NOSYSTEM"),
3938 "config pins are harden()-only"
3939 );
3940 }
3941
3942 #[test]
3944 fn ref_name_and_rev_spec_validate() {
3945 for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
3946 assert!(RefName::new(ok).is_ok(), "{ok}");
3947 }
3948 for bad in [
3949 "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
3950 "a\\b", "end/", "x.lock",
3951 ] {
3952 assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
3953 }
3954 assert!(RevSpec::new("HEAD~2").is_ok());
3955 assert!(RevSpec::new("main..feature").is_ok());
3956 assert!(RevSpec::new("-evil").is_err());
3957 assert!(RevSpec::new("").is_err());
3958 }
3959
3960 #[tokio::test]
3963 async fn capabilities_parse_and_gate_versions() {
3964 let gh = Git::with_runner(ScriptedRunner::new().on(
3965 ["git", "--version"],
3966 Reply::ok("git version 2.54.0.windows.1\n"),
3967 ));
3968 let caps = gh.capabilities().await.expect("capabilities");
3969 assert_eq!(caps.version.to_string(), "2.54.0");
3970 assert!(caps.is_supported());
3971 caps.ensure_supported().expect("supported");
3972
3973 let old = Git::with_runner(
3976 ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
3977 );
3978 let caps = old.capabilities().await.expect("capabilities");
3979 assert_eq!(
3980 caps.version,
3981 GitVersion {
3982 major: 1,
3983 minor: 9,
3984 patch: 0
3985 }
3986 );
3987 let err = caps.ensure_supported().expect_err("unsupported");
3988 let Error::Spawn { source, .. } = &err else {
3990 panic!("expected Spawn, got {err:?}");
3991 };
3992 let message = source.to_string();
3993 assert!(message.contains(">= 2"), "names the floor: {message}");
3994 assert!(
3995 message.contains("1.9.0"),
3996 "names the found version: {message}"
3997 );
3998
3999 let mid = Git::with_runner(
4003 ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 2.7.4\n")),
4004 );
4005 let caps = mid.capabilities().await.expect("capabilities");
4006 assert!(!caps.is_supported(), "2.7.4 is below the 2.31 floor");
4007 let err = caps.ensure_supported().expect_err("2.7.4 unsupported");
4008 let Error::Spawn { source, .. } = &err else {
4009 panic!("expected Spawn, got {err:?}");
4010 };
4011 assert!(
4012 source.to_string().contains(">= 2.31"),
4013 "names the 2.31 floor"
4014 );
4015
4016 let garbage = Git::with_runner(
4018 ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
4019 );
4020 assert!(matches!(
4021 garbage.capabilities().await.unwrap_err(),
4022 Error::Parse { .. }
4023 ));
4024 }
4025
4026 #[tokio::test]
4028 async fn clone_repo_builds_flags_and_runs_dirless() {
4029 let rec = RecordingRunner::replying(Reply::ok(""));
4030 let git = Git::with_runner(&rec);
4031 git.clone_repo(
4032 "https://example.com/r.git",
4033 Path::new("/dest"),
4034 CloneSpec::new().branch("main").depth(1).bare(),
4035 )
4036 .await
4037 .expect("clone");
4038 let call = rec.only_call();
4039 assert_eq!(
4040 call.args_str(),
4041 [
4042 "clone",
4043 "--branch",
4044 "main",
4045 "--depth",
4046 "1",
4047 "--bare",
4048 "https://example.com/r.git",
4049 "/dest"
4050 ]
4051 );
4052 assert_eq!(call.cwd, None, "clone runs without a working directory");
4053
4054 let bare = RecordingRunner::replying(Reply::ok(""));
4055 let git = Git::with_runner(&bare);
4056 git.clone_repo("u", Path::new("/d"), CloneSpec::new())
4057 .await
4058 .expect("clone");
4059 assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
4060 }
4061
4062 #[tokio::test]
4068 async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
4069 use vcs_testkit::TempDir;
4070 let tmp = TempDir::new("r7-clone");
4071 let git = Git::with_runner(ScriptedRunner::new().on(
4072 ["git", "clone"],
4073 Reply::fail(
4074 128,
4075 "fatal: could not read Username for 'https://x': prompts disabled",
4076 ),
4077 ));
4078
4079 let occupied = tmp.path().join("occupied");
4081 std::fs::create_dir(&occupied).unwrap();
4082 std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
4083 assert!(
4084 git.clone_repo("https://x/r", &occupied, CloneSpec::new())
4085 .await
4086 .is_err()
4087 );
4088 assert!(
4089 occupied.join("keep.txt").exists(),
4090 "a non-empty caller dir must survive a failed clone"
4091 );
4092
4093 let empty = tmp.path().join("empty");
4095 std::fs::create_dir(&empty).unwrap();
4096 assert!(
4097 git.clone_repo("https://x/r", &empty, CloneSpec::new())
4098 .await
4099 .is_err()
4100 );
4101 assert!(
4102 !empty.exists(),
4103 "an empty dest is cleaned so a retry isn't blocked"
4104 );
4105
4106 let file_dest = tmp.path().join("a-file");
4110 std::fs::write(&file_dest, b"caller file").unwrap();
4111 assert!(
4112 git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
4113 .await
4114 .is_err()
4115 );
4116 assert!(
4117 file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
4118 "a caller's file at dest must survive a failed clone"
4119 );
4120
4121 #[cfg(unix)]
4126 {
4127 let target = tmp.path().join("link-target"); std::fs::create_dir(&target).unwrap();
4129 let sentinel = tmp.path().join("sibling.txt");
4130 std::fs::write(&sentinel, b"untouched").unwrap();
4131 let link = tmp.path().join("a-symlink");
4132 std::os::unix::fs::symlink(&target, &link).unwrap();
4133 assert!(
4134 git.clone_repo("https://x/r", &link, CloneSpec::new())
4135 .await
4136 .is_err()
4137 );
4138 assert!(
4139 target.exists() && sentinel.exists(),
4140 "a failed clone must unlink at most the symlink, never delete through it"
4141 );
4142 }
4143 }
4144
4145 #[tokio::test]
4146 async fn tag_methods_build_args() {
4147 let rec = RecordingRunner::replying(Reply::ok(""));
4148 let git = Git::with_runner(&rec);
4149 git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
4150 git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
4151 .await
4152 .unwrap();
4153 git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
4154 .await
4155 .unwrap();
4156 git.tag_delete(Path::new("/r"), "v1").await.unwrap();
4157 let calls = rec.calls();
4158 assert_eq!(calls[0].args_str(), ["tag", "v1"]);
4159 assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
4160 assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
4161 assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
4162 }
4163
4164 #[tokio::test]
4165 async fn tag_list_splits_lines() {
4166 let git = Git::with_runner(
4167 ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
4168 );
4169 assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
4170 }
4171
4172 #[tokio::test]
4178 async fn list_commands_disable_column_and_color() {
4179 let rec = RecordingRunner::replying(Reply::ok(""));
4180 let git = Git::with_runner(&rec);
4181 git.branches(Path::new(".")).await.unwrap();
4182 git.is_merged(Path::new("."), MergeCheck::branch("b").into_base("main"))
4183 .await
4184 .unwrap();
4185 git.tag_list(Path::new(".")).await.unwrap();
4186 let calls = rec.calls();
4187 assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
4188 assert_eq!(
4189 calls[1].args_str(),
4190 ["branch", "--merged", "main", "--no-column", "--no-color"]
4191 );
4192 assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
4193 }
4194
4195 #[tokio::test]
4198 async fn classified_commands_force_c_locale() {
4199 let rec = RecordingRunner::replying(Reply::ok(""));
4200 let git = Git::with_runner(&rec);
4201 git.commit(Path::new("."), "msg").await.unwrap();
4202 git.merge_commit(Path::new("."), MergeCommit::branch("b"))
4203 .await
4204 .unwrap();
4205 git.merge_squash(Path::new("."), "b").await.unwrap();
4206 git.merge_no_commit(Path::new("."), MergeNoCommit::branch("b"))
4207 .await
4208 .unwrap();
4209 git.cherry_pick(Path::new("."), "abc").await.unwrap();
4210 git.stash_pop(Path::new(".")).await.unwrap();
4211 git.fetch(Path::new(".")).await.unwrap();
4212 for call in rec.calls() {
4213 assert!(
4214 call.envs.iter().any(|(k, v)| {
4215 k.to_str() == Some("LC_ALL")
4216 && v.as_deref().and_then(|o| o.to_str()) == Some("C")
4217 }),
4218 "{:?} should force LC_ALL=C",
4219 call.args_str()
4220 );
4221 }
4222 }
4223
4224 #[cfg(windows)]
4227 #[tokio::test]
4228 async fn show_file_normalises_path_separators() {
4229 let rec = RecordingRunner::replying(Reply::ok("content\n"));
4230 let git = Git::with_runner(&rec);
4231 let out = git
4232 .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4233 .await
4234 .expect("show_file");
4235 assert_eq!(out, "content\n");
4237 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
4238 }
4239
4240 #[tokio::test]
4243 async fn content_verbs_preserve_exact_trailing_bytes() {
4244 for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces \n"] {
4245 let rec = RecordingRunner::replying(Reply::ok(raw));
4246 let git = Git::with_runner(&rec);
4247 let out = git
4248 .show_file(Path::new("/r"), "HEAD", "f.txt")
4249 .await
4250 .expect("show_file");
4251 assert_eq!(out, raw, "show_file returns bytes verbatim");
4252 }
4253 let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
4256 let rec = RecordingRunner::replying(Reply::ok(diff));
4257 let git = Git::with_runner(&rec);
4258 assert_eq!(
4259 git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
4260 .await
4261 .expect("diff_text"),
4262 diff
4263 );
4264 }
4265
4266 #[cfg(not(windows))]
4269 #[tokio::test]
4270 async fn show_file_keeps_backslashes_on_unix() {
4271 let rec = RecordingRunner::replying(Reply::ok("content\n"));
4272 let git = Git::with_runner(&rec);
4273 git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4274 .await
4275 .expect("show_file");
4276 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
4277 }
4278
4279 #[tokio::test]
4281 async fn config_get_maps_exit_codes() {
4282 let set = Git::with_runner(
4283 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
4284 );
4285 assert_eq!(
4286 set.config_get(Path::new("."), "user.name").await.unwrap(),
4287 Some("Alice".to_string())
4288 );
4289 let spaced = Git::with_runner(
4292 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix: \r\n")),
4293 );
4294 assert_eq!(
4295 spaced.config_get(Path::new("."), "x.y").await.unwrap(),
4296 Some("prefix: ".to_string())
4297 );
4298 let unset = Git::with_runner(
4299 ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
4300 );
4301 assert_eq!(
4302 unset.config_get(Path::new("."), "user.name").await.unwrap(),
4303 None
4304 );
4305 let multi = Git::with_runner(ScriptedRunner::new().on(
4307 ["git", "config", "--get"],
4308 Reply::fail(2, "multiple values"),
4309 ));
4310 assert!(
4311 multi
4312 .config_get(Path::new("."), "remote.all")
4313 .await
4314 .is_err()
4315 );
4316 }
4317
4318 #[tokio::test]
4319 async fn blame_builds_rev_before_pathspec_separator() {
4320 let rec = RecordingRunner::replying(Reply::ok(""));
4321 let git = Git::with_runner(&rec);
4322 git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
4323 .await
4324 .unwrap();
4325 git.blame(Path::new("/r"), "src/lib.rs", None)
4326 .await
4327 .unwrap();
4328 let calls = rec.calls();
4329 assert_eq!(
4330 calls[0].args_str(),
4331 ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
4332 );
4333 assert_eq!(
4334 calls[1].args_str(),
4335 ["blame", "--line-porcelain", "--", "src/lib.rs"]
4336 );
4337 }
4338
4339 #[tokio::test]
4341 async fn sequencer_methods_suppress_editors() {
4342 let rec = RecordingRunner::replying(Reply::ok(""));
4343 let git = Git::with_runner(&rec);
4344 git.revert(Path::new("/r"), "abc").await.unwrap();
4345 git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
4346 git.rebase_skip(Path::new("/r")).await.unwrap();
4347 let calls = rec.calls();
4348 assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
4349 assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
4350 assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
4351 for call in &calls {
4352 assert!(
4353 call.envs
4354 .iter()
4355 .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
4356 "editor suppressed on {:?}",
4357 call.args_str()
4358 );
4359 }
4360 }
4361
4362 #[tokio::test]
4371 async fn hardened_sequencer_keeps_its_no_op_editor() {
4372 let rec = RecordingRunner::replying(Reply::ok(""));
4373 let git = Git::with_runner(&rec).harden();
4374 git.revert(Path::new("/r"), "abc").await.unwrap();
4375 let call = rec.only_call();
4376 let effective = |var: &str| {
4378 call.envs
4379 .iter()
4380 .rfind(|(k, _)| k.to_str() == Some(var))
4381 .and_then(|(_, v)| v.as_deref())
4382 .and_then(|v| v.to_str())
4383 };
4384 assert_eq!(
4387 effective("GIT_EDITOR"),
4388 Some("true"),
4389 "the per-command no-op editor must survive harden()'s scrub"
4390 );
4391 assert_eq!(
4392 effective("GIT_SEQUENCE_EDITOR"),
4393 Some("true"),
4394 "the per-command no-op sequence editor must survive harden()'s scrub"
4395 );
4396 }
4397
4398 #[tokio::test]
4399 async fn remote_add_and_set_url_build_args() {
4400 let rec = RecordingRunner::replying(Reply::ok(""));
4401 let git = Git::with_runner(&rec);
4402 git.remote_add(Path::new("/r"), "up", "https://x/y.git")
4403 .await
4404 .unwrap();
4405 git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
4406 .await
4407 .unwrap();
4408 let calls = rec.calls();
4409 assert_eq!(
4410 calls[0].args_str(),
4411 ["remote", "add", "up", "https://x/y.git"]
4412 );
4413 assert_eq!(
4414 calls[1].args_str(),
4415 ["remote", "set-url", "up", "https://x/z.git"]
4416 );
4417 }
4418
4419 #[tokio::test]
4422 async fn switch_with_stash_round_trips_dirty_tree() {
4423 let rec = RecordingRunner::new(
4424 ScriptedRunner::new()
4425 .on(["git", "status"], Reply::ok(" M a.rs\0"))
4426 .on_sequence(
4428 ["git", "stash", "list"],
4429 [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4430 )
4431 .on(["git", "stash", "push"], Reply::ok(""))
4432 .on(["git", "checkout"], Reply::ok(""))
4433 .on(["git", "stash", "pop"], Reply::ok("")),
4434 );
4435 let git = Git::with_runner(&rec);
4436 git.switch_with_stash(Path::new("/r"), "feature")
4437 .await
4438 .expect("switch");
4439 let calls = rec.calls();
4440 assert_eq!(calls.len(), 6);
4441 assert_eq!(
4442 calls[2].args_str(),
4443 ["stash", "push", "--include-untracked"]
4444 );
4445 assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
4446 assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
4448 }
4449
4450 #[tokio::test]
4454 async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
4455 let rec = RecordingRunner::new(
4456 ScriptedRunner::new()
4457 .on(["git", "status"], Reply::ok(" M sub\0"))
4458 .on(
4460 ["git", "stash", "list"],
4461 Reply::ok("stash@{0}: someone else's WIP\n"),
4462 )
4463 .on(
4464 ["git", "stash", "push"],
4465 Reply::ok("No local changes to save\n"),
4466 )
4467 .on(["git", "checkout"], Reply::ok("")),
4468 );
4469 let git = Git::with_runner(&rec);
4470 git.switch_with_stash(Path::new("/r"), "feature")
4471 .await
4472 .expect("switch");
4473 assert!(
4474 rec.calls()
4475 .iter()
4476 .all(|c| c.args_str() != ["stash", "pop", "--index"]
4477 && c.args_str() != ["stash", "pop"]),
4478 "must not pop an unrelated stash when the push saved nothing"
4479 );
4480 }
4481
4482 #[tokio::test]
4485 async fn switch_with_stash_skips_stash_on_clean_tree() {
4486 let rec = RecordingRunner::new(
4487 ScriptedRunner::new()
4488 .on(["git", "status"], Reply::ok(""))
4489 .on(["git", "checkout"], Reply::ok("")),
4490 );
4491 let git = Git::with_runner(&rec);
4492 git.switch_with_stash(Path::new("/r"), "feature")
4493 .await
4494 .expect("switch");
4495 let calls = rec.calls();
4496 assert_eq!(calls.len(), 2);
4497 assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
4498 }
4499
4500 #[tokio::test]
4503 async fn switch_with_stash_restores_on_checkout_failure() {
4504 let rec = RecordingRunner::new(
4505 ScriptedRunner::new()
4506 .on(["git", "status"], Reply::ok(" M a.rs\0"))
4507 .on_sequence(
4508 ["git", "stash", "list"],
4509 [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4510 )
4511 .on(["git", "stash", "push"], Reply::ok(""))
4512 .on(
4513 ["git", "checkout"],
4514 Reply::fail(1, "error: pathspec 'nope'"),
4515 )
4516 .on(["git", "stash", "pop"], Reply::ok("")),
4517 );
4518 let git = Git::with_runner(&rec);
4519 let err = git
4520 .switch_with_stash(Path::new("/r"), "nope")
4521 .await
4522 .expect_err("checkout error must surface");
4523 assert!(matches!(err, Error::Exit { .. }));
4524 let calls = rec.calls();
4525 assert_eq!(
4526 calls.last().unwrap().args_str(),
4527 ["stash", "pop", "--index"],
4528 "restoring pop ran with --index"
4529 );
4530 }
4531
4532 #[tokio::test]
4535 async fn fetch_from_builds_args_and_retries() {
4536 let rec = RecordingRunner::replying(Reply::ok(""));
4537 let git = Git::with_runner(&rec);
4538 git.fetch_from(Path::new("/r"), "upstream")
4539 .await
4540 .expect("fetch_from");
4541 let call = rec.only_call();
4542 assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
4543 assert!(call.envs.iter().any(|(k, v)| {
4544 k.to_str() == Some("GIT_TERMINAL_PROMPT")
4545 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4546 }));
4547
4548 let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
4549 let git = Git::with_runner(&failing);
4550 assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
4551 assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
4552 }
4553
4554 #[cfg(feature = "mock")]
4557 #[tokio::test]
4558 async fn consumer_mocks_the_interface() {
4559 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
4560 git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
4561 }
4562 let mut mock = MockGitApi::new();
4563 mock.expect_current_branch()
4564 .returning(|_| Ok(Some("main".to_string())));
4565 assert!(on_branch(&mock, "main").await);
4566 }
4567}
4568
4569#[doc = include_str!("../docs/git.md")]
4571#[allow(rustdoc::broken_intra_doc_links)]
4572pub mod guide {
4573 #[doc = include_str!("../docs/security.md")]
4574 #[allow(rustdoc::broken_intra_doc_links)]
4575 pub mod security {}
4576 #[doc = include_str!("../docs/conflicts.md")]
4577 #[allow(rustdoc::broken_intra_doc_links)]
4578 pub mod conflicts {}
4579}