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;
572
573impl GitCapabilities {
574 pub fn is_supported(&self) -> bool {
576 self.version.major >= MIN_SUPPORTED_MAJOR
577 }
578
579 pub fn ensure_supported(&self) -> Result<()> {
582 if self.is_supported() {
583 return Ok(());
584 }
585 Err(Error::Spawn {
586 program: BINARY.to_string(),
587 source: std::io::Error::new(
588 std::io::ErrorKind::Unsupported,
589 format!(
590 "vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
591 found {}",
592 self.version
593 ),
594 ),
595 })
596 }
597}
598
599#[cfg_attr(feature = "mock", mockall::automock)]
611#[async_trait::async_trait]
612pub trait GitApi: Send + Sync {
613 async fn run(&self, args: &[String]) -> Result<String>;
620 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
623 async fn version(&self) -> Result<String>;
625 async fn capabilities(&self) -> Result<GitCapabilities>;
629 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
631 async fn status_text(&self, dir: &Path) -> Result<String>;
634 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
638 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
643 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
646 async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
654 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
656 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>>;
663 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
667 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
670 async fn init(&self, dir: &Path) -> Result<()>;
672 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
674 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
676 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
678 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
680 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
682 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
685 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
688 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
691 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
695
696 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
701 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
703 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
706 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
709 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
711 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
716 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
718 async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
721 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
724
725 async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool>;
732 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
735 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
737 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
739 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
741 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
743 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
746 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
751 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
753
754 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
758 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
761 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
763 async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
767
768 async fn fetch(&self, dir: &Path) -> Result<()>;
773 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
777 async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
781 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
783 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
785 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
789 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
798 async fn merge_abort(&self, dir: &Path) -> Result<()>;
800 async fn merge_continue(&self, dir: &Path) -> Result<()>;
802 async fn reset_merge(&self, dir: &Path) -> Result<()>;
808 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
810 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
813 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
815 async fn am_abort(&self, dir: &Path) -> Result<()>;
817 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
820 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
823 async fn stash_pop(&self, dir: &Path) -> Result<()>;
825
826 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
830 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
832 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
834 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
836 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
838
839 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
844 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
846 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
849 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
851 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
853 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
859 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
863 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
870 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
872 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
874 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
877
878 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
883 async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
885 async fn rebase_skip(&self, dir: &Path) -> Result<()>;
889}
890
891vcs_cli_support::managed_client! {
892 pub struct Git => BINARY, scrub_env = [
905 "GIT_DIR",
906 "GIT_WORK_TREE",
907 "GIT_INDEX_FILE",
908 "GIT_COMMON_DIR",
909 "GIT_OBJECT_DIRECTORY",
910 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
911 "GIT_NAMESPACE",
912 ]
913}
914
915impl<R: ProcessRunner> Git<R> {
916 pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
923 self.core = self.core.with_retry(policy);
924 self
925 }
926
927 #[must_use]
935 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
936 self.core = self.core.with_credentials(provider);
937 self
938 }
939
940 #[must_use]
947 pub fn with_token(self, token: impl Into<Secret>) -> Self {
948 self.with_credentials(Arc::new(StaticCredential::token(token)))
949 }
950
951 #[must_use]
955 pub fn with_env_token(self, var: impl Into<String>) -> Self {
956 self.with_credentials(Arc::new(EnvToken::new(var)))
957 }
958
959 async fn remote_credentials(
969 &self,
970 expect_host: Option<&str>,
971 ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
972 match self
973 .core
974 .resolve_credential(CredentialService::Git, None)
975 .await?
976 {
977 Some(cred) => {
978 let helper = git_credential_helper(&cred, expect_host);
979 Ok((helper.config_args, helper.env))
980 }
981 None => Ok((Vec::new(), Vec::new())),
982 }
983 }
984}
985
986fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
989 envs.iter()
990 .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
991}
992
993#[async_trait::async_trait]
994impl<R: ProcessRunner> GitApi for Git<R> {
995 async fn run(&self, args: &[String]) -> Result<String> {
996 self.core.run(args).await
997 }
998
999 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1000 self.core.output_string(args).await
1001 }
1002
1003 async fn version(&self) -> Result<String> {
1004 self.core.run(["--version"]).await
1005 }
1006
1007 async fn capabilities(&self) -> Result<GitCapabilities> {
1008 let raw = self.version().await?;
1009 let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
1010 program: BINARY.to_string(),
1011 message: format!("unrecognisable `git --version` output: {raw:?}"),
1012 })?;
1013 Ok(GitCapabilities { version })
1014 }
1015
1016 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1017 self.core
1018 .parse(
1019 self.core
1020 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
1021 parse::parse_porcelain,
1022 )
1023 .await
1024 }
1025
1026 async fn status_text(&self, dir: &Path) -> Result<String> {
1027 self.core
1028 .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
1029 .await
1030 }
1031
1032 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
1033 self.core
1039 .parse(
1040 self.core
1041 .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
1042 .env("GIT_OPTIONAL_LOCKS", "0"),
1043 parse::parse_porcelain_v2,
1044 )
1045 .await
1046 }
1047
1048 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1049 self.core
1050 .parse(
1051 self.core.command_in(
1052 dir,
1053 ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1054 ),
1055 parse::parse_porcelain,
1056 )
1057 .await
1058 }
1059
1060 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
1061 self.core
1063 .parse(
1064 self.core
1065 .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1066 parse::parse_nul_paths,
1067 )
1068 .await
1069 }
1070
1071 async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1072 let res = self
1081 .core
1082 .output_string(
1083 self.core
1084 .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1085 )
1086 .await?;
1087 match res.code() {
1088 Some(0) => Ok(Some(res.stdout().trim().to_string())),
1089 Some(1) => Ok(None), _ => {
1091 let _ = res.ensure_success()?;
1092 Ok(None) }
1094 }
1095 }
1096
1097 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1098 self.core
1103 .parse(
1104 self.core
1105 .command_in(dir, ["branch", "--no-column", "--no-color"]),
1106 parse::parse_branches,
1107 )
1108 .await
1109 }
1110
1111 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>> {
1112 reject_flag_like("revspec", revspec)?;
1113 let n = format!("-n{max}");
1114 self.core
1115 .parse(
1116 self.core.command_in(
1117 dir,
1118 [
1119 "log",
1120 revspec,
1121 n.as_str(),
1122 "-z",
1123 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1124 ],
1125 ),
1126 parse::parse_log,
1127 )
1128 .await
1129 }
1130
1131 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
1132 reject_flag_like("revision", rev)?;
1133 self.core
1139 .run(self.core.command_in(dir, ["rev-parse", "--verify", rev]))
1140 .await
1141 }
1142
1143 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
1144 reject_flag_like("revision", rev)?;
1145 self.core
1146 .run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
1147 .await
1148 }
1149
1150 async fn init(&self, dir: &Path) -> Result<()> {
1151 self.core
1152 .run_unit(self.core.command_in(dir, ["init"]))
1153 .await
1154 }
1155
1156 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1157 let mut command = self.core.command_in(dir, ["add", "--"]);
1159 for path in paths {
1160 command = command.arg(path);
1161 }
1162 self.core.run_unit(command).await
1163 }
1164
1165 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1166 self.core
1168 .run_unit(c_locale(
1169 self.core.command_in(dir, ["commit", "-m", message]),
1170 ))
1171 .await
1172 }
1173
1174 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
1175 reject_flag_like("branch name", name)?;
1176 self.core
1177 .run_unit(self.core.command_in(dir, ["branch", name]))
1178 .await
1179 }
1180
1181 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
1182 reject_flag_like("reference", reference)?;
1183 self.core
1190 .run_unit(self.core.command_in(dir, ["checkout", reference, "--"]))
1191 .await
1192 }
1193
1194 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1195 reject_flag_like("commit", commit)?;
1196 self.core
1197 .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1198 .await
1199 }
1200
1201 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1202 let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1206 if spec.amend {
1207 command = command.arg("--amend");
1208 }
1209 command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1210 for path in &spec.paths {
1211 command = command.arg(path);
1212 }
1213 self.core.run_unit(command).await
1214 }
1215
1216 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1217 self.core
1218 .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1219 .await
1220 }
1221
1222 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1223 Ok(!self
1227 .core
1228 .probe(
1229 self.core
1230 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1231 )
1232 .await?)
1233 }
1234
1235 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1236 self.core
1239 .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1240 .await
1241 }
1242
1243 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1244 Ok(PathBuf::from(
1245 self.core
1246 .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1247 .await?,
1248 ))
1249 }
1250
1251 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1252 Ok(PathBuf::from(
1253 self.core
1254 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1255 .await?,
1256 ))
1257 }
1258
1259 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1260 reject_flag_like("revision", rev)?;
1261 let spec = format!("{rev}^{{commit}}");
1263 self.core
1264 .run(
1265 self.core
1266 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1267 )
1268 .await
1269 }
1270
1271 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1272 let res = self
1278 .core
1279 .output_string(
1280 self.core
1281 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1282 )
1283 .await?;
1284 match res.code() {
1285 Some(0) => {
1286 let out = res.stdout().trim();
1289 Ok(Some(
1290 out.strip_prefix("refs/remotes/origin/")
1291 .unwrap_or(out)
1292 .to_string(),
1293 ))
1294 }
1295 Some(1) => Ok(None), _ => {
1297 let _ = res.ensure_success()?;
1298 Ok(None) }
1300 }
1301 }
1302
1303 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1304 let refname = format!("refs/heads/{name}");
1305 self.core
1307 .probe(
1308 self.core
1309 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1310 )
1311 .await
1312 }
1313
1314 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1315 let refname = format!("refs/heads/{name}");
1325 let (pre, envs) = self.remote_credentials(None).await?;
1326 let mut args: Vec<String> = pre;
1327 args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1328 let cmd = apply_secret_env(
1329 self.core
1330 .command_in(dir, &args)
1331 .env("GIT_TERMINAL_PROMPT", "0")
1332 .timeout(Duration::from_secs(10)),
1333 &envs,
1334 );
1335 let res = self.core.output_string(cmd).await?;
1336 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1337 }
1338
1339 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1340 reject_flag_like("remote name", remote)?;
1341 self.core
1342 .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1343 .await
1344 }
1345
1346 async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1347 let res = self
1355 .core
1356 .output_string(self.core.command_in(
1357 dir,
1358 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1359 ))
1360 .await?;
1361 match res.code() {
1362 Some(0) => {
1363 let name = res.stdout().trim();
1364 Ok((!name.is_empty()).then(|| name.to_string()))
1365 }
1366 Some(_) => Ok(None), None => {
1368 let _ = res.ensure_success()?; Ok(None) }
1371 }
1372 }
1373
1374 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1375 reject_flag_like("remote name", remote)?;
1376 let (pre, envs) = self.remote_credentials(None).await?;
1380 let mut args: Vec<String> = pre;
1381 args.extend(["ls-remote", "--heads", remote].map(String::from));
1382 let cmd = apply_secret_env(
1383 self.core
1384 .command_in(dir, &args)
1385 .env("GIT_TERMINAL_PROMPT", "0"),
1386 &envs,
1387 );
1388 self.core.parse(cmd, parse::parse_ls_remote_heads).await
1389 }
1390
1391 async fn is_merged(&self, dir: &Path, spec: MergeCheck) -> Result<bool> {
1392 reject_flag_like("branch", &spec.branch)?;
1393 reject_flag_like("base", &spec.base)?;
1394 let out = self
1399 .core
1400 .run(self.core.command_in(
1401 dir,
1402 [
1403 "branch",
1404 "--merged",
1405 spec.base.as_str(),
1406 "--no-column",
1407 "--no-color",
1408 ],
1409 ))
1410 .await?;
1411 Ok(out
1415 .lines()
1416 .filter_map(|line| line.get(2..))
1417 .any(|b| b == spec.branch.as_str()))
1418 }
1419
1420 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1421 reject_flag_like("branch name", branch)?;
1422 let flag = format!("--set-upstream-to={upstream}");
1423 self.core
1424 .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1425 .await
1426 }
1427
1428 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1429 reject_flag_like("branch name", name)?;
1430 let flag = if force { "-D" } else { "-d" };
1431 self.core
1432 .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1433 .await
1434 }
1435
1436 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1437 reject_flag_like("branch name", old)?;
1438 reject_flag_like("branch name", new)?;
1439 self.core
1440 .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1441 .await
1442 }
1443
1444 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1445 reject_flag_like("range", range)?;
1446 self.core
1447 .try_parse(
1448 self.core.command_in(dir, ["rev-list", "--count", range]),
1449 |s| {
1450 s.trim().parse::<usize>().map_err(|e| Error::Parse {
1451 program: BINARY.to_string(),
1452 message: e.to_string(),
1453 })
1454 },
1455 )
1456 .await
1457 }
1458
1459 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1460 reject_flag_like("range", range)?;
1461 self.core
1463 .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
1464 .await
1465 }
1466
1467 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1468 reject_flag_like("range", range)?;
1469 self.core
1474 .parse(
1475 c_locale(self.core.command_in(dir, ["diff", "--shortstat", range])),
1476 parse::parse_shortstat,
1477 )
1478 .await
1479 }
1480
1481 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1482 let target = match spec {
1486 DiffSpec::WorkingTree => {
1487 if self.is_unborn(dir).await? {
1491 EMPTY_TREE.to_string()
1492 } else {
1493 "HEAD".to_string()
1494 }
1495 }
1496 DiffSpec::Rev(rev) => {
1497 reject_flag_like("revision", &rev)?;
1498 rev
1499 }
1500 };
1501 self.core
1509 .run_untrimmed(self.core.command_in(
1510 dir,
1511 [
1512 "diff",
1513 target.as_str(),
1514 "--no-color",
1515 "--no-ext-diff",
1516 "-M",
1517 "--src-prefix=a/",
1518 "--dst-prefix=b/",
1519 ],
1520 ))
1521 .await
1522 }
1523
1524 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1525 let text = self.diff_text(dir, spec).await?;
1526 Ok(parse_diff(&text))
1527 }
1528
1529 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1530 self.core
1532 .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1533 .await
1534 }
1535
1536 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1537 let git_dir = self.resolved_git_dir(dir).await?;
1538 let rebase_apply = git_dir.join("rebase-apply");
1543 let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
1544 Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
1545 }
1546
1547 async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
1548 Ok(self
1551 .resolved_git_dir(dir)
1552 .await?
1553 .join("rebase-apply")
1554 .join("applying")
1555 .exists())
1556 }
1557
1558 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1559 Ok(self
1560 .resolved_git_dir(dir)
1561 .await?
1562 .join("MERGE_HEAD")
1563 .exists())
1564 }
1565
1566 async fn fetch(&self, dir: &Path) -> Result<()> {
1567 let (pre, envs) = self.remote_credentials(None).await?;
1575 let mut args: Vec<String> = pre;
1576 args.extend(["fetch", "--quiet"].map(String::from));
1577 let cmd = apply_secret_env(
1578 c_locale(self.core.command_in(dir, &args))
1579 .env("GIT_TERMINAL_PROMPT", "0")
1580 .timeout_grace(FETCH_TIMEOUT_GRACE)
1583 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1584 &envs,
1585 );
1586 self.core.run_unit(cmd).await
1587 }
1588
1589 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1590 reject_flag_like("remote", remote)?;
1594 let (pre, envs) = self.remote_credentials(None).await?;
1597 let mut args: Vec<String> = pre;
1598 args.extend(["fetch", "--quiet", remote].map(String::from));
1599 let cmd = apply_secret_env(
1600 c_locale(self.core.command_in(dir, &args))
1601 .env("GIT_TERMINAL_PROMPT", "0")
1602 .timeout_grace(FETCH_TIMEOUT_GRACE)
1603 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1604 &envs,
1605 );
1606 self.core.run_unit(cmd).await
1607 }
1608
1609 async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1610 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1611 let (pre, envs) = self.remote_credentials(None).await?;
1612 let mut args: Vec<String> = pre;
1613 args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
1614 let cmd = apply_secret_env(
1615 c_locale(self.core.command_in(dir, &args))
1616 .env("GIT_TERMINAL_PROMPT", "0")
1617 .timeout_grace(FETCH_TIMEOUT_GRACE)
1618 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1619 &envs,
1620 );
1621 self.core.run_unit(cmd).await
1622 }
1623
1624 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1625 reject_flag_like("remote", &spec.remote)?;
1626 reject_flag_like("refspec", &spec.refspec)?;
1627 let sides: Vec<&str> = spec.refspec.split(':').collect();
1636 if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
1637 return Err(processkit::Error::Spawn {
1638 program: BINARY.to_string(),
1639 source: std::io::Error::new(
1640 std::io::ErrorKind::InvalidInput,
1641 format!(
1642 "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
1643 metacharacter — pass a plain branch or `local:remote`, or use \
1644 `run([\"push\", …])` for a force-push",
1645 spec.refspec
1646 ),
1647 ),
1648 });
1649 }
1650 let (pre, envs) = self.remote_credentials(None).await?;
1651 let mut args: Vec<String> = pre;
1652 args.push("push".to_string());
1653 if spec.set_upstream {
1654 args.push("-u".to_string());
1655 }
1656 args.push(spec.remote.clone());
1657 args.push(spec.refspec.clone());
1658 let cmd = apply_secret_env(
1659 self.core
1660 .command_in(dir, &args)
1661 .env("GIT_TERMINAL_PROMPT", "0")
1662 .timeout_grace(FETCH_TIMEOUT_GRACE),
1667 &envs,
1668 );
1669 self.core.run_unit(cmd).await
1670 }
1671
1672 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1673 reject_flag_like("branch", branch)?;
1674 self.core
1677 .run_unit(c_locale(
1678 self.core.command_in(dir, ["merge", "--squash", branch]),
1679 ))
1680 .await
1681 }
1682
1683 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1684 reject_flag_like("branch", &spec.branch)?;
1685 let mut args: Vec<&str> = vec!["merge"];
1686 if spec.no_ff {
1687 args.push("--no-ff");
1688 }
1689 if let Some(msg) = spec.message.as_deref() {
1690 args.push("-m");
1691 args.push(msg);
1692 } else {
1693 args.push("--no-edit");
1696 }
1697 args.push(&spec.branch);
1698 self.core
1700 .run_unit(c_locale(self.core.command_in(dir, args)))
1701 .await
1702 }
1703
1704 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1705 reject_flag_like("branch", &spec.branch)?;
1706 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1707 if spec.squash {
1710 args.push("--squash");
1711 } else if spec.no_ff {
1712 args.push("--no-ff");
1713 }
1714 args.push(&spec.branch);
1715 self.core
1717 .run_unit(c_locale(self.core.command_in(dir, args)))
1718 .await
1719 }
1720
1721 async fn merge_abort(&self, dir: &Path) -> Result<()> {
1722 self.core
1723 .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1724 .await
1725 }
1726
1727 async fn merge_continue(&self, dir: &Path) -> Result<()> {
1728 self.core
1733 .run_unit(no_editor(c_locale(
1734 self.core.command_in(dir, ["commit", "--no-edit"]),
1735 )))
1736 .await
1737 }
1738
1739 async fn reset_merge(&self, dir: &Path) -> Result<()> {
1740 self.core
1741 .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1742 .await
1743 }
1744
1745 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1746 reject_flag_like("revision", rev)?;
1747 self.core
1748 .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1749 .await
1750 }
1751
1752 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1753 reject_flag_like("rebase target", onto)?;
1754 self.core
1758 .run_unit(no_editor(c_locale(
1759 self.core.command_in(dir, ["rebase", onto]),
1760 )))
1761 .await
1762 }
1763
1764 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1765 self.core
1766 .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1767 .await
1768 }
1769
1770 async fn am_abort(&self, dir: &Path) -> Result<()> {
1771 self.core
1772 .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
1773 .await
1774 }
1775
1776 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1777 self.core
1778 .run_unit(no_editor(c_locale(
1779 self.core.command_in(dir, ["rebase", "--continue"]),
1780 )))
1781 .await
1782 }
1783
1784 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1785 let mut command = self.core.command_in(dir, ["stash", "push"]);
1786 if include_untracked {
1787 command = command.arg("--include-untracked");
1788 }
1789 self.core.run_unit(command).await
1790 }
1791
1792 async fn stash_pop(&self, dir: &Path) -> Result<()> {
1793 self.core
1797 .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
1798 .await
1799 }
1800
1801 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1802 self.core
1803 .parse(
1804 self.core
1805 .command_in(dir, ["worktree", "list", "--porcelain"]),
1806 parse::parse_worktree_porcelain,
1807 )
1808 .await
1809 }
1810
1811 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1812 if let Some(name) = spec.new_branch.as_deref() {
1813 reject_flag_like("branch name", name)?;
1814 }
1815 if let Some(commitish) = spec.commitish.as_deref() {
1816 reject_flag_like("commit-ish", commitish)?;
1817 }
1818 let mut command = self.core.command_in(dir, ["worktree", "add"]);
1819 if let Some(name) = spec.new_branch.as_deref() {
1820 command = command.arg("-b").arg(name);
1821 }
1822 if spec.no_checkout {
1823 command = command.arg("--no-checkout");
1824 }
1825 command = command.arg(&spec.path);
1826 if let Some(commitish) = spec.commitish.as_deref() {
1827 command = command.arg(commitish);
1828 }
1829 self.core.run_unit(command).await
1830 }
1831
1832 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1833 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1834 if force {
1835 command = command.arg("--force");
1836 }
1837 command = command.arg(path);
1838 self.core.run_unit(command).await
1839 }
1840
1841 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1842 let command = self
1843 .core
1844 .command_in(dir, ["worktree", "move"])
1845 .arg(from)
1846 .arg(to);
1847 self.core.run_unit(command).await
1848 }
1849
1850 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1851 self.core
1852 .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1853 .await
1854 }
1855
1856 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1857 reject_flag_like("url", url)?;
1861 let (pre, envs) = self
1867 .remote_credentials(vcs_cli_support::https_host(url).as_deref())
1868 .await?;
1869 let mut initial: Vec<String> = pre;
1870 initial.push("clone".to_string());
1871 let mut command = self.core.command(&initial);
1872 if let Some(branch) = spec.branch.as_deref() {
1873 command = command.arg("--branch").arg(branch);
1874 }
1875 if let Some(depth) = spec.depth {
1876 command = command.arg("--depth").arg(depth.to_string());
1877 }
1878 if spec.bare {
1879 command = command.arg("--bare");
1880 }
1881 let command = apply_secret_env(
1882 command
1883 .arg(url)
1884 .arg(dest)
1885 .env("GIT_TERMINAL_PROMPT", "0")
1886 .timeout_grace(FETCH_TIMEOUT_GRACE),
1889 &envs,
1890 );
1891
1892 let cleanable = match std::fs::read_dir(dest) {
1904 Err(_) => true, Ok(mut entries) => entries.next().is_none(), };
1907 let result = self.core.run_unit(command).await;
1908 if result.is_err() && cleanable {
1909 let _ = std::fs::remove_dir_all(dest);
1910 }
1911 result
1912 }
1913
1914 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1915 reject_flag_like("tag name", name)?;
1916 if let Some(rev) = rev.as_deref() {
1917 reject_flag_like("revision", rev)?;
1918 }
1919 let mut args = vec!["tag", name];
1920 if let Some(rev) = rev.as_deref() {
1921 args.push(rev);
1922 }
1923 self.core.run_unit(self.core.command_in(dir, args)).await
1924 }
1925
1926 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1927 reject_flag_like("tag name", &spec.name)?;
1928 if let Some(rev) = spec.rev.as_deref() {
1929 reject_flag_like("revision", rev)?;
1930 }
1931 let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1932 if let Some(rev) = spec.rev.as_deref() {
1933 args.push(rev);
1934 }
1935 self.core.run_unit(self.core.command_in(dir, args)).await
1936 }
1937
1938 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1939 let out = self
1942 .core
1943 .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1944 .await?;
1945 Ok(out.lines().map(str::to_string).collect())
1946 }
1947
1948 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1949 reject_flag_like("tag name", name)?;
1950 self.core
1951 .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1952 .await
1953 }
1954
1955 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1956 reject_flag_like("revision", rev)?;
1959 #[cfg(windows)]
1964 let path = path.replace('\\', "/");
1965 let spec = format!("{rev}:{path}");
1966 self.core
1969 .run_untrimmed(self.core.command_in(dir, ["show", spec.as_str()]))
1970 .await
1971 }
1972
1973 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
1974 reject_flag_like("config key", key)?;
1975 let res = self
1976 .core
1977 .output_string(self.core.command_in(dir, ["config", "--get", key]))
1978 .await?;
1979 match res.code() {
1980 Some(1) => Ok(None),
1982 Some(0) => Ok(Some(
1987 res.stdout().trim_end_matches(['\r', '\n']).to_string(),
1988 )),
1989 _ => {
1990 let _ = res.ensure_success()?;
1991 Ok(None) }
1993 }
1994 }
1995
1996 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
1997 reject_flag_like("config key", key)?;
1998 self.core
1999 .run_unit(self.core.command_in(dir, ["config", key, value]))
2000 .await
2001 }
2002
2003 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2004 reject_flag_like("remote name", name)?;
2005 reject_flag_like("url", url)?;
2006 self.core
2007 .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
2008 .await
2009 }
2010
2011 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
2012 reject_flag_like("remote name", name)?;
2013 reject_flag_like("url", url)?;
2014 self.core
2015 .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
2016 .await
2017 }
2018
2019 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
2020 let mut args = vec!["blame", "--line-porcelain"];
2021 if let Some(rev) = rev.as_deref() {
2022 reject_flag_like("revision", rev)?;
2025 args.push(rev);
2026 }
2027 args.push("--");
2028 args.push(path);
2029 self.core
2030 .parse(
2031 self.core.command_in(dir, args),
2032 parse::parse_blame_porcelain,
2033 )
2034 .await
2035 }
2036
2037 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
2038 reject_flag_like("revision", rev)?;
2039 self.core
2042 .run_unit(no_editor(c_locale(
2043 self.core.command_in(dir, ["cherry-pick", rev]),
2044 )))
2045 .await
2046 }
2047
2048 async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
2049 reject_flag_like("revision", rev)?;
2050 self.core
2051 .run_unit(no_editor(c_locale(
2052 self.core.command_in(dir, ["revert", "--no-edit", rev]),
2053 )))
2054 .await
2055 }
2056
2057 async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2058 self.core
2059 .run_unit(no_editor(c_locale(
2060 self.core.command_in(dir, ["rebase", "--skip"]),
2061 )))
2062 .await
2063 }
2064}
2065
2066pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2077
2078const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2081const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2082const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2083
2084fn no_editor(cmd: processkit::Command) -> processkit::Command {
2088 cmd.env("GIT_EDITOR", "true")
2089 .env("GIT_SEQUENCE_EDITOR", "true")
2090}
2091
2092fn c_locale(cmd: processkit::Command) -> processkit::Command {
2098 cmd.env("LC_ALL", "C")
2099}
2100
2101fn reject_flag_like(what: &str, value: &str) -> Result<()> {
2105 vcs_cli_support::reject_flag_like(BINARY, what, value)
2106}
2107
2108impl<R: ProcessRunner> Git<R> {
2109 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
2114 self.core.run(args).await
2115 }
2116
2117 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
2120 self.core.output_string(args).await
2121 }
2122
2123 pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
2128 GitAt { git: self, dir }
2129 }
2130
2131 pub fn harden(self) -> Self {
2204 let removed = [
2205 "GIT_DIR",
2210 "GIT_WORK_TREE",
2211 "GIT_INDEX_FILE",
2212 "GIT_COMMON_DIR",
2213 "GIT_OBJECT_DIRECTORY",
2214 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
2215 "GIT_NAMESPACE",
2216 "GIT_CEILING_DIRECTORIES",
2217 "GIT_CONFIG_PARAMETERS",
2218 "GIT_CONFIG_GLOBAL",
2219 "GIT_CONFIG_SYSTEM",
2220 "GIT_SSH_COMMAND",
2222 "GIT_SSH",
2223 "GIT_ASKPASS",
2224 "GIT_EXTERNAL_DIFF",
2225 "GIT_PAGER",
2226 "GIT_EDITOR",
2227 "GIT_SEQUENCE_EDITOR",
2228 "GIT_PROXY_COMMAND",
2233 "GIT_EXEC_PATH",
2234 "GIT_TEMPLATE_DIR",
2235 "GIT_LITERAL_PATHSPECS",
2238 "GIT_GLOB_PATHSPECS",
2239 "GIT_NOGLOB_PATHSPECS",
2240 "GIT_ICASE_PATHSPECS",
2241 ];
2242 let mut hardened = self;
2243 for key in removed {
2244 hardened = hardened.default_env_remove(key);
2245 }
2246 hardened
2247 .default_env("GIT_CONFIG_NOSYSTEM", "1")
2248 .default_env("GIT_TERMINAL_PROMPT", "0")
2249 .default_env("GIT_CONFIG_COUNT", "3")
2254 .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
2255 .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
2262 .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
2263 .default_env("GIT_CONFIG_VALUE_1", "false")
2264 .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
2270 .default_env("GIT_CONFIG_VALUE_2", "")
2271 }
2272
2273 pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
2296 if self.status(dir).await?.is_empty() {
2299 return self.checkout(dir, branch).await;
2300 }
2301 let depth_before = self.stash_depth(dir).await?;
2308 self.stash_push(dir, true).await?;
2309 if self.stash_depth(dir).await? <= depth_before {
2310 return self.checkout(dir, branch).await;
2312 }
2313 match self.checkout(dir, branch).await {
2316 Ok(()) => self.stash_pop_index(dir).await,
2317 Err(err) => {
2318 let _ = self.stash_pop_index(dir).await;
2322 Err(err)
2323 }
2324 }
2325 }
2326
2327 async fn stash_depth(&self, dir: &Path) -> Result<usize> {
2331 let out = self
2332 .core
2333 .run(self.core.command_in(dir, ["stash", "list"]))
2334 .await?;
2335 Ok(out.lines().filter(|l| !l.is_empty()).count())
2336 }
2337
2338 async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
2342 self.core
2343 .run_unit(c_locale(
2344 self.core.command_in(dir, ["stash", "pop", "--index"]),
2345 ))
2346 .await
2347 }
2348
2349 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
2352 let git_dir = PathBuf::from(
2353 self.core
2354 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
2355 .await?,
2356 );
2357 Ok(if git_dir.is_absolute() {
2358 git_dir
2359 } else {
2360 dir.join(git_dir)
2361 })
2362 }
2363}
2364
2365impl Git {
2366 pub fn hardened() -> Self {
2369 Self::new().harden()
2370 }
2371}
2372
2373pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
2378 git: &'a Git<R>,
2379 dir: &'a Path,
2380}
2381
2382impl<R: ProcessRunner> Clone for GitAt<'_, R> {
2387 fn clone(&self) -> Self {
2388 *self
2389 }
2390}
2391impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
2392
2393vcs_cli_support::at_forwarders! {
2397 GitAt, git, "Git",
2398 bare {
2399 fn run(args: &[String]) -> Result<String>;
2400 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
2401 fn run_args(args: &[&str]) -> Result<String>;
2402 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
2403 fn version() -> Result<String>;
2404 fn capabilities() -> Result<GitCapabilities>;
2405 fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
2406 }
2407 dir {
2408 fn status() -> Result<Vec<StatusEntry>>;
2409 fn status_text() -> Result<String>;
2410 fn status_tracked() -> Result<Vec<StatusEntry>>;
2411 fn branch_status() -> Result<BranchStatus>;
2412 fn conflicted_files() -> Result<Vec<String>>;
2413 fn current_branch() -> Result<Option<String>>;
2414 fn branches() -> Result<Vec<Branch>>;
2415 fn log(revspec: &str, max: usize) -> Result<Vec<Commit>>;
2416 fn rev_parse(rev: &str) -> Result<String>;
2417 fn rev_parse_short(rev: &str) -> Result<String>;
2418 fn init() -> Result<()>;
2419 fn add(paths: &[PathBuf]) -> Result<()>;
2420 fn commit(message: &str) -> Result<()>;
2421 fn create_branch(name: &str) -> Result<()>;
2422 fn checkout(reference: &str) -> Result<()>;
2423 fn checkout_detach(commit: &str) -> Result<()>;
2424 fn commit_paths(spec: CommitPaths) -> Result<()>;
2425 fn last_commit_message() -> Result<String>;
2426 fn is_unborn() -> Result<bool>;
2427 fn diff_is_empty() -> Result<bool>;
2428 fn common_dir() -> Result<PathBuf>;
2429 fn git_dir() -> Result<PathBuf>;
2430 fn resolve_commit(rev: &str) -> Result<String>;
2431 fn remote_head_branch() -> Result<Option<String>>;
2432 fn branch_exists(name: &str) -> Result<bool>;
2433 fn remote_branch_exists(name: &str) -> Result<bool>;
2434 fn remote_url(remote: &str) -> Result<String>;
2435 fn upstream() -> Result<Option<String>>;
2436 fn remote_branches(remote: &str) -> Result<Vec<String>>;
2437 fn is_merged(spec: MergeCheck) -> Result<bool>;
2438 fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
2439 fn delete_branch(name: &str, force: bool) -> Result<()>;
2440 fn rename_branch(old: &str, new: &str) -> Result<()>;
2441 fn rev_list_count(range: &str) -> Result<usize>;
2442 fn diff_range_is_empty(range: &str) -> Result<bool>;
2443 fn diff_stat(range: &str) -> Result<DiffStat>;
2444 fn diff_text(spec: DiffSpec) -> Result<String>;
2445 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
2446 fn staged_is_empty() -> Result<bool>;
2447 fn is_rebase_in_progress() -> Result<bool>;
2448 fn is_merge_in_progress() -> Result<bool>;
2449 fn is_am_in_progress() -> Result<bool>;
2450 fn fetch() -> Result<()>;
2451 fn fetch_from(remote: &str) -> Result<()>;
2452 fn fetch_branch(branch: &str) -> Result<()>;
2453 fn push(spec: GitPush) -> Result<()>;
2454 fn merge_squash(branch: &str) -> Result<()>;
2455 fn merge_commit(spec: MergeCommit) -> Result<()>;
2456 fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
2457 fn merge_abort() -> Result<()>;
2458 fn merge_continue() -> Result<()>;
2459 fn reset_merge() -> Result<()>;
2460 fn reset_hard(rev: &str) -> Result<()>;
2461 fn rebase(onto: &str) -> Result<()>;
2462 fn rebase_abort() -> Result<()>;
2463 fn am_abort() -> Result<()>;
2464 fn rebase_continue() -> Result<()>;
2465 fn stash_push(include_untracked: bool) -> Result<()>;
2466 fn stash_pop() -> Result<()>;
2467 fn switch_with_stash(branch: &str) -> Result<()>;
2468 fn worktree_list() -> Result<Vec<Worktree>>;
2469 fn worktree_add(spec: WorktreeAdd) -> Result<()>;
2470 fn worktree_remove(path: &Path, force: bool) -> Result<()>;
2471 fn worktree_move(from: &Path, to: &Path) -> Result<()>;
2472 fn worktree_prune() -> Result<()>;
2473 fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
2474 fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2475 fn tag_list() -> Result<Vec<String>>;
2476 fn tag_delete(name: &str) -> Result<()>;
2477 fn show_file(rev: &str, path: &str) -> Result<String>;
2478 fn config_get(key: &str) -> Result<Option<String>>;
2479 fn config_set(key: &str, value: &str) -> Result<()>;
2480 fn remote_add(name: &str, url: &str) -> Result<()>;
2481 fn remote_set_url(name: &str, url: &str) -> Result<()>;
2482 fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2483 fn cherry_pick(rev: &str) -> Result<()>;
2484 fn revert(rev: &str) -> Result<()>;
2485 fn rebase_skip() -> Result<()>;
2486 }
2487}
2488
2489pub mod blocking {
2493 use std::path::Path;
2494 use std::process::Command;
2495
2496 pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2498 let mut cmd = Command::new(super::BINARY);
2499 cmd.current_dir(dir).args(["worktree", "remove"]);
2500 if force {
2501 cmd.arg("--force");
2502 }
2503 cmd.arg(path);
2504 let status = cmd.status()?;
2505 if status.success() {
2506 Ok(())
2507 } else {
2508 Err(std::io::Error::other(format!(
2509 "`git worktree remove` exited with {status}"
2510 )))
2511 }
2512 }
2513}
2514
2515#[cfg(test)]
2516mod tests {
2517 use super::*;
2518 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
2519
2520 #[test]
2521 fn binary_name_is_git() {
2522 assert_eq!(BINARY, "git");
2523 }
2524
2525 #[allow(dead_code)]
2529 fn bound_view_is_copy_for_default_runner() {
2530 fn assert_copy<T: Copy>() {}
2531 assert_copy::<GitAt<'static, processkit::JobRunner>>();
2532 }
2533
2534 #[tokio::test]
2538 async fn bound_view_matches_dir_taking_calls() {
2539 let dir = Path::new("/repo");
2540 let rec = RecordingRunner::replying(Reply::ok(""));
2541 let git = Git::with_runner(&rec);
2542
2543 git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2545 .await
2546 .unwrap();
2547 git.at(dir)
2548 .merge_commit(MergeCommit::branch("feat").no_ff())
2549 .await
2550 .unwrap();
2551 git.worktree_remove(dir, Path::new("/wt"), true)
2553 .await
2554 .unwrap();
2555 git.at(dir)
2556 .worktree_remove(Path::new("/wt"), true)
2557 .await
2558 .unwrap();
2559 git.conflicted_files(dir).await.unwrap();
2561 git.at(dir).conflicted_files().await.unwrap();
2562 git.tag_delete(dir, "v1").await.unwrap();
2564 git.at(dir).tag_delete("v1").await.unwrap();
2565
2566 let calls = rec.calls();
2567 assert_eq!(calls[0].args_str(), calls[1].args_str());
2568 assert_eq!(calls[2].args_str(), calls[3].args_str());
2569 assert_eq!(calls[4].args_str(), calls[5].args_str());
2570 assert_eq!(calls[6].args_str(), calls[7].args_str());
2571 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2573 assert_eq!(calls[3].cwd.as_deref(), Some(dir));
2574 }
2575
2576 #[tokio::test]
2579 async fn status_parses_scripted_output() {
2580 let git = Git::with_runner(
2582 ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
2583 );
2584 let entries = git.status(Path::new(".")).await.expect("status");
2585 assert_eq!(entries.len(), 2);
2586 assert_eq!(entries[0].code, " M");
2587 assert_eq!(entries[1].path, "b.rs");
2588 }
2589
2590 #[tokio::test]
2592 async fn status_tracked_excludes_untracked_flag() {
2593 let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2594 let git = Git::with_runner(&rec);
2595 let entries = git.status_tracked(Path::new(".")).await.expect("status");
2596 assert_eq!(entries.len(), 1);
2597 assert_eq!(entries[0].code, " M");
2598 assert_eq!(
2599 rec.only_call().args_str(),
2600 ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2601 );
2602 }
2603
2604 #[tokio::test]
2607 async fn branch_status_builds_v2_branch_args_and_parses() {
2608 let out = concat!(
2609 "# branch.oid abc\0",
2610 "# branch.head main\0",
2611 "# branch.upstream origin/main\0",
2612 "# branch.ab +1 -0\0",
2613 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2614 "? new.txt\0",
2615 );
2616 let rec = RecordingRunner::replying(Reply::ok(out));
2617 let git = Git::with_runner(&rec);
2618 let s = git
2619 .branch_status(Path::new("."))
2620 .await
2621 .expect("branch_status");
2622 assert_eq!(
2623 rec.only_call().args_str(),
2624 ["status", "--porcelain=v2", "--branch", "-z"]
2625 );
2626 assert!(rec.only_call().envs.iter().any(|(k, v)| {
2629 k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2630 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2631 }));
2632 assert_eq!(s.branch.as_deref(), Some("main"));
2633 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2634 assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2635 assert_eq!(s.tracked_changes, 1);
2636 assert_eq!(s.untracked, 1);
2637 assert!(s.is_dirty());
2638 }
2639
2640 #[tokio::test]
2642 async fn conflicted_files_builds_args_and_parses_nul_list() {
2643 let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2644 let git = Git::with_runner(&rec);
2645 let paths = git
2646 .conflicted_files(Path::new("."))
2647 .await
2648 .expect("conflicted_files");
2649 assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2650 assert_eq!(
2651 rec.only_call().args_str(),
2652 ["diff", "--name-only", "--diff-filter=U", "-z"]
2653 );
2654 }
2655
2656 #[tokio::test]
2657 async fn rev_parse_short_builds_short_flag() {
2658 let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2659 let git = Git::with_runner(&rec);
2660 let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2661 assert_eq!(out, "a1b2c3d");
2662 assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
2663 }
2664
2665 #[tokio::test]
2668 async fn rev_parse_verifies_the_revision() {
2669 let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
2670 let git = Git::with_runner(&rec);
2671 let out = git.rev_parse(Path::new("/r"), "HEAD").await.unwrap();
2672 assert_eq!(out, "deadbeef");
2673 assert_eq!(
2674 rec.only_call().args_str(),
2675 ["rev-parse", "--verify", "HEAD"]
2676 );
2677 }
2678
2679 #[tokio::test]
2683 async fn distinguishes_git_am_from_an_apply_backend_rebase() {
2684 use vcs_testkit::TempDir;
2685 let gd = TempDir::new("m20-am");
2686 let git = Git::with_runner(ScriptedRunner::new().on(
2687 ["git", "rev-parse", "--git-dir"],
2688 Reply::ok(gd.path().to_str().unwrap()),
2689 ));
2690 let apply = gd.path().join("rebase-apply");
2691 std::fs::create_dir_all(&apply).unwrap();
2692
2693 std::fs::write(apply.join("applying"), b"").unwrap();
2695 assert!(
2696 git.is_am_in_progress(Path::new("/r")).await.unwrap(),
2697 "am detected"
2698 );
2699 assert!(
2700 !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2701 "a git am is NOT reported as a rebase"
2702 );
2703
2704 std::fs::remove_file(apply.join("applying")).unwrap();
2706 assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
2707 assert!(
2708 git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2709 "a bare rebase-apply dir is a rebase"
2710 );
2711 }
2712
2713 #[tokio::test]
2715 async fn nonzero_exit_is_structured_error() {
2716 let git = Git::with_runner(
2717 ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
2718 );
2719 match git.status(Path::new(".")).await.unwrap_err() {
2720 Error::Exit { code, stderr, .. } => {
2721 assert_eq!(code, 128);
2722 assert!(stderr.contains("not a git repository"), "{stderr}");
2723 }
2724 other => panic!("expected Exit, got {other:?}"),
2725 }
2726 }
2727
2728 #[tokio::test]
2731 async fn diff_is_empty_maps_exit_codes() {
2732 let clean =
2733 Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
2734 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2735
2736 let dirty = Git::with_runner(
2737 ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
2738 );
2739 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2740
2741 let broken = Git::with_runner(ScriptedRunner::new().on(
2742 ["git", "diff", "--quiet"],
2743 Reply::fail(128, "fatal: not a repo"),
2744 ));
2745 assert!(matches!(
2746 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2747 Error::Exit { code: 128, .. }
2748 ));
2749 }
2750
2751 #[tokio::test]
2754 async fn add_inserts_pathspec_separator() {
2755 let git = Git::with_runner(ScriptedRunner::new().on(["git", "add", "--"], Reply::ok("")));
2756 git.add(Path::new("."), &[PathBuf::from("f.rs")])
2757 .await
2758 .expect("add should build `add -- <paths>`");
2759 }
2760
2761 #[tokio::test]
2762 async fn worktree_list_parses_porcelain() {
2763 let git = Git::with_runner(ScriptedRunner::new().on(
2764 ["git", "worktree", "list"],
2765 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2766 ));
2767 let wts = git.worktree_list(Path::new(".")).await.expect("list");
2768 assert_eq!(wts.len(), 1);
2769 assert_eq!(wts[0].branch.as_deref(), Some("main"));
2770 assert_eq!(wts[0].head.as_deref(), Some("abc"));
2771 }
2772
2773 #[tokio::test]
2776 async fn worktree_add_builds_branch_path_and_base() {
2777 let rec = RecordingRunner::replying(Reply::ok(""));
2778 let git = Git::with_runner(&rec);
2779 git.worktree_add(
2780 Path::new("/repo"),
2781 WorktreeAdd::create_branch("/wt", "feature", "main"),
2782 )
2783 .await
2784 .expect("worktree add");
2785 assert_eq!(
2786 rec.only_call().args_str(),
2787 ["worktree", "add", "-b", "feature", "/wt", "main"]
2788 );
2789 }
2790
2791 #[tokio::test]
2792 async fn worktree_remove_passes_force_then_path() {
2793 let rec = RecordingRunner::replying(Reply::ok(""));
2794 let git = Git::with_runner(&rec);
2795 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2796 .await
2797 .expect("remove");
2798 assert_eq!(
2799 rec.only_call().args_str(),
2800 ["worktree", "remove", "--force", "/wt"]
2801 );
2802 }
2803
2804 #[tokio::test]
2806 async fn worktree_add_no_checkout_inserts_flag() {
2807 let rec = RecordingRunner::replying(Reply::ok(""));
2808 let git = Git::with_runner(&rec);
2809 git.worktree_add(
2810 Path::new("/repo"),
2811 WorktreeAdd::checkout("/wt", "main").no_checkout(),
2812 )
2813 .await
2814 .expect("worktree add");
2815 assert_eq!(
2816 rec.only_call().args_str(),
2817 ["worktree", "add", "--no-checkout", "/wt", "main"]
2818 );
2819 }
2820
2821 #[tokio::test]
2822 async fn checkout_detach_builds_args() {
2823 let rec = RecordingRunner::replying(Reply::ok(""));
2824 let git = Git::with_runner(&rec);
2825 git.checkout_detach(Path::new("."), "abc123")
2826 .await
2827 .expect("detach");
2828 assert_eq!(
2829 rec.only_call().args_str(),
2830 ["checkout", "--detach", "abc123"]
2831 );
2832 }
2833
2834 #[tokio::test]
2838 async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
2839 let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
2841 let on_branch = Git::with_runner(&rec);
2842 assert_eq!(
2843 on_branch.current_branch(Path::new(".")).await.unwrap(),
2844 Some("feature/x".to_string())
2845 );
2846 assert_eq!(
2847 rec.only_call().args_str(),
2848 ["symbolic-ref", "--quiet", "--short", "HEAD"]
2849 );
2850 let unborn = Git::with_runner(
2853 ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
2854 );
2855 assert_eq!(
2856 unborn.current_branch(Path::new(".")).await.unwrap(),
2857 Some("main".to_string())
2858 );
2859 let detached =
2861 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2862 assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
2863 let not_repo = Git::with_runner(ScriptedRunner::new().on(
2865 ["git", "symbolic-ref"],
2866 Reply::fail(128, "fatal: not a git repository"),
2867 ));
2868 assert!(not_repo.current_branch(Path::new(".")).await.is_err());
2869 }
2870
2871 #[tokio::test]
2873 async fn commit_paths_builds_only_amend_args() {
2874 let rec = RecordingRunner::replying(Reply::ok(""));
2875 let git = Git::with_runner(&rec);
2876 git.commit_paths(
2877 Path::new("."),
2878 CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2879 )
2880 .await
2881 .expect("commit_paths");
2882 assert_eq!(
2883 rec.only_call().args_str(),
2884 [
2885 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2886 ]
2887 );
2888 }
2889
2890 #[tokio::test]
2893 async fn is_unborn_maps_exit_codes() {
2894 let born =
2895 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
2896 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2897 let unborn =
2898 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
2899 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2900 let broken = Git::with_runner(
2901 ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
2902 );
2903 assert!(matches!(
2904 broken.is_unborn(Path::new(".")).await.unwrap_err(),
2905 Error::Exit { code: 128, .. }
2906 ));
2907 }
2908
2909 #[tokio::test]
2910 async fn log_builds_revspec_and_format() {
2911 let rec = RecordingRunner::replying(Reply::ok(""));
2912 let git = Git::with_runner(&rec);
2913 git.log(Path::new("."), "main..HEAD", 5).await.expect("log");
2914 assert_eq!(
2915 rec.only_call().args_str(),
2916 [
2917 "log",
2918 "main..HEAD",
2919 "-n5",
2920 "-z",
2921 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2922 ]
2923 );
2924 }
2925
2926 #[tokio::test]
2927 async fn stash_push_adds_include_untracked() {
2928 let rec = RecordingRunner::replying(Reply::ok(""));
2929 let git = Git::with_runner(&rec);
2930 git.stash_push(Path::new("."), true).await.expect("stash");
2931 assert_eq!(
2932 rec.only_call().args_str(),
2933 ["stash", "push", "--include-untracked"]
2934 );
2935 }
2936
2937 #[tokio::test]
2940 async fn diff_text_builds_working_tree_args() {
2941 let rec = RecordingRunner::replying(Reply::ok(""));
2944 let git = Git::with_runner(&rec);
2945 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2946 .await
2947 .expect("diff_text");
2948 assert_eq!(
2949 rec.calls().last().unwrap().args_str(),
2950 [
2951 "diff",
2952 "HEAD",
2953 "--no-color",
2954 "--no-ext-diff",
2955 "-M",
2956 "--src-prefix=a/",
2959 "--dst-prefix=b/",
2960 ]
2961 );
2962 }
2963
2964 #[tokio::test]
2968 async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
2969 let git = Git::with_runner(
2970 ScriptedRunner::new()
2971 .on(["git", "rev-parse"], Reply::fail(1, "")) .on(["git", "diff", EMPTY_TREE], Reply::ok("EMPTY")),
2973 );
2974 let out = git
2975 .diff_text(Path::new("."), DiffSpec::WorkingTree)
2976 .await
2977 .expect("diff_text");
2978 assert_eq!(out, "EMPTY");
2979 }
2980
2981 #[tokio::test]
2984 async fn diff_parses_scripted_output() {
2985 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2986 let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
2987 let files = git
2988 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
2989 .await
2990 .expect("diff");
2991 assert_eq!(files.len(), 1);
2992 assert_eq!(files[0].path, "m");
2993 assert_eq!(files[0].change, ChangeKind::Modified);
2994 }
2995
2996 #[tokio::test]
2997 async fn branch_exists_maps_exit_codes() {
2998 let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
2999 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
3000 let no =
3001 Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
3002 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
3003 }
3004
3005 #[tokio::test]
3008 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
3009 let simple = Git::with_runner(ScriptedRunner::new().on(
3010 ["git", "symbolic-ref"],
3011 Reply::ok("refs/remotes/origin/main\n"),
3012 ));
3013 assert_eq!(
3014 simple
3015 .remote_head_branch(Path::new("."))
3016 .await
3017 .unwrap()
3018 .as_deref(),
3019 Some("main")
3020 );
3021
3022 let slashed = Git::with_runner(ScriptedRunner::new().on(
3023 ["git", "symbolic-ref"],
3024 Reply::ok("refs/remotes/origin/release/v2\n"),
3025 ));
3026 assert_eq!(
3027 slashed
3028 .remote_head_branch(Path::new("."))
3029 .await
3030 .unwrap()
3031 .as_deref(),
3032 Some("release/v2")
3033 );
3034
3035 let unset =
3036 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3037 assert!(
3038 unset
3039 .remote_head_branch(Path::new("."))
3040 .await
3041 .unwrap()
3042 .is_none()
3043 );
3044 }
3045
3046 #[tokio::test]
3049 async fn remote_branch_exists_sets_env_and_reads_stdout() {
3050 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
3051 let git = Git::with_runner(&rec);
3052 assert!(
3053 git.remote_branch_exists(Path::new("/repo"), "main")
3054 .await
3055 .unwrap()
3056 );
3057 let call = rec.only_call();
3058 assert!(call.envs.iter().any(|(k, v)| {
3059 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3060 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3061 }));
3062 assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
3064
3065 let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
3066 assert!(
3067 !empty
3068 .remote_branch_exists(Path::new("."), "x")
3069 .await
3070 .unwrap()
3071 );
3072 }
3073
3074 #[tokio::test]
3075 async fn diff_stat_parses_counts() {
3076 let git = Git::with_runner(ScriptedRunner::new().on(
3077 ["git", "diff", "--shortstat"],
3078 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
3079 ));
3080 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
3081 assert_eq!(
3082 (stat.files_changed, stat.insertions, stat.deletions),
3083 (2, 5, 1)
3084 );
3085 }
3086
3087 #[tokio::test]
3088 async fn status_text_returns_raw_porcelain() {
3089 let git = Git::with_runner(ScriptedRunner::new().on(
3090 ["git", "status", "--porcelain=v1"],
3091 Reply::ok(" M a.rs\n?? b.rs\n"),
3092 ));
3093 let text = git.status_text(Path::new(".")).await.expect("status_text");
3094 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
3095 }
3096
3097 #[tokio::test]
3098 async fn run_args_forwards_str_slices() {
3099 let git =
3100 Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
3101 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
3102 }
3103
3104 #[tokio::test]
3105 async fn merge_commit_builds_no_ff_and_message() {
3106 let rec = RecordingRunner::replying(Reply::ok(""));
3107 let git = Git::with_runner(&rec);
3108 git.merge_commit(
3109 Path::new("/r"),
3110 MergeCommit::branch("feature").no_ff().message("merge it"),
3111 )
3112 .await
3113 .unwrap();
3114 assert_eq!(
3115 rec.only_call().args_str(),
3116 ["merge", "--no-ff", "-m", "merge it", "feature"]
3117 );
3118 }
3119
3120 #[tokio::test]
3122 async fn merge_commit_without_message_uses_no_edit() {
3123 let rec = RecordingRunner::replying(Reply::ok(""));
3124 let git = Git::with_runner(&rec);
3125 git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
3126 .await
3127 .unwrap();
3128 assert_eq!(
3129 rec.only_call().args_str(),
3130 ["merge", "--no-edit", "feature"]
3131 );
3132 }
3133
3134 #[tokio::test]
3136 async fn rebase_suppresses_editor() {
3137 let rec = RecordingRunner::replying(Reply::ok(""));
3138 let git = Git::with_runner(&rec);
3139 git.rebase(Path::new("/r"), "main").await.unwrap();
3140 let call = rec.only_call();
3141 assert_eq!(call.args_str(), ["rebase", "main"]);
3142 assert!(call.envs.iter().any(|(k, v)| {
3143 k.to_str() == Some("GIT_EDITOR")
3144 && v.as_deref().and_then(|o| o.to_str()) == Some("true")
3145 }));
3146 }
3147
3148 #[tokio::test]
3149 async fn push_builds_set_upstream_remote_refspec() {
3150 let rec = RecordingRunner::replying(Reply::ok(""));
3151 let git = Git::with_runner(&rec);
3152 git.push(
3153 Path::new("/r"),
3154 GitPush::refspec("feat", "feature").set_upstream(),
3155 )
3156 .await
3157 .unwrap();
3158 assert_eq!(
3159 rec.only_call().args_str(),
3160 ["push", "-u", "origin", "feat:feature"]
3161 );
3162 }
3163
3164 #[tokio::test]
3167 async fn push_bare_branch_builds_origin_branch_prompt_off() {
3168 let rec = RecordingRunner::replying(Reply::ok(""));
3169 let git = Git::with_runner(&rec);
3170 git.push(Path::new("/r"), GitPush::branch("feature"))
3171 .await
3172 .unwrap();
3173 let call = rec.only_call();
3174 assert_eq!(call.args_str(), ["push", "origin", "feature"]);
3175 assert!(call.envs.iter().any(|(k, v)| {
3176 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3177 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3178 }));
3179 }
3180
3181 #[tokio::test]
3184 async fn push_rejects_force_and_multiref_metacharacters() {
3185 let rec = RecordingRunner::replying(Reply::ok(""));
3186 let git = Git::with_runner(&rec);
3187 for bad in ["+main", "+main:main", "a:b:c"] {
3188 assert!(
3189 git.push(Path::new("/r"), GitPush::branch(bad))
3190 .await
3191 .is_err(),
3192 "{bad:?} must be rejected"
3193 );
3194 }
3195 assert!(
3197 git.push(Path::new("/r"), GitPush::refspec("main", "prod"))
3198 .await
3199 .is_ok()
3200 );
3201 assert!(
3202 rec.calls()
3203 .iter()
3204 .all(|c| c.args_str().last().unwrap() != "+main")
3205 );
3206 }
3207
3208 #[tokio::test]
3210 async fn push_remote_override_swaps_remote() {
3211 let rec = RecordingRunner::replying(Reply::ok(""));
3212 let git = Git::with_runner(&rec);
3213 git.push(
3214 Path::new("/r"),
3215 GitPush::branch("feature").remote("upstream"),
3216 )
3217 .await
3218 .unwrap();
3219 assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
3220 }
3221
3222 #[tokio::test]
3226 async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
3227 let rec = RecordingRunner::replying(Reply::ok(""));
3228 let git = Git::with_runner(&rec)
3229 .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
3230 git.push(Path::new("/r"), GitPush::branch("feature"))
3231 .await
3232 .unwrap();
3233 let call = rec.only_call();
3234 let args = call.args_str();
3235 assert_eq!(args[0], "-c", "config flag leads the argv");
3237 assert!(
3238 args.iter().any(|a| a == "credential.helper="),
3239 "inherited helpers are cleared first: {args:?}"
3240 );
3241 assert!(
3242 args.iter()
3243 .any(|a| a.contains("credential.helper=!f()")
3244 && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
3245 "inline helper references the secret by env-var name: {args:?}"
3246 );
3247 assert!(
3248 args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
3249 "the real subcommand still runs: {args:?}"
3250 );
3251 assert!(
3253 !args.iter().any(|a| a.contains("ghp_secret123")),
3254 "secret leaked into argv: {args:?}"
3255 );
3256 let pw = call
3258 .envs
3259 .iter()
3260 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3261 .and_then(|(_, v)| v.as_ref())
3262 .and_then(|v| v.to_str());
3263 assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
3264 }
3265
3266 #[tokio::test]
3269 async fn default_client_injects_no_credential_helper() {
3270 let rec = RecordingRunner::replying(Reply::ok(""));
3271 let git = Git::with_runner(&rec);
3272 git.push(Path::new("/r"), GitPush::branch("feature"))
3273 .await
3274 .unwrap();
3275 let call = rec.only_call();
3276 assert_eq!(
3277 call.args_str(),
3278 ["push", "origin", "feature"],
3279 "no credential `-c` args without a provider"
3280 );
3281 assert!(
3282 !call
3283 .envs
3284 .iter()
3285 .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
3286 "no secret env without a provider"
3287 );
3288 }
3289
3290 #[tokio::test]
3294 async fn with_credentials_clone_puts_config_flags_before_subcommand() {
3295 let rec = RecordingRunner::replying(Reply::ok(""));
3296 let git =
3297 Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
3298 git.clone_repo(
3299 "https://example.com/r.git",
3300 Path::new("/dest"),
3301 CloneSpec::default().branch("main"),
3302 )
3303 .await
3304 .unwrap();
3305 let call = rec.only_call();
3306 let args = call.args_str();
3307 assert_eq!(args[0], "-c", "config flags lead the clone argv");
3308 let clone_at = args
3309 .iter()
3310 .position(|a| a == "clone")
3311 .expect("clone present");
3312 assert!(
3314 args[..clone_at]
3315 .iter()
3316 .all(|a| a == "-c" || a.starts_with("credential.helper")),
3317 "only credential -c flags precede `clone`: {args:?}"
3318 );
3319 let tail = &args[clone_at..];
3321 assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
3322 assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
3323 assert!(
3324 !args.iter().any(|a| a.contains("s3cr3t")),
3325 "secret not in argv"
3326 );
3327 let host = call
3330 .envs
3331 .iter()
3332 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
3333 .and_then(|(_, v)| v.as_ref())
3334 .and_then(|v| v.to_str());
3335 assert_eq!(
3336 host,
3337 Some("example.com"),
3338 "the clone URL's host scopes the credential helper"
3339 );
3340 assert!(
3343 args[..clone_at].iter().all(|a| !a.contains("example.com")),
3344 "host stays in env, not the credential config args: {:?}",
3345 &args[..clone_at]
3346 );
3347 }
3348
3349 #[tokio::test]
3352 async fn with_credentials_userpass_threads_username_through_env() {
3353 let rec = RecordingRunner::replying(Reply::ok(""));
3354 let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
3355 Credential::userpass("alice", "s3cr3t"),
3356 )));
3357 git.fetch(Path::new("/r")).await.unwrap();
3358 let call = rec.only_call();
3359 let user = call
3360 .envs
3361 .iter()
3362 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
3363 .and_then(|(_, v)| v.as_ref())
3364 .and_then(|v| v.to_str());
3365 assert_eq!(user, Some("alice"), "userpass username reaches the env");
3366 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
3367 assert!(call.args_str().contains(&"fetch".to_string()));
3368 }
3369
3370 #[tokio::test]
3373 async fn default_client_no_helper_on_fetch_and_clone() {
3374 let rec = RecordingRunner::replying(Reply::ok(""));
3375 Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
3376 assert_eq!(
3377 rec.only_call().args_str(),
3378 ["fetch", "--quiet"],
3379 "fetch unchanged without a provider"
3380 );
3381
3382 let rec = RecordingRunner::replying(Reply::ok(""));
3383 Git::with_runner(&rec)
3384 .clone_repo(
3385 "https://example.com/r.git",
3386 Path::new("/dest"),
3387 CloneSpec::default(),
3388 )
3389 .await
3390 .unwrap();
3391 assert_eq!(
3392 rec.only_call().args_str()[0],
3393 "clone",
3394 "clone leads with the subcommand (no `-c`) without a provider"
3395 );
3396 }
3397
3398 #[tokio::test]
3401 async fn with_token_convenience_authenticates_https_remote() {
3402 let rec = RecordingRunner::replying(Reply::ok(""));
3403 let git = Git::with_runner(&rec).with_token("ghp_conv");
3404 git.fetch(Path::new("/r")).await.unwrap();
3405 let call = rec.only_call();
3406 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
3407 let pw = call
3408 .envs
3409 .iter()
3410 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3411 .and_then(|(_, v)| v.as_ref())
3412 .and_then(|v| v.to_str());
3413 assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
3414 assert!(
3415 !call.args_str().iter().any(|a| a.contains("ghp_conv")),
3416 "secret not in argv"
3417 );
3418 }
3419
3420 #[tokio::test]
3421 async fn upstream_maps_unset_to_none() {
3422 let set = Git::with_runner(
3423 ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("origin/main\n")),
3424 );
3425 assert_eq!(
3426 set.upstream(Path::new(".")).await.unwrap().as_deref(),
3427 Some("origin/main")
3428 );
3429 let unset =
3432 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "")));
3433 assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
3434 let timed_out =
3437 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::timeout()));
3438 assert!(timed_out.upstream(Path::new(".")).await.is_err());
3439 }
3440
3441 #[tokio::test]
3445 async fn remote_head_branch_maps_exit_codes() {
3446 let set = Git::with_runner(ScriptedRunner::new().on(
3447 ["git", "symbolic-ref"],
3448 Reply::ok("refs/remotes/origin/release/v2\n"),
3449 ));
3450 assert_eq!(
3451 set.remote_head_branch(Path::new("."))
3452 .await
3453 .unwrap()
3454 .as_deref(),
3455 Some("release/v2"),
3456 "the full ref prefix is stripped, slashes preserved"
3457 );
3458 let unset =
3459 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3460 assert!(
3461 unset
3462 .remote_head_branch(Path::new("."))
3463 .await
3464 .unwrap()
3465 .is_none()
3466 );
3467 let err = Git::with_runner(ScriptedRunner::new().on(
3469 ["git", "symbolic-ref"],
3470 Reply::fail(128, "fatal: not a git repository"),
3471 ));
3472 assert!(err.remote_head_branch(Path::new(".")).await.is_err());
3473 let timed_out =
3475 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
3476 assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
3477 }
3478
3479 #[tokio::test]
3480 async fn set_upstream_builds_branch_flag() {
3481 let rec = RecordingRunner::replying(Reply::ok(""));
3482 let git = Git::with_runner(&rec);
3483 git.set_upstream(Path::new("/r"), "feat", "origin/feature")
3484 .await
3485 .unwrap();
3486 assert_eq!(
3487 rec.only_call().args_str(),
3488 ["branch", "--set-upstream-to=origin/feature", "feat"]
3489 );
3490 }
3491
3492 #[tokio::test]
3493 async fn remote_branches_parses_ls_remote() {
3494 let git = Git::with_runner(ScriptedRunner::new().on(
3495 ["git", "ls-remote"],
3496 Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
3497 ));
3498 let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
3499 assert_eq!(branches, ["main", "feat/x"]);
3500 }
3501
3502 #[tokio::test]
3503 async fn delete_branch_force_uses_capital_d() {
3504 let rec = RecordingRunner::replying(Reply::ok(""));
3505 let git = Git::with_runner(&rec);
3506 git.delete_branch(Path::new("/r"), "old", true)
3507 .await
3508 .unwrap();
3509 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
3510 }
3511
3512 #[tokio::test]
3515 async fn is_merged_strips_branch_markers() {
3516 let git = Git::with_runner(ScriptedRunner::new().on(
3517 ["git", "branch", "--merged"],
3518 Reply::ok(" main\n* feature\n+ wt-branch\n"),
3519 ));
3520 for name in ["main", "feature", "wt-branch"] {
3521 assert!(
3522 git.is_merged(Path::new("."), MergeCheck::branch(name).into_base("main"))
3523 .await
3524 .unwrap(),
3525 "{name} should be reported merged"
3526 );
3527 }
3528 assert!(
3529 !git.is_merged(
3530 Path::new("."),
3531 MergeCheck::branch("absent").into_base("main")
3532 )
3533 .await
3534 .unwrap()
3535 );
3536 }
3537
3538 #[tokio::test]
3542 async fn merge_check_names_branch_and_base_without_transposition() {
3543 use processkit::testing::RecordingRunner;
3544 let spec = MergeCheck::branch("feature").into_base("main");
3545 assert_eq!(spec.branch, "feature");
3546 assert_eq!(spec.base, "main");
3547
3548 let rec = RecordingRunner::replying(Reply::ok(" feature\n* main\n"));
3549 let merged = Git::with_runner(&rec)
3550 .is_merged(
3551 Path::new("/repo"),
3552 MergeCheck::branch("feature").into_base("main"),
3553 )
3554 .await
3555 .unwrap();
3556 assert!(merged, "feature is listed as merged into main");
3559 assert_eq!(
3560 rec.only_call().args_str(),
3561 ["branch", "--merged", "main", "--no-column", "--no-color"]
3562 );
3563 }
3564
3565 #[tokio::test]
3568 async fn fetch_disables_terminal_prompt() {
3569 let rec = RecordingRunner::replying(Reply::ok(""));
3570 let git = Git::with_runner(&rec);
3571 git.fetch(Path::new("/r")).await.unwrap();
3572 let call = rec.only_call();
3573 assert_eq!(call.args_str(), ["fetch", "--quiet"]);
3574 assert!(call.envs.iter().any(|(k, v)| {
3575 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3576 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3577 }));
3578 }
3579
3580 #[tokio::test]
3582 async fn fetch_retries_transient_failures() {
3583 let rec = RecordingRunner::replying(Reply::fail(
3584 128,
3585 "fatal: unable to access: Could not resolve host: example.com",
3586 ));
3587 let git = Git::with_runner(&rec);
3588 assert!(git.fetch(Path::new("/r")).await.is_err());
3589 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
3590 }
3591
3592 #[tokio::test]
3606 async fn with_retry_retries_lock_contention_on_a_mutation() {
3607 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3608 ["git", "commit"],
3609 [
3610 Reply::fail(
3611 128,
3612 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3613 ),
3614 Reply::ok(""),
3615 ],
3616 ));
3617 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3618 git.commit(Path::new("/r"), "msg")
3619 .await
3620 .expect("retried past the lock");
3621 assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
3622 }
3623
3624 #[tokio::test]
3626 async fn default_client_does_not_retry_lock_contention() {
3627 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3628 ["git", "commit"],
3629 [
3630 Reply::fail(
3631 128,
3632 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3633 ),
3634 Reply::ok(""),
3635 ],
3636 ));
3637 let git = Git::with_runner(&rec);
3638 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3639 assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
3640 }
3641
3642 #[tokio::test]
3645 async fn with_retry_does_not_retry_a_real_failure() {
3646 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3647 ["git", "commit"],
3648 [
3649 Reply::fail(1, "error: pathspec 'x' did not match"),
3650 Reply::ok(""),
3651 ],
3652 ));
3653 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3654 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3655 assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
3656 }
3657
3658 #[tokio::test]
3660 async fn fetch_does_not_retry_permanent_failures() {
3661 let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
3662 let git = Git::with_runner(&rec);
3663 assert!(git.fetch(Path::new("/r")).await.is_err());
3664 assert_eq!(rec.calls().len(), 1);
3665 }
3666
3667 #[tokio::test(start_paused = true)]
3674 async fn fetch_cancels_and_does_not_retry() {
3675 use processkit::CancellationToken;
3676 let token = CancellationToken::new();
3677 let rec =
3678 RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
3679 let git = Git::with_runner(&rec).default_cancel_on(token.clone());
3680 let call = git.fetch(Path::new("/r"));
3681 tokio::pin!(call);
3682 assert!(
3683 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
3684 .await
3685 .is_err(),
3686 "fetch must park until the token fires"
3687 );
3688 token.cancel();
3689 assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
3690 assert_eq!(
3691 rec.calls().len(),
3692 1,
3693 "cancellation is terminal — the fetch-retry must not replay it"
3694 );
3695 }
3696
3697 #[tokio::test]
3700 async fn flag_like_positionals_are_rejected_before_spawning() {
3701 let rec = RecordingRunner::replying(Reply::ok(""));
3702 let git = Git::with_runner(&rec);
3703 let dir = Path::new("/r");
3704
3705 assert!(git.checkout(dir, "-evil").await.is_err());
3706 assert!(git.create_branch(dir, "--force").await.is_err());
3707 assert!(git.delete_branch(dir, "-D", false).await.is_err());
3708 assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
3709 assert!(
3710 git.merge_commit(dir, MergeCommit::branch("-evil"))
3711 .await
3712 .is_err()
3713 );
3714 assert!(
3715 git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
3716 .await
3717 .is_err()
3718 );
3719 assert!(git.merge_squash(dir, "-evil").await.is_err());
3720 assert!(git.rebase(dir, "-i").await.is_err());
3721 assert!(git.cherry_pick(dir, "-n").await.is_err());
3722 assert!(git.revert(dir, "-evil").await.is_err());
3723 assert!(git.tag_create(dir, "-d", None).await.is_err());
3724 assert!(
3725 git.tag_create(dir, "ok", Some("-evil".into()))
3726 .await
3727 .is_err()
3728 );
3729 assert!(git.tag_delete(dir, "-evil").await.is_err());
3730 assert!(git.remote_add(dir, "-evil", "url").await.is_err());
3731 assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
3732 assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
3733 assert!(git.log(dir, "-evil", 5).await.is_err());
3734 assert!(git.rev_list_count(dir, "-evil").await.is_err());
3735 assert!(git.diff_stat(dir, "-evil").await.is_err());
3736 assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
3737 assert!(
3738 git.diff_text(dir, DiffSpec::Rev("-evil".into()))
3739 .await
3740 .is_err()
3741 );
3742 assert!(git.rev_parse(dir, "-evil").await.is_err());
3743 assert!(git.rev_parse_short(dir, "-evil").await.is_err());
3744 assert!(git.resolve_commit(dir, "-evil").await.is_err());
3745 assert!(git.reset_hard(dir, "-evil").await.is_err());
3746 assert!(git.checkout_detach(dir, "-evil").await.is_err());
3747 assert!(git.config_set(dir, "-evil", "v").await.is_err());
3748 assert!(
3749 git.push(dir, GitPush::branch("-evil")).await.is_err(),
3750 "refspec guard"
3751 );
3752 assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
3754 assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
3755 assert!(git.remote_url(dir, "-evil").await.is_err());
3756 assert!(git.remote_branches(dir, "-evil").await.is_err());
3757 assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
3758 assert!(
3760 git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
3761 .await
3762 .is_err()
3763 );
3764 assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
3765 assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
3766 assert!(
3767 git.is_merged(dir, MergeCheck::branch("-evil").into_base("main"))
3768 .await
3769 .is_err()
3770 );
3771 assert!(git.config_get(dir, "-evil").await.is_err());
3772 assert!(
3773 git.worktree_add(
3774 dir,
3775 WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
3776 )
3777 .await
3778 .is_err()
3779 );
3780 assert!(git.checkout(dir, "").await.is_err());
3782
3783 assert!(
3784 rec.calls().is_empty(),
3785 "nothing may spawn: {:?}",
3786 rec.calls()
3787 );
3788
3789 git.checkout(dir, "feature/x").await.expect("checkout");
3792 assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
3793 }
3794
3795 #[tokio::test]
3798 async fn harden_applies_env_profile_to_every_command() {
3799 let rec = RecordingRunner::replying(Reply::ok(""));
3800 let git = Git::with_runner(&rec).harden();
3801 git.status(Path::new("/r")).await.expect("status");
3802 git.fetch(Path::new("/r")).await.expect("fetch");
3803
3804 for call in rec.calls() {
3805 let has = |k: &str, v: &str| {
3806 call.envs.iter().any(|(key, val)| {
3807 key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
3808 })
3809 };
3810 let removed = |k: &str| {
3811 call.envs
3812 .iter()
3813 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3814 };
3815 assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
3816 assert!(has("GIT_CONFIG_COUNT", "3"));
3817 assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
3818 assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
3819 assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
3820 assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
3822 assert!(has("GIT_CONFIG_VALUE_2", ""));
3823 assert!(has("GIT_TERMINAL_PROMPT", "0"));
3824 assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
3825 assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
3826 assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
3828 assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
3829 assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
3830 assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
3831 assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
3833 assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
3834 assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
3835 assert!(
3836 removed("GIT_ICASE_PATHSPECS"),
3837 "GIT_ICASE_PATHSPECS scrubbed"
3838 );
3839 }
3840 }
3841
3842 #[tokio::test]
3848 async fn default_client_scrubs_repo_redirector_env() {
3849 let rec = RecordingRunner::replying(Reply::ok(""));
3850 let git = Git::with_runner(&rec); git.status(Path::new("/r")).await.expect("status");
3852 let call = rec.only_call();
3853 let removed = |k: &str| {
3854 call.envs
3855 .iter()
3856 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3857 };
3858 let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
3859 for var in [
3860 "GIT_DIR",
3861 "GIT_WORK_TREE",
3862 "GIT_INDEX_FILE",
3863 "GIT_COMMON_DIR",
3864 "GIT_OBJECT_DIRECTORY",
3865 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3866 "GIT_NAMESPACE",
3867 ] {
3868 assert!(removed(var), "{var} must be scrubbed on the default client");
3869 }
3870 assert!(
3872 !has_key("GIT_SSH_COMMAND"),
3873 "command-hook scrub is harden()-only"
3874 );
3875 assert!(
3876 !has_key("GIT_CONFIG_NOSYSTEM"),
3877 "config pins are harden()-only"
3878 );
3879 }
3880
3881 #[test]
3883 fn ref_name_and_rev_spec_validate() {
3884 for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
3885 assert!(RefName::new(ok).is_ok(), "{ok}");
3886 }
3887 for bad in [
3888 "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
3889 "a\\b", "end/", "x.lock",
3890 ] {
3891 assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
3892 }
3893 assert!(RevSpec::new("HEAD~2").is_ok());
3894 assert!(RevSpec::new("main..feature").is_ok());
3895 assert!(RevSpec::new("-evil").is_err());
3896 assert!(RevSpec::new("").is_err());
3897 }
3898
3899 #[tokio::test]
3902 async fn capabilities_parse_and_gate_versions() {
3903 let gh = Git::with_runner(ScriptedRunner::new().on(
3904 ["git", "--version"],
3905 Reply::ok("git version 2.54.0.windows.1\n"),
3906 ));
3907 let caps = gh.capabilities().await.expect("capabilities");
3908 assert_eq!(caps.version.to_string(), "2.54.0");
3909 assert!(caps.is_supported());
3910 caps.ensure_supported().expect("supported");
3911
3912 let old = Git::with_runner(
3915 ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
3916 );
3917 let caps = old.capabilities().await.expect("capabilities");
3918 assert_eq!(
3919 caps.version,
3920 GitVersion {
3921 major: 1,
3922 minor: 9,
3923 patch: 0
3924 }
3925 );
3926 let err = caps.ensure_supported().expect_err("unsupported");
3927 let Error::Spawn { source, .. } = &err else {
3929 panic!("expected Spawn, got {err:?}");
3930 };
3931 let message = source.to_string();
3932 assert!(message.contains(">= 2"), "names the floor: {message}");
3933 assert!(
3934 message.contains("1.9.0"),
3935 "names the found version: {message}"
3936 );
3937
3938 let garbage = Git::with_runner(
3940 ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
3941 );
3942 assert!(matches!(
3943 garbage.capabilities().await.unwrap_err(),
3944 Error::Parse { .. }
3945 ));
3946 }
3947
3948 #[tokio::test]
3950 async fn clone_repo_builds_flags_and_runs_dirless() {
3951 let rec = RecordingRunner::replying(Reply::ok(""));
3952 let git = Git::with_runner(&rec);
3953 git.clone_repo(
3954 "https://example.com/r.git",
3955 Path::new("/dest"),
3956 CloneSpec::new().branch("main").depth(1).bare(),
3957 )
3958 .await
3959 .expect("clone");
3960 let call = rec.only_call();
3961 assert_eq!(
3962 call.args_str(),
3963 [
3964 "clone",
3965 "--branch",
3966 "main",
3967 "--depth",
3968 "1",
3969 "--bare",
3970 "https://example.com/r.git",
3971 "/dest"
3972 ]
3973 );
3974 assert_eq!(call.cwd, None, "clone runs without a working directory");
3975
3976 let bare = RecordingRunner::replying(Reply::ok(""));
3977 let git = Git::with_runner(&bare);
3978 git.clone_repo("u", Path::new("/d"), CloneSpec::new())
3979 .await
3980 .expect("clone");
3981 assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
3982 }
3983
3984 #[tokio::test]
3990 async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
3991 use vcs_testkit::TempDir;
3992 let tmp = TempDir::new("r7-clone");
3993 let git = Git::with_runner(ScriptedRunner::new().on(
3994 ["git", "clone"],
3995 Reply::fail(
3996 128,
3997 "fatal: could not read Username for 'https://x': prompts disabled",
3998 ),
3999 ));
4000
4001 let occupied = tmp.path().join("occupied");
4003 std::fs::create_dir(&occupied).unwrap();
4004 std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
4005 assert!(
4006 git.clone_repo("https://x/r", &occupied, CloneSpec::new())
4007 .await
4008 .is_err()
4009 );
4010 assert!(
4011 occupied.join("keep.txt").exists(),
4012 "a non-empty caller dir must survive a failed clone"
4013 );
4014
4015 let empty = tmp.path().join("empty");
4017 std::fs::create_dir(&empty).unwrap();
4018 assert!(
4019 git.clone_repo("https://x/r", &empty, CloneSpec::new())
4020 .await
4021 .is_err()
4022 );
4023 assert!(
4024 !empty.exists(),
4025 "an empty dest is cleaned so a retry isn't blocked"
4026 );
4027
4028 let file_dest = tmp.path().join("a-file");
4032 std::fs::write(&file_dest, b"caller file").unwrap();
4033 assert!(
4034 git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
4035 .await
4036 .is_err()
4037 );
4038 assert!(
4039 file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
4040 "a caller's file at dest must survive a failed clone"
4041 );
4042
4043 #[cfg(unix)]
4048 {
4049 let target = tmp.path().join("link-target"); std::fs::create_dir(&target).unwrap();
4051 let sentinel = tmp.path().join("sibling.txt");
4052 std::fs::write(&sentinel, b"untouched").unwrap();
4053 let link = tmp.path().join("a-symlink");
4054 std::os::unix::fs::symlink(&target, &link).unwrap();
4055 assert!(
4056 git.clone_repo("https://x/r", &link, CloneSpec::new())
4057 .await
4058 .is_err()
4059 );
4060 assert!(
4061 target.exists() && sentinel.exists(),
4062 "a failed clone must unlink at most the symlink, never delete through it"
4063 );
4064 }
4065 }
4066
4067 #[tokio::test]
4068 async fn tag_methods_build_args() {
4069 let rec = RecordingRunner::replying(Reply::ok(""));
4070 let git = Git::with_runner(&rec);
4071 git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
4072 git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
4073 .await
4074 .unwrap();
4075 git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
4076 .await
4077 .unwrap();
4078 git.tag_delete(Path::new("/r"), "v1").await.unwrap();
4079 let calls = rec.calls();
4080 assert_eq!(calls[0].args_str(), ["tag", "v1"]);
4081 assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
4082 assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
4083 assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
4084 }
4085
4086 #[tokio::test]
4087 async fn tag_list_splits_lines() {
4088 let git = Git::with_runner(
4089 ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
4090 );
4091 assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
4092 }
4093
4094 #[tokio::test]
4100 async fn list_commands_disable_column_and_color() {
4101 let rec = RecordingRunner::replying(Reply::ok(""));
4102 let git = Git::with_runner(&rec);
4103 git.branches(Path::new(".")).await.unwrap();
4104 git.is_merged(Path::new("."), MergeCheck::branch("b").into_base("main"))
4105 .await
4106 .unwrap();
4107 git.tag_list(Path::new(".")).await.unwrap();
4108 let calls = rec.calls();
4109 assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
4110 assert_eq!(
4111 calls[1].args_str(),
4112 ["branch", "--merged", "main", "--no-column", "--no-color"]
4113 );
4114 assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
4115 }
4116
4117 #[tokio::test]
4120 async fn classified_commands_force_c_locale() {
4121 let rec = RecordingRunner::replying(Reply::ok(""));
4122 let git = Git::with_runner(&rec);
4123 git.commit(Path::new("."), "msg").await.unwrap();
4124 git.merge_commit(Path::new("."), MergeCommit::branch("b"))
4125 .await
4126 .unwrap();
4127 git.merge_squash(Path::new("."), "b").await.unwrap();
4128 git.merge_no_commit(Path::new("."), MergeNoCommit::branch("b"))
4129 .await
4130 .unwrap();
4131 git.cherry_pick(Path::new("."), "abc").await.unwrap();
4132 git.stash_pop(Path::new(".")).await.unwrap();
4133 git.fetch(Path::new(".")).await.unwrap();
4134 for call in rec.calls() {
4135 assert!(
4136 call.envs.iter().any(|(k, v)| {
4137 k.to_str() == Some("LC_ALL")
4138 && v.as_deref().and_then(|o| o.to_str()) == Some("C")
4139 }),
4140 "{:?} should force LC_ALL=C",
4141 call.args_str()
4142 );
4143 }
4144 }
4145
4146 #[cfg(windows)]
4149 #[tokio::test]
4150 async fn show_file_normalises_path_separators() {
4151 let rec = RecordingRunner::replying(Reply::ok("content\n"));
4152 let git = Git::with_runner(&rec);
4153 let out = git
4154 .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4155 .await
4156 .expect("show_file");
4157 assert_eq!(out, "content\n");
4159 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
4160 }
4161
4162 #[tokio::test]
4165 async fn content_verbs_preserve_exact_trailing_bytes() {
4166 for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces \n"] {
4167 let rec = RecordingRunner::replying(Reply::ok(raw));
4168 let git = Git::with_runner(&rec);
4169 let out = git
4170 .show_file(Path::new("/r"), "HEAD", "f.txt")
4171 .await
4172 .expect("show_file");
4173 assert_eq!(out, raw, "show_file returns bytes verbatim");
4174 }
4175 let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
4178 let rec = RecordingRunner::replying(Reply::ok(diff));
4179 let git = Git::with_runner(&rec);
4180 assert_eq!(
4181 git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
4182 .await
4183 .expect("diff_text"),
4184 diff
4185 );
4186 }
4187
4188 #[cfg(not(windows))]
4191 #[tokio::test]
4192 async fn show_file_keeps_backslashes_on_unix() {
4193 let rec = RecordingRunner::replying(Reply::ok("content\n"));
4194 let git = Git::with_runner(&rec);
4195 git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4196 .await
4197 .expect("show_file");
4198 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
4199 }
4200
4201 #[tokio::test]
4203 async fn config_get_maps_exit_codes() {
4204 let set = Git::with_runner(
4205 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
4206 );
4207 assert_eq!(
4208 set.config_get(Path::new("."), "user.name").await.unwrap(),
4209 Some("Alice".to_string())
4210 );
4211 let spaced = Git::with_runner(
4214 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix: \r\n")),
4215 );
4216 assert_eq!(
4217 spaced.config_get(Path::new("."), "x.y").await.unwrap(),
4218 Some("prefix: ".to_string())
4219 );
4220 let unset = Git::with_runner(
4221 ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
4222 );
4223 assert_eq!(
4224 unset.config_get(Path::new("."), "user.name").await.unwrap(),
4225 None
4226 );
4227 let multi = Git::with_runner(ScriptedRunner::new().on(
4229 ["git", "config", "--get"],
4230 Reply::fail(2, "multiple values"),
4231 ));
4232 assert!(
4233 multi
4234 .config_get(Path::new("."), "remote.all")
4235 .await
4236 .is_err()
4237 );
4238 }
4239
4240 #[tokio::test]
4241 async fn blame_builds_rev_before_pathspec_separator() {
4242 let rec = RecordingRunner::replying(Reply::ok(""));
4243 let git = Git::with_runner(&rec);
4244 git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
4245 .await
4246 .unwrap();
4247 git.blame(Path::new("/r"), "src/lib.rs", None)
4248 .await
4249 .unwrap();
4250 let calls = rec.calls();
4251 assert_eq!(
4252 calls[0].args_str(),
4253 ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
4254 );
4255 assert_eq!(
4256 calls[1].args_str(),
4257 ["blame", "--line-porcelain", "--", "src/lib.rs"]
4258 );
4259 }
4260
4261 #[tokio::test]
4263 async fn sequencer_methods_suppress_editors() {
4264 let rec = RecordingRunner::replying(Reply::ok(""));
4265 let git = Git::with_runner(&rec);
4266 git.revert(Path::new("/r"), "abc").await.unwrap();
4267 git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
4268 git.rebase_skip(Path::new("/r")).await.unwrap();
4269 let calls = rec.calls();
4270 assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
4271 assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
4272 assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
4273 for call in &calls {
4274 assert!(
4275 call.envs
4276 .iter()
4277 .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
4278 "editor suppressed on {:?}",
4279 call.args_str()
4280 );
4281 }
4282 }
4283
4284 #[tokio::test]
4293 async fn hardened_sequencer_keeps_its_no_op_editor() {
4294 let rec = RecordingRunner::replying(Reply::ok(""));
4295 let git = Git::with_runner(&rec).harden();
4296 git.revert(Path::new("/r"), "abc").await.unwrap();
4297 let call = rec.only_call();
4298 let effective = |var: &str| {
4300 call.envs
4301 .iter()
4302 .rfind(|(k, _)| k.to_str() == Some(var))
4303 .and_then(|(_, v)| v.as_deref())
4304 .and_then(|v| v.to_str())
4305 };
4306 assert_eq!(
4309 effective("GIT_EDITOR"),
4310 Some("true"),
4311 "the per-command no-op editor must survive harden()'s scrub"
4312 );
4313 assert_eq!(
4314 effective("GIT_SEQUENCE_EDITOR"),
4315 Some("true"),
4316 "the per-command no-op sequence editor must survive harden()'s scrub"
4317 );
4318 }
4319
4320 #[tokio::test]
4321 async fn remote_add_and_set_url_build_args() {
4322 let rec = RecordingRunner::replying(Reply::ok(""));
4323 let git = Git::with_runner(&rec);
4324 git.remote_add(Path::new("/r"), "up", "https://x/y.git")
4325 .await
4326 .unwrap();
4327 git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
4328 .await
4329 .unwrap();
4330 let calls = rec.calls();
4331 assert_eq!(
4332 calls[0].args_str(),
4333 ["remote", "add", "up", "https://x/y.git"]
4334 );
4335 assert_eq!(
4336 calls[1].args_str(),
4337 ["remote", "set-url", "up", "https://x/z.git"]
4338 );
4339 }
4340
4341 #[tokio::test]
4344 async fn switch_with_stash_round_trips_dirty_tree() {
4345 let rec = RecordingRunner::new(
4346 ScriptedRunner::new()
4347 .on(["git", "status"], Reply::ok(" M a.rs\0"))
4348 .on_sequence(
4350 ["git", "stash", "list"],
4351 [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4352 )
4353 .on(["git", "stash", "push"], Reply::ok(""))
4354 .on(["git", "checkout"], Reply::ok(""))
4355 .on(["git", "stash", "pop"], Reply::ok("")),
4356 );
4357 let git = Git::with_runner(&rec);
4358 git.switch_with_stash(Path::new("/r"), "feature")
4359 .await
4360 .expect("switch");
4361 let calls = rec.calls();
4362 assert_eq!(calls.len(), 6);
4363 assert_eq!(
4364 calls[2].args_str(),
4365 ["stash", "push", "--include-untracked"]
4366 );
4367 assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
4368 assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
4370 }
4371
4372 #[tokio::test]
4376 async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
4377 let rec = RecordingRunner::new(
4378 ScriptedRunner::new()
4379 .on(["git", "status"], Reply::ok(" M sub\0"))
4380 .on(
4382 ["git", "stash", "list"],
4383 Reply::ok("stash@{0}: someone else's WIP\n"),
4384 )
4385 .on(
4386 ["git", "stash", "push"],
4387 Reply::ok("No local changes to save\n"),
4388 )
4389 .on(["git", "checkout"], Reply::ok("")),
4390 );
4391 let git = Git::with_runner(&rec);
4392 git.switch_with_stash(Path::new("/r"), "feature")
4393 .await
4394 .expect("switch");
4395 assert!(
4396 rec.calls()
4397 .iter()
4398 .all(|c| c.args_str() != ["stash", "pop", "--index"]
4399 && c.args_str() != ["stash", "pop"]),
4400 "must not pop an unrelated stash when the push saved nothing"
4401 );
4402 }
4403
4404 #[tokio::test]
4407 async fn switch_with_stash_skips_stash_on_clean_tree() {
4408 let rec = RecordingRunner::new(
4409 ScriptedRunner::new()
4410 .on(["git", "status"], Reply::ok(""))
4411 .on(["git", "checkout"], Reply::ok("")),
4412 );
4413 let git = Git::with_runner(&rec);
4414 git.switch_with_stash(Path::new("/r"), "feature")
4415 .await
4416 .expect("switch");
4417 let calls = rec.calls();
4418 assert_eq!(calls.len(), 2);
4419 assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
4420 }
4421
4422 #[tokio::test]
4425 async fn switch_with_stash_restores_on_checkout_failure() {
4426 let rec = RecordingRunner::new(
4427 ScriptedRunner::new()
4428 .on(["git", "status"], Reply::ok(" M a.rs\0"))
4429 .on_sequence(
4430 ["git", "stash", "list"],
4431 [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4432 )
4433 .on(["git", "stash", "push"], Reply::ok(""))
4434 .on(
4435 ["git", "checkout"],
4436 Reply::fail(1, "error: pathspec 'nope'"),
4437 )
4438 .on(["git", "stash", "pop"], Reply::ok("")),
4439 );
4440 let git = Git::with_runner(&rec);
4441 let err = git
4442 .switch_with_stash(Path::new("/r"), "nope")
4443 .await
4444 .expect_err("checkout error must surface");
4445 assert!(matches!(err, Error::Exit { .. }));
4446 let calls = rec.calls();
4447 assert_eq!(
4448 calls.last().unwrap().args_str(),
4449 ["stash", "pop", "--index"],
4450 "restoring pop ran with --index"
4451 );
4452 }
4453
4454 #[tokio::test]
4457 async fn fetch_from_builds_args_and_retries() {
4458 let rec = RecordingRunner::replying(Reply::ok(""));
4459 let git = Git::with_runner(&rec);
4460 git.fetch_from(Path::new("/r"), "upstream")
4461 .await
4462 .expect("fetch_from");
4463 let call = rec.only_call();
4464 assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
4465 assert!(call.envs.iter().any(|(k, v)| {
4466 k.to_str() == Some("GIT_TERMINAL_PROMPT")
4467 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4468 }));
4469
4470 let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
4471 let git = Git::with_runner(&failing);
4472 assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
4473 assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
4474 }
4475
4476 #[cfg(feature = "mock")]
4479 #[tokio::test]
4480 async fn consumer_mocks_the_interface() {
4481 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
4482 git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
4483 }
4484 let mut mock = MockGitApi::new();
4485 mock.expect_current_branch()
4486 .returning(|_| Ok(Some("main".to_string())));
4487 assert!(on_branch(&mock, "main").await);
4488 }
4489}
4490
4491#[doc = include_str!("../docs/git.md")]
4493#[allow(rustdoc::broken_intra_doc_links)]
4494pub mod guide {
4495 #[doc = include_str!("../docs/security.md")]
4496 #[allow(rustdoc::broken_intra_doc_links)]
4497 pub mod security {}
4498 #[doc = include_str!("../docs/conflicts.md")]
4499 #[allow(rustdoc::broken_intra_doc_links)]
4500 pub mod conflicts {}
4501}