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)]
328#[non_exhaustive]
329pub struct MergeCommit {
330 pub branch: String,
332 pub no_ff: bool,
335 pub message: Option<String>,
338}
339
340impl MergeCommit {
341 pub fn branch(name: impl Into<String>) -> Self {
344 Self {
345 branch: name.into(),
346 no_ff: false,
347 message: None,
348 }
349 }
350
351 pub fn no_ff(mut self) -> Self {
354 self.no_ff = true;
355 self
356 }
357
358 pub fn message(mut self, m: impl Into<String>) -> Self {
360 self.message = Some(m.into());
361 self
362 }
363}
364
365#[derive(Debug, Clone)]
370#[non_exhaustive]
371pub struct MergeNoCommit {
372 pub branch: String,
374 pub squash: bool,
377 pub no_ff: bool,
380}
381
382impl MergeNoCommit {
383 pub fn branch(name: impl Into<String>) -> Self {
385 Self {
386 branch: name.into(),
387 squash: false,
388 no_ff: false,
389 }
390 }
391
392 pub fn squash(mut self) -> Self {
394 self.squash = true;
395 self
396 }
397
398 pub fn no_ff(mut self) -> Self {
401 self.no_ff = true;
402 self
403 }
404}
405
406#[derive(Debug, Clone)]
411#[non_exhaustive]
412pub struct AnnotatedTag {
413 pub name: String,
415 pub message: String,
417 pub rev: Option<String>,
419}
420
421impl AnnotatedTag {
422 pub fn new(name: impl Into<String>, message: impl Into<String>) -> Self {
425 Self {
426 name: name.into(),
427 message: message.into(),
428 rev: None,
429 }
430 }
431
432 pub fn rev(mut self, r: impl Into<String>) -> Self {
434 self.rev = Some(r.into());
435 self
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Hash)]
449pub struct RefName(String);
450
451impl RefName {
452 pub fn new(name: impl Into<String>) -> Result<Self> {
454 let name = name.into();
455 let bad = name.is_empty()
456 || name.starts_with('-')
457 || name.starts_with('.')
458 || name.ends_with('/')
459 || name.ends_with(".lock")
460 || name.contains("..")
461 || name
462 .chars()
463 .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
464 if bad {
465 return Err(Error::Spawn {
466 program: BINARY.to_string(),
467 source: std::io::Error::new(
468 std::io::ErrorKind::InvalidInput,
469 format!("invalid git reference name: {name:?}"),
470 ),
471 });
472 }
473 Ok(RefName(name))
474 }
475
476 pub fn as_str(&self) -> &str {
478 &self.0
479 }
480}
481
482impl std::fmt::Display for RefName {
483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484 f.write_str(&self.0)
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Hash)]
494pub struct RevSpec(String);
495
496impl RevSpec {
497 pub fn new(rev: impl Into<String>) -> Result<Self> {
499 let rev = rev.into();
500 reject_flag_like("revision", &rev)?;
501 Ok(RevSpec(rev))
502 }
503
504 pub fn as_str(&self) -> &str {
506 &self.0
507 }
508}
509
510impl std::fmt::Display for RevSpec {
511 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512 f.write_str(&self.0)
513 }
514}
515
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520#[non_exhaustive]
521pub struct GitCapabilities {
522 pub version: GitVersion,
524}
525
526const MIN_SUPPORTED_MAJOR: u64 = 2;
532
533impl GitCapabilities {
534 pub fn is_supported(&self) -> bool {
536 self.version.major >= MIN_SUPPORTED_MAJOR
537 }
538
539 pub fn ensure_supported(&self) -> Result<()> {
542 if self.is_supported() {
543 return Ok(());
544 }
545 Err(Error::Spawn {
546 program: BINARY.to_string(),
547 source: std::io::Error::new(
548 std::io::ErrorKind::Unsupported,
549 format!(
550 "vcs-git requires git >= {MIN_SUPPORTED_MAJOR} (validated on 2.54), \
551 found {}",
552 self.version
553 ),
554 ),
555 })
556 }
557}
558
559#[cfg_attr(feature = "mock", mockall::automock)]
571#[async_trait::async_trait]
572pub trait GitApi: Send + Sync {
573 async fn run(&self, args: &[String]) -> Result<String>;
580 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
583 async fn version(&self) -> Result<String>;
585 async fn capabilities(&self) -> Result<GitCapabilities>;
589 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
591 async fn status_text(&self, dir: &Path) -> Result<String>;
594 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
598 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus>;
603 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>>;
606 async fn current_branch(&self, dir: &Path) -> Result<Option<String>>;
614 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
616 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>>;
623 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
627 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String>;
630 async fn init(&self, dir: &Path) -> Result<()>;
632 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
634 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
636 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
638 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
640 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()>;
642 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()>;
645 async fn last_commit_message(&self, dir: &Path) -> Result<String>;
648 async fn is_unborn(&self, dir: &Path) -> Result<bool>;
651 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
655
656 async fn common_dir(&self, dir: &Path) -> Result<PathBuf>;
661 async fn git_dir(&self, dir: &Path) -> Result<PathBuf>;
663 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String>;
666 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>>;
669 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
671 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool>;
676 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String>;
678 async fn upstream(&self, dir: &Path) -> Result<Option<String>>;
681 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>>;
684
685 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool>;
689 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()>;
692 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()>;
694 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
696 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize>;
698 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool>;
700 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat>;
703 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
708 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
710
711 async fn staged_is_empty(&self, dir: &Path) -> Result<bool>;
715 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool>;
718 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool>;
720 async fn is_am_in_progress(&self, dir: &Path) -> Result<bool>;
724
725 async fn fetch(&self, dir: &Path) -> Result<()>;
730 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
734 async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
738 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()>;
740 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()>;
742 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()>;
746 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()>;
755 async fn merge_abort(&self, dir: &Path) -> Result<()>;
757 async fn merge_continue(&self, dir: &Path) -> Result<()>;
759 async fn reset_merge(&self, dir: &Path) -> Result<()>;
765 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()>;
767 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
770 async fn rebase_abort(&self, dir: &Path) -> Result<()>;
772 async fn am_abort(&self, dir: &Path) -> Result<()>;
774 async fn rebase_continue(&self, dir: &Path) -> Result<()>;
777 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()>;
780 async fn stash_pop(&self, dir: &Path) -> Result<()>;
782
783 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>>;
787 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()>;
789 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()>;
791 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()>;
793 async fn worktree_prune(&self, dir: &Path) -> Result<()>;
795
796 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
801 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()>;
803 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()>;
806 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>>;
808 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()>;
810 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String>;
816 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>>;
820 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()>;
827 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
829 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()>;
831 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
834
835 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()>;
840 async fn revert(&self, dir: &Path, rev: &str) -> Result<()>;
842 async fn rebase_skip(&self, dir: &Path) -> Result<()>;
846}
847
848vcs_cli_support::managed_client! {
849 pub struct Git => BINARY, scrub_env = [
862 "GIT_DIR",
863 "GIT_WORK_TREE",
864 "GIT_INDEX_FILE",
865 "GIT_COMMON_DIR",
866 "GIT_OBJECT_DIRECTORY",
867 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
868 "GIT_NAMESPACE",
869 ]
870}
871
872impl<R: ProcessRunner> Git<R> {
873 pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
880 self.core = self.core.with_retry(policy);
881 self
882 }
883
884 #[must_use]
892 pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
893 self.core = self.core.with_credentials(provider);
894 self
895 }
896
897 #[must_use]
904 pub fn with_token(self, token: impl Into<Secret>) -> Self {
905 self.with_credentials(Arc::new(StaticCredential::token(token)))
906 }
907
908 #[must_use]
912 pub fn with_env_token(self, var: impl Into<String>) -> Self {
913 self.with_credentials(Arc::new(EnvToken::new(var)))
914 }
915
916 async fn remote_credentials(
926 &self,
927 expect_host: Option<&str>,
928 ) -> Result<(Vec<String>, Vec<(String, Secret)>)> {
929 match self
930 .core
931 .resolve_credential(CredentialService::Git, None)
932 .await?
933 {
934 Some(cred) => {
935 let helper = git_credential_helper(&cred, expect_host);
936 Ok((helper.config_args, helper.env))
937 }
938 None => Ok((Vec::new(), Vec::new())),
939 }
940 }
941}
942
943fn apply_secret_env(cmd: Command, envs: &[(String, Secret)]) -> Command {
946 envs.iter()
947 .fold(cmd, |cmd, (name, value)| cmd.env(name, value.expose()))
948}
949
950#[async_trait::async_trait]
951impl<R: ProcessRunner> GitApi for Git<R> {
952 async fn run(&self, args: &[String]) -> Result<String> {
953 self.core.run(args).await
954 }
955
956 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
957 self.core.output_string(args).await
958 }
959
960 async fn version(&self) -> Result<String> {
961 self.core.run(["--version"]).await
962 }
963
964 async fn capabilities(&self) -> Result<GitCapabilities> {
965 let raw = self.version().await?;
966 let version = parse::parse_git_version(&raw).ok_or_else(|| Error::Parse {
967 program: BINARY.to_string(),
968 message: format!("unrecognisable `git --version` output: {raw:?}"),
969 })?;
970 Ok(GitCapabilities { version })
971 }
972
973 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
974 self.core
975 .parse(
976 self.core
977 .command_in(dir, ["status", "--porcelain=v1", "-z"]),
978 parse::parse_porcelain,
979 )
980 .await
981 }
982
983 async fn status_text(&self, dir: &Path) -> Result<String> {
984 self.core
985 .run(self.core.command_in(dir, ["status", "--porcelain=v1"]))
986 .await
987 }
988
989 async fn branch_status(&self, dir: &Path) -> Result<BranchStatus> {
990 self.core
996 .parse(
997 self.core
998 .command_in(dir, ["status", "--porcelain=v2", "--branch", "-z"])
999 .env("GIT_OPTIONAL_LOCKS", "0"),
1000 parse::parse_porcelain_v2,
1001 )
1002 .await
1003 }
1004
1005 async fn status_tracked(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
1006 self.core
1007 .parse(
1008 self.core.command_in(
1009 dir,
1010 ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
1011 ),
1012 parse::parse_porcelain,
1013 )
1014 .await
1015 }
1016
1017 async fn conflicted_files(&self, dir: &Path) -> Result<Vec<String>> {
1018 self.core
1020 .parse(
1021 self.core
1022 .command_in(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]),
1023 parse::parse_nul_paths,
1024 )
1025 .await
1026 }
1027
1028 async fn current_branch(&self, dir: &Path) -> Result<Option<String>> {
1029 let res = self
1038 .core
1039 .output_string(
1040 self.core
1041 .command_in(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
1042 )
1043 .await?;
1044 match res.code() {
1045 Some(0) => Ok(Some(res.stdout().trim().to_string())),
1046 Some(1) => Ok(None), _ => {
1048 let _ = res.ensure_success()?;
1049 Ok(None) }
1051 }
1052 }
1053
1054 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
1055 self.core
1060 .parse(
1061 self.core
1062 .command_in(dir, ["branch", "--no-column", "--no-color"]),
1063 parse::parse_branches,
1064 )
1065 .await
1066 }
1067
1068 async fn log(&self, dir: &Path, revspec: &str, max: usize) -> Result<Vec<Commit>> {
1069 reject_flag_like("revspec", revspec)?;
1070 let n = format!("-n{max}");
1071 self.core
1072 .parse(
1073 self.core.command_in(
1074 dir,
1075 [
1076 "log",
1077 revspec,
1078 n.as_str(),
1079 "-z",
1080 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
1081 ],
1082 ),
1083 parse::parse_log,
1084 )
1085 .await
1086 }
1087
1088 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
1089 reject_flag_like("revision", rev)?;
1090 self.core
1096 .run(self.core.command_in(dir, ["rev-parse", "--verify", rev]))
1097 .await
1098 }
1099
1100 async fn rev_parse_short(&self, dir: &Path, rev: &str) -> Result<String> {
1101 reject_flag_like("revision", rev)?;
1102 self.core
1103 .run(self.core.command_in(dir, ["rev-parse", "--short", rev]))
1104 .await
1105 }
1106
1107 async fn init(&self, dir: &Path) -> Result<()> {
1108 self.core
1109 .run_unit(self.core.command_in(dir, ["init"]))
1110 .await
1111 }
1112
1113 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
1114 let mut command = self.core.command_in(dir, ["add", "--"]);
1116 for path in paths {
1117 command = command.arg(path);
1118 }
1119 self.core.run_unit(command).await
1120 }
1121
1122 async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
1123 self.core
1125 .run_unit(c_locale(
1126 self.core.command_in(dir, ["commit", "-m", message]),
1127 ))
1128 .await
1129 }
1130
1131 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
1132 reject_flag_like("branch name", name)?;
1133 self.core
1134 .run_unit(self.core.command_in(dir, ["branch", name]))
1135 .await
1136 }
1137
1138 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
1139 reject_flag_like("reference", reference)?;
1140 self.core
1147 .run_unit(self.core.command_in(dir, ["checkout", reference, "--"]))
1148 .await
1149 }
1150
1151 async fn checkout_detach(&self, dir: &Path, commit: &str) -> Result<()> {
1152 reject_flag_like("commit", commit)?;
1153 self.core
1154 .run_unit(self.core.command_in(dir, ["checkout", "--detach", commit]))
1155 .await
1156 }
1157
1158 async fn commit_paths(&self, dir: &Path, spec: CommitPaths) -> Result<()> {
1159 let mut command = c_locale(self.core.command_in(dir, ["commit"]));
1163 if spec.amend {
1164 command = command.arg("--amend");
1165 }
1166 command = command.arg("-m").arg(spec.message).arg("--only").arg("--");
1167 for path in &spec.paths {
1168 command = command.arg(path);
1169 }
1170 self.core.run_unit(command).await
1171 }
1172
1173 async fn last_commit_message(&self, dir: &Path) -> Result<String> {
1174 self.core
1175 .run(self.core.command_in(dir, ["log", "-1", "--format=%B"]))
1176 .await
1177 }
1178
1179 async fn is_unborn(&self, dir: &Path) -> Result<bool> {
1180 Ok(!self
1184 .core
1185 .probe(
1186 self.core
1187 .command_in(dir, ["rev-parse", "--verify", "-q", "HEAD"]),
1188 )
1189 .await?)
1190 }
1191
1192 async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
1193 self.core
1196 .probe(self.core.command_in(dir, ["diff", "--quiet"]))
1197 .await
1198 }
1199
1200 async fn common_dir(&self, dir: &Path) -> Result<PathBuf> {
1201 Ok(PathBuf::from(
1202 self.core
1203 .run(self.core.command_in(dir, ["rev-parse", "--git-common-dir"]))
1204 .await?,
1205 ))
1206 }
1207
1208 async fn git_dir(&self, dir: &Path) -> Result<PathBuf> {
1209 Ok(PathBuf::from(
1210 self.core
1211 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
1212 .await?,
1213 ))
1214 }
1215
1216 async fn resolve_commit(&self, dir: &Path, rev: &str) -> Result<String> {
1217 reject_flag_like("revision", rev)?;
1218 let spec = format!("{rev}^{{commit}}");
1220 self.core
1221 .run(
1222 self.core
1223 .command_in(dir, ["rev-parse", "--verify", spec.as_str()]),
1224 )
1225 .await
1226 }
1227
1228 async fn remote_head_branch(&self, dir: &Path) -> Result<Option<String>> {
1229 let res = self
1235 .core
1236 .output_string(
1237 self.core
1238 .command_in(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]),
1239 )
1240 .await?;
1241 match res.code() {
1242 Some(0) => {
1243 let out = res.stdout().trim();
1246 Ok(Some(
1247 out.strip_prefix("refs/remotes/origin/")
1248 .unwrap_or(out)
1249 .to_string(),
1250 ))
1251 }
1252 Some(1) => Ok(None), _ => {
1254 let _ = res.ensure_success()?;
1255 Ok(None) }
1257 }
1258 }
1259
1260 async fn branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1261 let refname = format!("refs/heads/{name}");
1262 self.core
1264 .probe(
1265 self.core
1266 .command_in(dir, ["show-ref", "--verify", "--quiet", refname.as_str()]),
1267 )
1268 .await
1269 }
1270
1271 async fn remote_branch_exists(&self, dir: &Path, name: &str) -> Result<bool> {
1272 let refname = format!("refs/heads/{name}");
1282 let (pre, envs) = self.remote_credentials(None).await?;
1283 let mut args: Vec<String> = pre;
1284 args.extend(["ls-remote", "origin", refname.as_str()].map(String::from));
1285 let cmd = apply_secret_env(
1286 self.core
1287 .command_in(dir, &args)
1288 .env("GIT_TERMINAL_PROMPT", "0")
1289 .timeout(Duration::from_secs(10)),
1290 &envs,
1291 );
1292 let res = self.core.output_string(cmd).await?;
1293 Ok(res.code() == Some(0) && !res.stdout().trim().is_empty())
1294 }
1295
1296 async fn remote_url(&self, dir: &Path, remote: &str) -> Result<String> {
1297 reject_flag_like("remote name", remote)?;
1298 self.core
1299 .run(self.core.command_in(dir, ["remote", "get-url", remote]))
1300 .await
1301 }
1302
1303 async fn upstream(&self, dir: &Path) -> Result<Option<String>> {
1304 let res = self
1312 .core
1313 .output_string(self.core.command_in(
1314 dir,
1315 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
1316 ))
1317 .await?;
1318 match res.code() {
1319 Some(0) => {
1320 let name = res.stdout().trim();
1321 Ok((!name.is_empty()).then(|| name.to_string()))
1322 }
1323 Some(_) => Ok(None), None => {
1325 let _ = res.ensure_success()?; Ok(None) }
1328 }
1329 }
1330
1331 async fn remote_branches(&self, dir: &Path, remote: &str) -> Result<Vec<String>> {
1332 reject_flag_like("remote name", remote)?;
1333 let (pre, envs) = self.remote_credentials(None).await?;
1337 let mut args: Vec<String> = pre;
1338 args.extend(["ls-remote", "--heads", remote].map(String::from));
1339 let cmd = apply_secret_env(
1340 self.core
1341 .command_in(dir, &args)
1342 .env("GIT_TERMINAL_PROMPT", "0"),
1343 &envs,
1344 );
1345 self.core.parse(cmd, parse::parse_ls_remote_heads).await
1346 }
1347
1348 async fn is_merged(&self, dir: &Path, branch: &str, target: &str) -> Result<bool> {
1349 reject_flag_like("branch", branch)?;
1350 reject_flag_like("target", target)?;
1351 let out = self
1356 .core
1357 .run(self.core.command_in(
1358 dir,
1359 ["branch", "--merged", target, "--no-column", "--no-color"],
1360 ))
1361 .await?;
1362 Ok(out
1366 .lines()
1367 .filter_map(|line| line.get(2..))
1368 .any(|b| b == branch))
1369 }
1370
1371 async fn set_upstream(&self, dir: &Path, branch: &str, upstream: &str) -> Result<()> {
1372 reject_flag_like("branch name", branch)?;
1373 let flag = format!("--set-upstream-to={upstream}");
1374 self.core
1375 .run_unit(self.core.command_in(dir, ["branch", flag.as_str(), branch]))
1376 .await
1377 }
1378
1379 async fn delete_branch(&self, dir: &Path, name: &str, force: bool) -> Result<()> {
1380 reject_flag_like("branch name", name)?;
1381 let flag = if force { "-D" } else { "-d" };
1382 self.core
1383 .run_unit(self.core.command_in(dir, ["branch", flag, name]))
1384 .await
1385 }
1386
1387 async fn rename_branch(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
1388 reject_flag_like("branch name", old)?;
1389 reject_flag_like("branch name", new)?;
1390 self.core
1391 .run_unit(self.core.command_in(dir, ["branch", "-m", old, new]))
1392 .await
1393 }
1394
1395 async fn rev_list_count(&self, dir: &Path, range: &str) -> Result<usize> {
1396 reject_flag_like("range", range)?;
1397 self.core
1398 .try_parse(
1399 self.core.command_in(dir, ["rev-list", "--count", range]),
1400 |s| {
1401 s.trim().parse::<usize>().map_err(|e| Error::Parse {
1402 program: BINARY.to_string(),
1403 message: e.to_string(),
1404 })
1405 },
1406 )
1407 .await
1408 }
1409
1410 async fn diff_range_is_empty(&self, dir: &Path, range: &str) -> Result<bool> {
1411 reject_flag_like("range", range)?;
1412 self.core
1414 .probe(self.core.command_in(dir, ["diff", "--quiet", range]))
1415 .await
1416 }
1417
1418 async fn diff_stat(&self, dir: &Path, range: &str) -> Result<DiffStat> {
1419 reject_flag_like("range", range)?;
1420 self.core
1425 .parse(
1426 c_locale(self.core.command_in(dir, ["diff", "--shortstat", range])),
1427 parse::parse_shortstat,
1428 )
1429 .await
1430 }
1431
1432 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1433 let target = match spec {
1437 DiffSpec::WorkingTree => {
1438 if self.is_unborn(dir).await? {
1442 EMPTY_TREE.to_string()
1443 } else {
1444 "HEAD".to_string()
1445 }
1446 }
1447 DiffSpec::Rev(rev) => {
1448 reject_flag_like("revision", &rev)?;
1449 rev
1450 }
1451 };
1452 self.core
1460 .run_untrimmed(self.core.command_in(
1461 dir,
1462 [
1463 "diff",
1464 target.as_str(),
1465 "--no-color",
1466 "--no-ext-diff",
1467 "-M",
1468 "--src-prefix=a/",
1469 "--dst-prefix=b/",
1470 ],
1471 ))
1472 .await
1473 }
1474
1475 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1476 let text = self.diff_text(dir, spec).await?;
1477 Ok(parse_diff(&text))
1478 }
1479
1480 async fn staged_is_empty(&self, dir: &Path) -> Result<bool> {
1481 self.core
1483 .probe(self.core.command_in(dir, ["diff", "--cached", "--quiet"]))
1484 .await
1485 }
1486
1487 async fn is_rebase_in_progress(&self, dir: &Path) -> Result<bool> {
1488 let git_dir = self.resolved_git_dir(dir).await?;
1489 let rebase_apply = git_dir.join("rebase-apply");
1494 let is_rebase_apply = rebase_apply.exists() && !rebase_apply.join("applying").exists();
1495 Ok(git_dir.join("rebase-merge").exists() || is_rebase_apply)
1496 }
1497
1498 async fn is_am_in_progress(&self, dir: &Path) -> Result<bool> {
1499 Ok(self
1502 .resolved_git_dir(dir)
1503 .await?
1504 .join("rebase-apply")
1505 .join("applying")
1506 .exists())
1507 }
1508
1509 async fn is_merge_in_progress(&self, dir: &Path) -> Result<bool> {
1510 Ok(self
1511 .resolved_git_dir(dir)
1512 .await?
1513 .join("MERGE_HEAD")
1514 .exists())
1515 }
1516
1517 async fn fetch(&self, dir: &Path) -> Result<()> {
1518 let (pre, envs) = self.remote_credentials(None).await?;
1526 let mut args: Vec<String> = pre;
1527 args.extend(["fetch", "--quiet"].map(String::from));
1528 let cmd = apply_secret_env(
1529 c_locale(self.core.command_in(dir, &args))
1530 .env("GIT_TERMINAL_PROMPT", "0")
1531 .timeout_grace(FETCH_TIMEOUT_GRACE)
1534 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1535 &envs,
1536 );
1537 self.core.run_unit(cmd).await
1538 }
1539
1540 async fn fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1541 reject_flag_like("remote", remote)?;
1545 let (pre, envs) = self.remote_credentials(None).await?;
1548 let mut args: Vec<String> = pre;
1549 args.extend(["fetch", "--quiet", remote].map(String::from));
1550 let cmd = apply_secret_env(
1551 c_locale(self.core.command_in(dir, &args))
1552 .env("GIT_TERMINAL_PROMPT", "0")
1553 .timeout_grace(FETCH_TIMEOUT_GRACE)
1554 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1555 &envs,
1556 );
1557 self.core.run_unit(cmd).await
1558 }
1559
1560 async fn fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1561 let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
1562 let (pre, envs) = self.remote_credentials(None).await?;
1563 let mut args: Vec<String> = pre;
1564 args.extend(["fetch", "--quiet", "origin", refspec.as_str()].map(String::from));
1565 let cmd = apply_secret_env(
1566 c_locale(self.core.command_in(dir, &args))
1567 .env("GIT_TERMINAL_PROMPT", "0")
1568 .timeout_grace(FETCH_TIMEOUT_GRACE)
1569 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1570 &envs,
1571 );
1572 self.core.run_unit(cmd).await
1573 }
1574
1575 async fn push(&self, dir: &Path, spec: GitPush) -> Result<()> {
1576 reject_flag_like("remote", &spec.remote)?;
1577 reject_flag_like("refspec", &spec.refspec)?;
1578 let sides: Vec<&str> = spec.refspec.split(':').collect();
1587 if sides.len() > 2 || sides.iter().any(|s| s.starts_with('+')) {
1588 return Err(processkit::Error::Spawn {
1589 program: BINARY.to_string(),
1590 source: std::io::Error::new(
1591 std::io::ErrorKind::InvalidInput,
1592 format!(
1593 "push refspec {:?} contains a force (`+`) or multi-ref (`:`) \
1594 metacharacter — pass a plain branch or `local:remote`, or use \
1595 `run([\"push\", …])` for a force-push",
1596 spec.refspec
1597 ),
1598 ),
1599 });
1600 }
1601 let (pre, envs) = self.remote_credentials(None).await?;
1602 let mut args: Vec<String> = pre;
1603 args.push("push".to_string());
1604 if spec.set_upstream {
1605 args.push("-u".to_string());
1606 }
1607 args.push(spec.remote.clone());
1608 args.push(spec.refspec.clone());
1609 let cmd = apply_secret_env(
1610 self.core
1611 .command_in(dir, &args)
1612 .env("GIT_TERMINAL_PROMPT", "0")
1613 .timeout_grace(FETCH_TIMEOUT_GRACE),
1618 &envs,
1619 );
1620 self.core.run_unit(cmd).await
1621 }
1622
1623 async fn merge_squash(&self, dir: &Path, branch: &str) -> Result<()> {
1624 reject_flag_like("branch", branch)?;
1625 self.core
1628 .run_unit(c_locale(
1629 self.core.command_in(dir, ["merge", "--squash", branch]),
1630 ))
1631 .await
1632 }
1633
1634 async fn merge_commit(&self, dir: &Path, spec: MergeCommit) -> Result<()> {
1635 reject_flag_like("branch", &spec.branch)?;
1636 let mut args: Vec<&str> = vec!["merge"];
1637 if spec.no_ff {
1638 args.push("--no-ff");
1639 }
1640 if let Some(msg) = spec.message.as_deref() {
1641 args.push("-m");
1642 args.push(msg);
1643 } else {
1644 args.push("--no-edit");
1647 }
1648 args.push(&spec.branch);
1649 self.core
1651 .run_unit(c_locale(self.core.command_in(dir, args)))
1652 .await
1653 }
1654
1655 async fn merge_no_commit(&self, dir: &Path, spec: MergeNoCommit) -> Result<()> {
1656 reject_flag_like("branch", &spec.branch)?;
1657 let mut args: Vec<&str> = vec!["merge", "--no-commit"];
1658 if spec.squash {
1661 args.push("--squash");
1662 } else if spec.no_ff {
1663 args.push("--no-ff");
1664 }
1665 args.push(&spec.branch);
1666 self.core
1668 .run_unit(c_locale(self.core.command_in(dir, args)))
1669 .await
1670 }
1671
1672 async fn merge_abort(&self, dir: &Path) -> Result<()> {
1673 self.core
1674 .run_unit(c_locale(self.core.command_in(dir, ["merge", "--abort"])))
1675 .await
1676 }
1677
1678 async fn merge_continue(&self, dir: &Path) -> Result<()> {
1679 self.core
1684 .run_unit(no_editor(c_locale(
1685 self.core.command_in(dir, ["commit", "--no-edit"]),
1686 )))
1687 .await
1688 }
1689
1690 async fn reset_merge(&self, dir: &Path) -> Result<()> {
1691 self.core
1692 .run_unit(self.core.command_in(dir, ["reset", "--merge"]))
1693 .await
1694 }
1695
1696 async fn reset_hard(&self, dir: &Path, rev: &str) -> Result<()> {
1697 reject_flag_like("revision", rev)?;
1698 self.core
1699 .run_unit(self.core.command_in(dir, ["reset", "--hard", rev]))
1700 .await
1701 }
1702
1703 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1704 reject_flag_like("rebase target", onto)?;
1705 self.core
1709 .run_unit(no_editor(c_locale(
1710 self.core.command_in(dir, ["rebase", onto]),
1711 )))
1712 .await
1713 }
1714
1715 async fn rebase_abort(&self, dir: &Path) -> Result<()> {
1716 self.core
1717 .run_unit(c_locale(self.core.command_in(dir, ["rebase", "--abort"])))
1718 .await
1719 }
1720
1721 async fn am_abort(&self, dir: &Path) -> Result<()> {
1722 self.core
1723 .run_unit(c_locale(self.core.command_in(dir, ["am", "--abort"])))
1724 .await
1725 }
1726
1727 async fn rebase_continue(&self, dir: &Path) -> Result<()> {
1728 self.core
1729 .run_unit(no_editor(c_locale(
1730 self.core.command_in(dir, ["rebase", "--continue"]),
1731 )))
1732 .await
1733 }
1734
1735 async fn stash_push(&self, dir: &Path, include_untracked: bool) -> Result<()> {
1736 let mut command = self.core.command_in(dir, ["stash", "push"]);
1737 if include_untracked {
1738 command = command.arg("--include-untracked");
1739 }
1740 self.core.run_unit(command).await
1741 }
1742
1743 async fn stash_pop(&self, dir: &Path) -> Result<()> {
1744 self.core
1748 .run_unit(c_locale(self.core.command_in(dir, ["stash", "pop"])))
1749 .await
1750 }
1751
1752 async fn worktree_list(&self, dir: &Path) -> Result<Vec<Worktree>> {
1753 self.core
1754 .parse(
1755 self.core
1756 .command_in(dir, ["worktree", "list", "--porcelain"]),
1757 parse::parse_worktree_porcelain,
1758 )
1759 .await
1760 }
1761
1762 async fn worktree_add(&self, dir: &Path, spec: WorktreeAdd) -> Result<()> {
1763 if let Some(name) = spec.new_branch.as_deref() {
1764 reject_flag_like("branch name", name)?;
1765 }
1766 if let Some(commitish) = spec.commitish.as_deref() {
1767 reject_flag_like("commit-ish", commitish)?;
1768 }
1769 let mut command = self.core.command_in(dir, ["worktree", "add"]);
1770 if let Some(name) = spec.new_branch.as_deref() {
1771 command = command.arg("-b").arg(name);
1772 }
1773 if spec.no_checkout {
1774 command = command.arg("--no-checkout");
1775 }
1776 command = command.arg(&spec.path);
1777 if let Some(commitish) = spec.commitish.as_deref() {
1778 command = command.arg(commitish);
1779 }
1780 self.core.run_unit(command).await
1781 }
1782
1783 async fn worktree_remove(&self, dir: &Path, path: &Path, force: bool) -> Result<()> {
1784 let mut command = self.core.command_in(dir, ["worktree", "remove"]);
1785 if force {
1786 command = command.arg("--force");
1787 }
1788 command = command.arg(path);
1789 self.core.run_unit(command).await
1790 }
1791
1792 async fn worktree_move(&self, dir: &Path, from: &Path, to: &Path) -> Result<()> {
1793 let command = self
1794 .core
1795 .command_in(dir, ["worktree", "move"])
1796 .arg(from)
1797 .arg(to);
1798 self.core.run_unit(command).await
1799 }
1800
1801 async fn worktree_prune(&self, dir: &Path) -> Result<()> {
1802 self.core
1803 .run_unit(self.core.command_in(dir, ["worktree", "prune"]))
1804 .await
1805 }
1806
1807 async fn clone_repo(&self, url: &str, dest: &Path, spec: CloneSpec) -> Result<()> {
1808 reject_flag_like("url", url)?;
1812 let (pre, envs) = self
1818 .remote_credentials(vcs_cli_support::https_host(url).as_deref())
1819 .await?;
1820 let mut initial: Vec<String> = pre;
1821 initial.push("clone".to_string());
1822 let mut command = self.core.command(&initial);
1823 if let Some(branch) = spec.branch.as_deref() {
1824 command = command.arg("--branch").arg(branch);
1825 }
1826 if let Some(depth) = spec.depth {
1827 command = command.arg("--depth").arg(depth.to_string());
1828 }
1829 if spec.bare {
1830 command = command.arg("--bare");
1831 }
1832 let command = apply_secret_env(
1833 command
1834 .arg(url)
1835 .arg(dest)
1836 .env("GIT_TERMINAL_PROMPT", "0")
1837 .timeout_grace(FETCH_TIMEOUT_GRACE),
1840 &envs,
1841 );
1842
1843 let cleanable = match std::fs::read_dir(dest) {
1855 Err(_) => true, Ok(mut entries) => entries.next().is_none(), };
1858 let result = self.core.run_unit(command).await;
1859 if result.is_err() && cleanable {
1860 let _ = std::fs::remove_dir_all(dest);
1861 }
1862 result
1863 }
1864
1865 async fn tag_create(&self, dir: &Path, name: &str, rev: Option<String>) -> Result<()> {
1866 reject_flag_like("tag name", name)?;
1867 if let Some(rev) = rev.as_deref() {
1868 reject_flag_like("revision", rev)?;
1869 }
1870 let mut args = vec!["tag", name];
1871 if let Some(rev) = rev.as_deref() {
1872 args.push(rev);
1873 }
1874 self.core.run_unit(self.core.command_in(dir, args)).await
1875 }
1876
1877 async fn tag_create_annotated(&self, dir: &Path, spec: AnnotatedTag) -> Result<()> {
1878 reject_flag_like("tag name", &spec.name)?;
1879 if let Some(rev) = spec.rev.as_deref() {
1880 reject_flag_like("revision", rev)?;
1881 }
1882 let mut args = vec!["tag", "-a", &spec.name, "-m", &spec.message];
1883 if let Some(rev) = spec.rev.as_deref() {
1884 args.push(rev);
1885 }
1886 self.core.run_unit(self.core.command_in(dir, args)).await
1887 }
1888
1889 async fn tag_list(&self, dir: &Path) -> Result<Vec<String>> {
1890 let out = self
1893 .core
1894 .run(self.core.command_in(dir, ["tag", "--list", "--no-column"]))
1895 .await?;
1896 Ok(out.lines().map(str::to_string).collect())
1897 }
1898
1899 async fn tag_delete(&self, dir: &Path, name: &str) -> Result<()> {
1900 reject_flag_like("tag name", name)?;
1901 self.core
1902 .run_unit(self.core.command_in(dir, ["tag", "-d", name]))
1903 .await
1904 }
1905
1906 async fn show_file(&self, dir: &Path, rev: &str, path: &str) -> Result<String> {
1907 reject_flag_like("revision", rev)?;
1910 #[cfg(windows)]
1915 let path = path.replace('\\', "/");
1916 let spec = format!("{rev}:{path}");
1917 self.core
1920 .run_untrimmed(self.core.command_in(dir, ["show", spec.as_str()]))
1921 .await
1922 }
1923
1924 async fn config_get(&self, dir: &Path, key: &str) -> Result<Option<String>> {
1925 reject_flag_like("config key", key)?;
1926 let res = self
1927 .core
1928 .output_string(self.core.command_in(dir, ["config", "--get", key]))
1929 .await?;
1930 match res.code() {
1931 Some(1) => Ok(None),
1933 Some(0) => Ok(Some(
1938 res.stdout().trim_end_matches(['\r', '\n']).to_string(),
1939 )),
1940 _ => {
1941 let _ = res.ensure_success()?;
1942 Ok(None) }
1944 }
1945 }
1946
1947 async fn config_set(&self, dir: &Path, key: &str, value: &str) -> Result<()> {
1948 reject_flag_like("config key", key)?;
1949 self.core
1950 .run_unit(self.core.command_in(dir, ["config", key, value]))
1951 .await
1952 }
1953
1954 async fn remote_add(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1955 reject_flag_like("remote name", name)?;
1956 reject_flag_like("url", url)?;
1957 self.core
1958 .run_unit(self.core.command_in(dir, ["remote", "add", name, url]))
1959 .await
1960 }
1961
1962 async fn remote_set_url(&self, dir: &Path, name: &str, url: &str) -> Result<()> {
1963 reject_flag_like("remote name", name)?;
1964 reject_flag_like("url", url)?;
1965 self.core
1966 .run_unit(self.core.command_in(dir, ["remote", "set-url", name, url]))
1967 .await
1968 }
1969
1970 async fn blame(&self, dir: &Path, path: &str, rev: Option<String>) -> Result<Vec<BlameLine>> {
1971 let mut args = vec!["blame", "--line-porcelain"];
1972 if let Some(rev) = rev.as_deref() {
1973 reject_flag_like("revision", rev)?;
1976 args.push(rev);
1977 }
1978 args.push("--");
1979 args.push(path);
1980 self.core
1981 .parse(
1982 self.core.command_in(dir, args),
1983 parse::parse_blame_porcelain,
1984 )
1985 .await
1986 }
1987
1988 async fn cherry_pick(&self, dir: &Path, rev: &str) -> Result<()> {
1989 reject_flag_like("revision", rev)?;
1990 self.core
1993 .run_unit(no_editor(c_locale(
1994 self.core.command_in(dir, ["cherry-pick", rev]),
1995 )))
1996 .await
1997 }
1998
1999 async fn revert(&self, dir: &Path, rev: &str) -> Result<()> {
2000 reject_flag_like("revision", rev)?;
2001 self.core
2002 .run_unit(no_editor(c_locale(
2003 self.core.command_in(dir, ["revert", "--no-edit", rev]),
2004 )))
2005 .await
2006 }
2007
2008 async fn rebase_skip(&self, dir: &Path) -> Result<()> {
2009 self.core
2010 .run_unit(no_editor(c_locale(
2011 self.core.command_in(dir, ["rebase", "--skip"]),
2012 )))
2013 .await
2014 }
2015}
2016
2017pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
2028
2029const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2032const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2033const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2034
2035fn no_editor(cmd: processkit::Command) -> processkit::Command {
2039 cmd.env("GIT_EDITOR", "true")
2040 .env("GIT_SEQUENCE_EDITOR", "true")
2041}
2042
2043fn c_locale(cmd: processkit::Command) -> processkit::Command {
2049 cmd.env("LC_ALL", "C")
2050}
2051
2052fn reject_flag_like(what: &str, value: &str) -> Result<()> {
2056 vcs_cli_support::reject_flag_like(BINARY, what, value)
2057}
2058
2059impl<R: ProcessRunner> Git<R> {
2060 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
2065 self.core.run(args).await
2066 }
2067
2068 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
2071 self.core.output_string(args).await
2072 }
2073
2074 pub fn at<'a>(&'a self, dir: &'a Path) -> GitAt<'a, R> {
2079 GitAt { git: self, dir }
2080 }
2081
2082 pub fn harden(self) -> Self {
2155 let removed = [
2156 "GIT_DIR",
2161 "GIT_WORK_TREE",
2162 "GIT_INDEX_FILE",
2163 "GIT_COMMON_DIR",
2164 "GIT_OBJECT_DIRECTORY",
2165 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
2166 "GIT_NAMESPACE",
2167 "GIT_CEILING_DIRECTORIES",
2168 "GIT_CONFIG_PARAMETERS",
2169 "GIT_CONFIG_GLOBAL",
2170 "GIT_CONFIG_SYSTEM",
2171 "GIT_SSH_COMMAND",
2173 "GIT_SSH",
2174 "GIT_ASKPASS",
2175 "GIT_EXTERNAL_DIFF",
2176 "GIT_PAGER",
2177 "GIT_EDITOR",
2178 "GIT_SEQUENCE_EDITOR",
2179 "GIT_PROXY_COMMAND",
2184 "GIT_EXEC_PATH",
2185 "GIT_TEMPLATE_DIR",
2186 "GIT_LITERAL_PATHSPECS",
2189 "GIT_GLOB_PATHSPECS",
2190 "GIT_NOGLOB_PATHSPECS",
2191 "GIT_ICASE_PATHSPECS",
2192 ];
2193 let mut hardened = self;
2194 for key in removed {
2195 hardened = hardened.default_env_remove(key);
2196 }
2197 hardened
2198 .default_env("GIT_CONFIG_NOSYSTEM", "1")
2199 .default_env("GIT_TERMINAL_PROMPT", "0")
2200 .default_env("GIT_CONFIG_COUNT", "3")
2205 .default_env("GIT_CONFIG_KEY_0", "core.hooksPath")
2206 .default_env("GIT_CONFIG_VALUE_0", "/dev/null")
2213 .default_env("GIT_CONFIG_KEY_1", "core.fsmonitor")
2214 .default_env("GIT_CONFIG_VALUE_1", "false")
2215 .default_env("GIT_CONFIG_KEY_2", "core.sshCommand")
2221 .default_env("GIT_CONFIG_VALUE_2", "")
2222 }
2223
2224 pub async fn switch_with_stash(&self, dir: &Path, branch: &str) -> Result<()> {
2247 if self.status(dir).await?.is_empty() {
2250 return self.checkout(dir, branch).await;
2251 }
2252 let depth_before = self.stash_depth(dir).await?;
2259 self.stash_push(dir, true).await?;
2260 if self.stash_depth(dir).await? <= depth_before {
2261 return self.checkout(dir, branch).await;
2263 }
2264 match self.checkout(dir, branch).await {
2267 Ok(()) => self.stash_pop_index(dir).await,
2268 Err(err) => {
2269 let _ = self.stash_pop_index(dir).await;
2273 Err(err)
2274 }
2275 }
2276 }
2277
2278 async fn stash_depth(&self, dir: &Path) -> Result<usize> {
2282 let out = self
2283 .core
2284 .run(self.core.command_in(dir, ["stash", "list"]))
2285 .await?;
2286 Ok(out.lines().filter(|l| !l.is_empty()).count())
2287 }
2288
2289 async fn stash_pop_index(&self, dir: &Path) -> Result<()> {
2293 self.core
2294 .run_unit(c_locale(
2295 self.core.command_in(dir, ["stash", "pop", "--index"]),
2296 ))
2297 .await
2298 }
2299
2300 async fn resolved_git_dir(&self, dir: &Path) -> Result<PathBuf> {
2303 let git_dir = PathBuf::from(
2304 self.core
2305 .run(self.core.command_in(dir, ["rev-parse", "--git-dir"]))
2306 .await?,
2307 );
2308 Ok(if git_dir.is_absolute() {
2309 git_dir
2310 } else {
2311 dir.join(git_dir)
2312 })
2313 }
2314}
2315
2316impl Git {
2317 pub fn hardened() -> Self {
2320 Self::new().harden()
2321 }
2322}
2323
2324pub struct GitAt<'a, R: ProcessRunner = processkit::JobRunner> {
2329 git: &'a Git<R>,
2330 dir: &'a Path,
2331}
2332
2333impl<R: ProcessRunner> Clone for GitAt<'_, R> {
2338 fn clone(&self) -> Self {
2339 *self
2340 }
2341}
2342impl<R: ProcessRunner> Copy for GitAt<'_, R> {}
2343
2344vcs_cli_support::at_forwarders! {
2348 GitAt, git, "Git",
2349 bare {
2350 fn run(args: &[String]) -> Result<String>;
2351 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
2352 fn run_args(args: &[&str]) -> Result<String>;
2353 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
2354 fn version() -> Result<String>;
2355 fn capabilities() -> Result<GitCapabilities>;
2356 fn clone_repo(url: &str, dest: &Path, spec: CloneSpec) -> Result<()>;
2357 }
2358 dir {
2359 fn status() -> Result<Vec<StatusEntry>>;
2360 fn status_text() -> Result<String>;
2361 fn status_tracked() -> Result<Vec<StatusEntry>>;
2362 fn branch_status() -> Result<BranchStatus>;
2363 fn conflicted_files() -> Result<Vec<String>>;
2364 fn current_branch() -> Result<Option<String>>;
2365 fn branches() -> Result<Vec<Branch>>;
2366 fn log(revspec: &str, max: usize) -> Result<Vec<Commit>>;
2367 fn rev_parse(rev: &str) -> Result<String>;
2368 fn rev_parse_short(rev: &str) -> Result<String>;
2369 fn init() -> Result<()>;
2370 fn add(paths: &[PathBuf]) -> Result<()>;
2371 fn commit(message: &str) -> Result<()>;
2372 fn create_branch(name: &str) -> Result<()>;
2373 fn checkout(reference: &str) -> Result<()>;
2374 fn checkout_detach(commit: &str) -> Result<()>;
2375 fn commit_paths(spec: CommitPaths) -> Result<()>;
2376 fn last_commit_message() -> Result<String>;
2377 fn is_unborn() -> Result<bool>;
2378 fn diff_is_empty() -> Result<bool>;
2379 fn common_dir() -> Result<PathBuf>;
2380 fn git_dir() -> Result<PathBuf>;
2381 fn resolve_commit(rev: &str) -> Result<String>;
2382 fn remote_head_branch() -> Result<Option<String>>;
2383 fn branch_exists(name: &str) -> Result<bool>;
2384 fn remote_branch_exists(name: &str) -> Result<bool>;
2385 fn remote_url(remote: &str) -> Result<String>;
2386 fn upstream() -> Result<Option<String>>;
2387 fn remote_branches(remote: &str) -> Result<Vec<String>>;
2388 fn is_merged(branch: &str, target: &str) -> Result<bool>;
2389 fn set_upstream(branch: &str, upstream: &str) -> Result<()>;
2390 fn delete_branch(name: &str, force: bool) -> Result<()>;
2391 fn rename_branch(old: &str, new: &str) -> Result<()>;
2392 fn rev_list_count(range: &str) -> Result<usize>;
2393 fn diff_range_is_empty(range: &str) -> Result<bool>;
2394 fn diff_stat(range: &str) -> Result<DiffStat>;
2395 fn diff_text(spec: DiffSpec) -> Result<String>;
2396 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
2397 fn staged_is_empty() -> Result<bool>;
2398 fn is_rebase_in_progress() -> Result<bool>;
2399 fn is_merge_in_progress() -> Result<bool>;
2400 fn is_am_in_progress() -> Result<bool>;
2401 fn fetch() -> Result<()>;
2402 fn fetch_from(remote: &str) -> Result<()>;
2403 fn fetch_branch(branch: &str) -> Result<()>;
2404 fn push(spec: GitPush) -> Result<()>;
2405 fn merge_squash(branch: &str) -> Result<()>;
2406 fn merge_commit(spec: MergeCommit) -> Result<()>;
2407 fn merge_no_commit(spec: MergeNoCommit) -> Result<()>;
2408 fn merge_abort() -> Result<()>;
2409 fn merge_continue() -> Result<()>;
2410 fn reset_merge() -> Result<()>;
2411 fn reset_hard(rev: &str) -> Result<()>;
2412 fn rebase(onto: &str) -> Result<()>;
2413 fn rebase_abort() -> Result<()>;
2414 fn am_abort() -> Result<()>;
2415 fn rebase_continue() -> Result<()>;
2416 fn stash_push(include_untracked: bool) -> Result<()>;
2417 fn stash_pop() -> Result<()>;
2418 fn switch_with_stash(branch: &str) -> Result<()>;
2419 fn worktree_list() -> Result<Vec<Worktree>>;
2420 fn worktree_add(spec: WorktreeAdd) -> Result<()>;
2421 fn worktree_remove(path: &Path, force: bool) -> Result<()>;
2422 fn worktree_move(from: &Path, to: &Path) -> Result<()>;
2423 fn worktree_prune() -> Result<()>;
2424 fn tag_create(name: &str, rev: Option<String>) -> Result<()>;
2425 fn tag_create_annotated(spec: AnnotatedTag) -> Result<()>;
2426 fn tag_list() -> Result<Vec<String>>;
2427 fn tag_delete(name: &str) -> Result<()>;
2428 fn show_file(rev: &str, path: &str) -> Result<String>;
2429 fn config_get(key: &str) -> Result<Option<String>>;
2430 fn config_set(key: &str, value: &str) -> Result<()>;
2431 fn remote_add(name: &str, url: &str) -> Result<()>;
2432 fn remote_set_url(name: &str, url: &str) -> Result<()>;
2433 fn blame(path: &str, rev: Option<String>) -> Result<Vec<BlameLine>>;
2434 fn cherry_pick(rev: &str) -> Result<()>;
2435 fn revert(rev: &str) -> Result<()>;
2436 fn rebase_skip() -> Result<()>;
2437 }
2438}
2439
2440pub mod blocking {
2444 use std::path::Path;
2445 use std::process::Command;
2446
2447 pub fn worktree_remove(dir: &Path, path: &Path, force: bool) -> std::io::Result<()> {
2449 let mut cmd = Command::new(super::BINARY);
2450 cmd.current_dir(dir).args(["worktree", "remove"]);
2451 if force {
2452 cmd.arg("--force");
2453 }
2454 cmd.arg(path);
2455 let status = cmd.status()?;
2456 if status.success() {
2457 Ok(())
2458 } else {
2459 Err(std::io::Error::other(format!(
2460 "`git worktree remove` exited with {status}"
2461 )))
2462 }
2463 }
2464}
2465
2466#[cfg(test)]
2467mod tests {
2468 use super::*;
2469 use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
2470
2471 #[test]
2472 fn binary_name_is_git() {
2473 assert_eq!(BINARY, "git");
2474 }
2475
2476 #[allow(dead_code)]
2480 fn bound_view_is_copy_for_default_runner() {
2481 fn assert_copy<T: Copy>() {}
2482 assert_copy::<GitAt<'static, processkit::JobRunner>>();
2483 }
2484
2485 #[tokio::test]
2489 async fn bound_view_matches_dir_taking_calls() {
2490 let dir = Path::new("/repo");
2491 let rec = RecordingRunner::replying(Reply::ok(""));
2492 let git = Git::with_runner(&rec);
2493
2494 git.merge_commit(dir, MergeCommit::branch("feat").no_ff())
2496 .await
2497 .unwrap();
2498 git.at(dir)
2499 .merge_commit(MergeCommit::branch("feat").no_ff())
2500 .await
2501 .unwrap();
2502 git.worktree_remove(dir, Path::new("/wt"), true)
2504 .await
2505 .unwrap();
2506 git.at(dir)
2507 .worktree_remove(Path::new("/wt"), true)
2508 .await
2509 .unwrap();
2510 git.conflicted_files(dir).await.unwrap();
2512 git.at(dir).conflicted_files().await.unwrap();
2513 git.tag_delete(dir, "v1").await.unwrap();
2515 git.at(dir).tag_delete("v1").await.unwrap();
2516
2517 let calls = rec.calls();
2518 assert_eq!(calls[0].args_str(), calls[1].args_str());
2519 assert_eq!(calls[2].args_str(), calls[3].args_str());
2520 assert_eq!(calls[4].args_str(), calls[5].args_str());
2521 assert_eq!(calls[6].args_str(), calls[7].args_str());
2522 assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2524 assert_eq!(calls[3].cwd.as_deref(), Some(dir));
2525 }
2526
2527 #[tokio::test]
2530 async fn status_parses_scripted_output() {
2531 let git = Git::with_runner(
2533 ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0?? b.rs\0")),
2534 );
2535 let entries = git.status(Path::new(".")).await.expect("status");
2536 assert_eq!(entries.len(), 2);
2537 assert_eq!(entries[0].code, " M");
2538 assert_eq!(entries[1].path, "b.rs");
2539 }
2540
2541 #[tokio::test]
2543 async fn status_tracked_excludes_untracked_flag() {
2544 let rec = RecordingRunner::replying(Reply::ok(" M a.rs\0"));
2545 let git = Git::with_runner(&rec);
2546 let entries = git.status_tracked(Path::new(".")).await.expect("status");
2547 assert_eq!(entries.len(), 1);
2548 assert_eq!(entries[0].code, " M");
2549 assert_eq!(
2550 rec.only_call().args_str(),
2551 ["status", "--porcelain=v1", "-z", "--untracked-files=no"]
2552 );
2553 }
2554
2555 #[tokio::test]
2558 async fn branch_status_builds_v2_branch_args_and_parses() {
2559 let out = concat!(
2560 "# branch.oid abc\0",
2561 "# branch.head main\0",
2562 "# branch.upstream origin/main\0",
2563 "# branch.ab +1 -0\0",
2564 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
2565 "? new.txt\0",
2566 );
2567 let rec = RecordingRunner::replying(Reply::ok(out));
2568 let git = Git::with_runner(&rec);
2569 let s = git
2570 .branch_status(Path::new("."))
2571 .await
2572 .expect("branch_status");
2573 assert_eq!(
2574 rec.only_call().args_str(),
2575 ["status", "--porcelain=v2", "--branch", "-z"]
2576 );
2577 assert!(rec.only_call().envs.iter().any(|(k, v)| {
2580 k.to_str() == Some("GIT_OPTIONAL_LOCKS")
2581 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
2582 }));
2583 assert_eq!(s.branch.as_deref(), Some("main"));
2584 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
2585 assert_eq!((s.ahead, s.behind), (Some(1), Some(0)));
2586 assert_eq!(s.tracked_changes, 1);
2587 assert_eq!(s.untracked, 1);
2588 assert!(s.is_dirty());
2589 }
2590
2591 #[tokio::test]
2593 async fn conflicted_files_builds_args_and_parses_nul_list() {
2594 let rec = RecordingRunner::replying(Reply::ok("a.rs\0sub/spaced name.rs\0"));
2595 let git = Git::with_runner(&rec);
2596 let paths = git
2597 .conflicted_files(Path::new("."))
2598 .await
2599 .expect("conflicted_files");
2600 assert_eq!(paths, ["a.rs", "sub/spaced name.rs"]);
2601 assert_eq!(
2602 rec.only_call().args_str(),
2603 ["diff", "--name-only", "--diff-filter=U", "-z"]
2604 );
2605 }
2606
2607 #[tokio::test]
2608 async fn rev_parse_short_builds_short_flag() {
2609 let rec = RecordingRunner::replying(Reply::ok("a1b2c3d\n"));
2610 let git = Git::with_runner(&rec);
2611 let out = git.rev_parse_short(Path::new("/r"), "HEAD").await.unwrap();
2612 assert_eq!(out, "a1b2c3d");
2613 assert_eq!(rec.only_call().args_str(), ["rev-parse", "--short", "HEAD"]);
2614 }
2615
2616 #[tokio::test]
2619 async fn rev_parse_verifies_the_revision() {
2620 let rec = RecordingRunner::replying(Reply::ok("deadbeef\n"));
2621 let git = Git::with_runner(&rec);
2622 let out = git.rev_parse(Path::new("/r"), "HEAD").await.unwrap();
2623 assert_eq!(out, "deadbeef");
2624 assert_eq!(
2625 rec.only_call().args_str(),
2626 ["rev-parse", "--verify", "HEAD"]
2627 );
2628 }
2629
2630 #[tokio::test]
2634 async fn distinguishes_git_am_from_an_apply_backend_rebase() {
2635 use vcs_testkit::TempDir;
2636 let gd = TempDir::new("m20-am");
2637 let git = Git::with_runner(ScriptedRunner::new().on(
2638 ["git", "rev-parse", "--git-dir"],
2639 Reply::ok(gd.path().to_str().unwrap()),
2640 ));
2641 let apply = gd.path().join("rebase-apply");
2642 std::fs::create_dir_all(&apply).unwrap();
2643
2644 std::fs::write(apply.join("applying"), b"").unwrap();
2646 assert!(
2647 git.is_am_in_progress(Path::new("/r")).await.unwrap(),
2648 "am detected"
2649 );
2650 assert!(
2651 !git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2652 "a git am is NOT reported as a rebase"
2653 );
2654
2655 std::fs::remove_file(apply.join("applying")).unwrap();
2657 assert!(!git.is_am_in_progress(Path::new("/r")).await.unwrap());
2658 assert!(
2659 git.is_rebase_in_progress(Path::new("/r")).await.unwrap(),
2660 "a bare rebase-apply dir is a rebase"
2661 );
2662 }
2663
2664 #[tokio::test]
2666 async fn nonzero_exit_is_structured_error() {
2667 let git = Git::with_runner(
2668 ScriptedRunner::new().on(["git", "status"], Reply::fail(128, "not a git repository")),
2669 );
2670 match git.status(Path::new(".")).await.unwrap_err() {
2671 Error::Exit { code, stderr, .. } => {
2672 assert_eq!(code, 128);
2673 assert!(stderr.contains("not a git repository"), "{stderr}");
2674 }
2675 other => panic!("expected Exit, got {other:?}"),
2676 }
2677 }
2678
2679 #[tokio::test]
2682 async fn diff_is_empty_maps_exit_codes() {
2683 let clean =
2684 Git::with_runner(ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::ok("")));
2685 assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
2686
2687 let dirty = Git::with_runner(
2688 ScriptedRunner::new().on(["git", "diff", "--quiet"], Reply::fail(1, "")),
2689 );
2690 assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
2691
2692 let broken = Git::with_runner(ScriptedRunner::new().on(
2693 ["git", "diff", "--quiet"],
2694 Reply::fail(128, "fatal: not a repo"),
2695 ));
2696 assert!(matches!(
2697 broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
2698 Error::Exit { code: 128, .. }
2699 ));
2700 }
2701
2702 #[tokio::test]
2705 async fn add_inserts_pathspec_separator() {
2706 let git = Git::with_runner(ScriptedRunner::new().on(["git", "add", "--"], Reply::ok("")));
2707 git.add(Path::new("."), &[PathBuf::from("f.rs")])
2708 .await
2709 .expect("add should build `add -- <paths>`");
2710 }
2711
2712 #[tokio::test]
2713 async fn worktree_list_parses_porcelain() {
2714 let git = Git::with_runner(ScriptedRunner::new().on(
2715 ["git", "worktree", "list"],
2716 Reply::ok("worktree /repo\nHEAD abc\nbranch refs/heads/main\n"),
2717 ));
2718 let wts = git.worktree_list(Path::new(".")).await.expect("list");
2719 assert_eq!(wts.len(), 1);
2720 assert_eq!(wts[0].branch.as_deref(), Some("main"));
2721 assert_eq!(wts[0].head.as_deref(), Some("abc"));
2722 }
2723
2724 #[tokio::test]
2727 async fn worktree_add_builds_branch_path_and_base() {
2728 let rec = RecordingRunner::replying(Reply::ok(""));
2729 let git = Git::with_runner(&rec);
2730 git.worktree_add(
2731 Path::new("/repo"),
2732 WorktreeAdd::create_branch("/wt", "feature", "main"),
2733 )
2734 .await
2735 .expect("worktree add");
2736 assert_eq!(
2737 rec.only_call().args_str(),
2738 ["worktree", "add", "-b", "feature", "/wt", "main"]
2739 );
2740 }
2741
2742 #[tokio::test]
2743 async fn worktree_remove_passes_force_then_path() {
2744 let rec = RecordingRunner::replying(Reply::ok(""));
2745 let git = Git::with_runner(&rec);
2746 git.worktree_remove(Path::new("/repo"), Path::new("/wt"), true)
2747 .await
2748 .expect("remove");
2749 assert_eq!(
2750 rec.only_call().args_str(),
2751 ["worktree", "remove", "--force", "/wt"]
2752 );
2753 }
2754
2755 #[tokio::test]
2757 async fn worktree_add_no_checkout_inserts_flag() {
2758 let rec = RecordingRunner::replying(Reply::ok(""));
2759 let git = Git::with_runner(&rec);
2760 git.worktree_add(
2761 Path::new("/repo"),
2762 WorktreeAdd::checkout("/wt", "main").no_checkout(),
2763 )
2764 .await
2765 .expect("worktree add");
2766 assert_eq!(
2767 rec.only_call().args_str(),
2768 ["worktree", "add", "--no-checkout", "/wt", "main"]
2769 );
2770 }
2771
2772 #[tokio::test]
2773 async fn checkout_detach_builds_args() {
2774 let rec = RecordingRunner::replying(Reply::ok(""));
2775 let git = Git::with_runner(&rec);
2776 git.checkout_detach(Path::new("."), "abc123")
2777 .await
2778 .expect("detach");
2779 assert_eq!(
2780 rec.only_call().args_str(),
2781 ["checkout", "--detach", "abc123"]
2782 );
2783 }
2784
2785 #[tokio::test]
2789 async fn current_branch_reads_symbolic_ref_with_exit_mapping() {
2790 let rec = RecordingRunner::replying(Reply::ok("feature/x\n"));
2792 let on_branch = Git::with_runner(&rec);
2793 assert_eq!(
2794 on_branch.current_branch(Path::new(".")).await.unwrap(),
2795 Some("feature/x".to_string())
2796 );
2797 assert_eq!(
2798 rec.only_call().args_str(),
2799 ["symbolic-ref", "--quiet", "--short", "HEAD"]
2800 );
2801 let unborn = Git::with_runner(
2804 ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")),
2805 );
2806 assert_eq!(
2807 unborn.current_branch(Path::new(".")).await.unwrap(),
2808 Some("main".to_string())
2809 );
2810 let detached =
2812 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2813 assert_eq!(detached.current_branch(Path::new(".")).await.unwrap(), None);
2814 let not_repo = Git::with_runner(ScriptedRunner::new().on(
2816 ["git", "symbolic-ref"],
2817 Reply::fail(128, "fatal: not a git repository"),
2818 ));
2819 assert!(not_repo.current_branch(Path::new(".")).await.is_err());
2820 }
2821
2822 #[tokio::test]
2824 async fn commit_paths_builds_only_amend_args() {
2825 let rec = RecordingRunner::replying(Reply::ok(""));
2826 let git = Git::with_runner(&rec);
2827 git.commit_paths(
2828 Path::new("."),
2829 CommitPaths::new([PathBuf::from("a.rs"), PathBuf::from("b.rs")], "msg").amend(),
2830 )
2831 .await
2832 .expect("commit_paths");
2833 assert_eq!(
2834 rec.only_call().args_str(),
2835 [
2836 "commit", "--amend", "-m", "msg", "--only", "--", "a.rs", "b.rs"
2837 ]
2838 );
2839 }
2840
2841 #[tokio::test]
2844 async fn is_unborn_maps_exit_codes() {
2845 let born =
2846 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("abc\n")));
2847 assert!(!born.is_unborn(Path::new(".")).await.unwrap());
2848 let unborn =
2849 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(1, "")));
2850 assert!(unborn.is_unborn(Path::new(".")).await.unwrap());
2851 let broken = Git::with_runner(
2852 ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "boom")),
2853 );
2854 assert!(matches!(
2855 broken.is_unborn(Path::new(".")).await.unwrap_err(),
2856 Error::Exit { code: 128, .. }
2857 ));
2858 }
2859
2860 #[tokio::test]
2861 async fn log_builds_revspec_and_format() {
2862 let rec = RecordingRunner::replying(Reply::ok(""));
2863 let git = Git::with_runner(&rec);
2864 git.log(Path::new("."), "main..HEAD", 5).await.expect("log");
2865 assert_eq!(
2866 rec.only_call().args_str(),
2867 [
2868 "log",
2869 "main..HEAD",
2870 "-n5",
2871 "-z",
2872 "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s"
2873 ]
2874 );
2875 }
2876
2877 #[tokio::test]
2878 async fn stash_push_adds_include_untracked() {
2879 let rec = RecordingRunner::replying(Reply::ok(""));
2880 let git = Git::with_runner(&rec);
2881 git.stash_push(Path::new("."), true).await.expect("stash");
2882 assert_eq!(
2883 rec.only_call().args_str(),
2884 ["stash", "push", "--include-untracked"]
2885 );
2886 }
2887
2888 #[tokio::test]
2891 async fn diff_text_builds_working_tree_args() {
2892 let rec = RecordingRunner::replying(Reply::ok(""));
2895 let git = Git::with_runner(&rec);
2896 git.diff_text(Path::new("."), DiffSpec::WorkingTree)
2897 .await
2898 .expect("diff_text");
2899 assert_eq!(
2900 rec.calls().last().unwrap().args_str(),
2901 [
2902 "diff",
2903 "HEAD",
2904 "--no-color",
2905 "--no-ext-diff",
2906 "-M",
2907 "--src-prefix=a/",
2910 "--dst-prefix=b/",
2911 ]
2912 );
2913 }
2914
2915 #[tokio::test]
2919 async fn diff_text_working_tree_uses_empty_tree_when_unborn() {
2920 let git = Git::with_runner(
2921 ScriptedRunner::new()
2922 .on(["git", "rev-parse"], Reply::fail(1, "")) .on(["git", "diff", EMPTY_TREE], Reply::ok("EMPTY")),
2924 );
2925 let out = git
2926 .diff_text(Path::new("."), DiffSpec::WorkingTree)
2927 .await
2928 .expect("diff_text");
2929 assert_eq!(out, "EMPTY");
2930 }
2931
2932 #[tokio::test]
2935 async fn diff_parses_scripted_output() {
2936 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2937 let git = Git::with_runner(ScriptedRunner::new().on(["git", "diff"], Reply::ok(out)));
2938 let files = git
2939 .diff(Path::new("."), DiffSpec::Rev("HEAD~1".into()))
2940 .await
2941 .expect("diff");
2942 assert_eq!(files.len(), 1);
2943 assert_eq!(files[0].path, "m");
2944 assert_eq!(files[0].change, ChangeKind::Modified);
2945 }
2946
2947 #[tokio::test]
2948 async fn branch_exists_maps_exit_codes() {
2949 let yes = Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
2950 assert!(yes.branch_exists(Path::new("."), "main").await.unwrap());
2951 let no =
2952 Git::with_runner(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
2953 assert!(!no.branch_exists(Path::new("."), "nope").await.unwrap());
2954 }
2955
2956 #[tokio::test]
2959 async fn remote_head_branch_strips_prefix_and_keeps_slashes() {
2960 let simple = Git::with_runner(ScriptedRunner::new().on(
2961 ["git", "symbolic-ref"],
2962 Reply::ok("refs/remotes/origin/main\n"),
2963 ));
2964 assert_eq!(
2965 simple
2966 .remote_head_branch(Path::new("."))
2967 .await
2968 .unwrap()
2969 .as_deref(),
2970 Some("main")
2971 );
2972
2973 let slashed = Git::with_runner(ScriptedRunner::new().on(
2974 ["git", "symbolic-ref"],
2975 Reply::ok("refs/remotes/origin/release/v2\n"),
2976 ));
2977 assert_eq!(
2978 slashed
2979 .remote_head_branch(Path::new("."))
2980 .await
2981 .unwrap()
2982 .as_deref(),
2983 Some("release/v2")
2984 );
2985
2986 let unset =
2987 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2988 assert!(
2989 unset
2990 .remote_head_branch(Path::new("."))
2991 .await
2992 .unwrap()
2993 .is_none()
2994 );
2995 }
2996
2997 #[tokio::test]
3000 async fn remote_branch_exists_sets_env_and_reads_stdout() {
3001 let rec = RecordingRunner::replying(Reply::ok("abc123\trefs/heads/main\n"));
3002 let git = Git::with_runner(&rec);
3003 assert!(
3004 git.remote_branch_exists(Path::new("/repo"), "main")
3005 .await
3006 .unwrap()
3007 );
3008 let call = rec.only_call();
3009 assert!(call.envs.iter().any(|(k, v)| {
3010 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3011 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3012 }));
3013 assert_eq!(call.args_str(), ["ls-remote", "origin", "refs/heads/main"]);
3015
3016 let empty = Git::with_runner(ScriptedRunner::new().on(["git", "ls-remote"], Reply::ok("")));
3017 assert!(
3018 !empty
3019 .remote_branch_exists(Path::new("."), "x")
3020 .await
3021 .unwrap()
3022 );
3023 }
3024
3025 #[tokio::test]
3026 async fn diff_stat_parses_counts() {
3027 let git = Git::with_runner(ScriptedRunner::new().on(
3028 ["git", "diff", "--shortstat"],
3029 Reply::ok(" 2 files changed, 5 insertions(+), 1 deletion(-)\n"),
3030 ));
3031 let stat = git.diff_stat(Path::new("."), "main..HEAD").await.unwrap();
3032 assert_eq!(
3033 (stat.files_changed, stat.insertions, stat.deletions),
3034 (2, 5, 1)
3035 );
3036 }
3037
3038 #[tokio::test]
3039 async fn status_text_returns_raw_porcelain() {
3040 let git = Git::with_runner(ScriptedRunner::new().on(
3041 ["git", "status", "--porcelain=v1"],
3042 Reply::ok(" M a.rs\n?? b.rs\n"),
3043 ));
3044 let text = git.status_text(Path::new(".")).await.expect("status_text");
3045 assert!(text.contains(" M a.rs") && text.contains("?? b.rs"));
3046 }
3047
3048 #[tokio::test]
3049 async fn run_args_forwards_str_slices() {
3050 let git =
3051 Git::with_runner(ScriptedRunner::new().on(["git", "status", "-s"], Reply::ok("ok\n")));
3052 assert_eq!(git.run_args(&["status", "-s"]).await.unwrap(), "ok");
3053 }
3054
3055 #[tokio::test]
3056 async fn merge_commit_builds_no_ff_and_message() {
3057 let rec = RecordingRunner::replying(Reply::ok(""));
3058 let git = Git::with_runner(&rec);
3059 git.merge_commit(
3060 Path::new("/r"),
3061 MergeCommit::branch("feature").no_ff().message("merge it"),
3062 )
3063 .await
3064 .unwrap();
3065 assert_eq!(
3066 rec.only_call().args_str(),
3067 ["merge", "--no-ff", "-m", "merge it", "feature"]
3068 );
3069 }
3070
3071 #[tokio::test]
3073 async fn merge_commit_without_message_uses_no_edit() {
3074 let rec = RecordingRunner::replying(Reply::ok(""));
3075 let git = Git::with_runner(&rec);
3076 git.merge_commit(Path::new("/r"), MergeCommit::branch("feature"))
3077 .await
3078 .unwrap();
3079 assert_eq!(
3080 rec.only_call().args_str(),
3081 ["merge", "--no-edit", "feature"]
3082 );
3083 }
3084
3085 #[tokio::test]
3087 async fn rebase_suppresses_editor() {
3088 let rec = RecordingRunner::replying(Reply::ok(""));
3089 let git = Git::with_runner(&rec);
3090 git.rebase(Path::new("/r"), "main").await.unwrap();
3091 let call = rec.only_call();
3092 assert_eq!(call.args_str(), ["rebase", "main"]);
3093 assert!(call.envs.iter().any(|(k, v)| {
3094 k.to_str() == Some("GIT_EDITOR")
3095 && v.as_deref().and_then(|o| o.to_str()) == Some("true")
3096 }));
3097 }
3098
3099 #[tokio::test]
3100 async fn push_builds_set_upstream_remote_refspec() {
3101 let rec = RecordingRunner::replying(Reply::ok(""));
3102 let git = Git::with_runner(&rec);
3103 git.push(
3104 Path::new("/r"),
3105 GitPush::refspec("feat", "feature").set_upstream(),
3106 )
3107 .await
3108 .unwrap();
3109 assert_eq!(
3110 rec.only_call().args_str(),
3111 ["push", "-u", "origin", "feat:feature"]
3112 );
3113 }
3114
3115 #[tokio::test]
3118 async fn push_bare_branch_builds_origin_branch_prompt_off() {
3119 let rec = RecordingRunner::replying(Reply::ok(""));
3120 let git = Git::with_runner(&rec);
3121 git.push(Path::new("/r"), GitPush::branch("feature"))
3122 .await
3123 .unwrap();
3124 let call = rec.only_call();
3125 assert_eq!(call.args_str(), ["push", "origin", "feature"]);
3126 assert!(call.envs.iter().any(|(k, v)| {
3127 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3128 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3129 }));
3130 }
3131
3132 #[tokio::test]
3135 async fn push_rejects_force_and_multiref_metacharacters() {
3136 let rec = RecordingRunner::replying(Reply::ok(""));
3137 let git = Git::with_runner(&rec);
3138 for bad in ["+main", "+main:main", "a:b:c"] {
3139 assert!(
3140 git.push(Path::new("/r"), GitPush::branch(bad))
3141 .await
3142 .is_err(),
3143 "{bad:?} must be rejected"
3144 );
3145 }
3146 assert!(
3148 git.push(Path::new("/r"), GitPush::refspec("main", "prod"))
3149 .await
3150 .is_ok()
3151 );
3152 assert!(
3153 rec.calls()
3154 .iter()
3155 .all(|c| c.args_str().last().unwrap() != "+main")
3156 );
3157 }
3158
3159 #[tokio::test]
3161 async fn push_remote_override_swaps_remote() {
3162 let rec = RecordingRunner::replying(Reply::ok(""));
3163 let git = Git::with_runner(&rec);
3164 git.push(
3165 Path::new("/r"),
3166 GitPush::branch("feature").remote("upstream"),
3167 )
3168 .await
3169 .unwrap();
3170 assert_eq!(rec.only_call().args_str(), ["push", "upstream", "feature"]);
3171 }
3172
3173 #[tokio::test]
3177 async fn with_credentials_injects_helper_and_secret_env_for_remote_ops() {
3178 let rec = RecordingRunner::replying(Reply::ok(""));
3179 let git = Git::with_runner(&rec)
3180 .with_credentials(Arc::new(StaticCredential::token("ghp_secret123")));
3181 git.push(Path::new("/r"), GitPush::branch("feature"))
3182 .await
3183 .unwrap();
3184 let call = rec.only_call();
3185 let args = call.args_str();
3186 assert_eq!(args[0], "-c", "config flag leads the argv");
3188 assert!(
3189 args.iter().any(|a| a == "credential.helper="),
3190 "inherited helpers are cleared first: {args:?}"
3191 );
3192 assert!(
3193 args.iter()
3194 .any(|a| a.contains("credential.helper=!f()")
3195 && a.contains("VCS_TOOLKIT_GIT_PASSWORD")),
3196 "inline helper references the secret by env-var name: {args:?}"
3197 );
3198 assert!(
3199 args.contains(&"push".to_string()) && args.contains(&"feature".to_string()),
3200 "the real subcommand still runs: {args:?}"
3201 );
3202 assert!(
3204 !args.iter().any(|a| a.contains("ghp_secret123")),
3205 "secret leaked into argv: {args:?}"
3206 );
3207 let pw = call
3209 .envs
3210 .iter()
3211 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3212 .and_then(|(_, v)| v.as_ref())
3213 .and_then(|v| v.to_str());
3214 assert_eq!(pw, Some("ghp_secret123"), "secret carried in env");
3215 }
3216
3217 #[tokio::test]
3220 async fn default_client_injects_no_credential_helper() {
3221 let rec = RecordingRunner::replying(Reply::ok(""));
3222 let git = Git::with_runner(&rec);
3223 git.push(Path::new("/r"), GitPush::branch("feature"))
3224 .await
3225 .unwrap();
3226 let call = rec.only_call();
3227 assert_eq!(
3228 call.args_str(),
3229 ["push", "origin", "feature"],
3230 "no credential `-c` args without a provider"
3231 );
3232 assert!(
3233 !call
3234 .envs
3235 .iter()
3236 .any(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD")),
3237 "no secret env without a provider"
3238 );
3239 }
3240
3241 #[tokio::test]
3245 async fn with_credentials_clone_puts_config_flags_before_subcommand() {
3246 let rec = RecordingRunner::replying(Reply::ok(""));
3247 let git =
3248 Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::token("s3cr3t")));
3249 git.clone_repo(
3250 "https://example.com/r.git",
3251 Path::new("/dest"),
3252 CloneSpec::default().branch("main"),
3253 )
3254 .await
3255 .unwrap();
3256 let call = rec.only_call();
3257 let args = call.args_str();
3258 assert_eq!(args[0], "-c", "config flags lead the clone argv");
3259 let clone_at = args
3260 .iter()
3261 .position(|a| a == "clone")
3262 .expect("clone present");
3263 assert!(
3265 args[..clone_at]
3266 .iter()
3267 .all(|a| a == "-c" || a.starts_with("credential.helper")),
3268 "only credential -c flags precede `clone`: {args:?}"
3269 );
3270 let tail = &args[clone_at..];
3272 assert!(tail.iter().any(|a| a == "--branch") && tail.iter().any(|a| a == "main"));
3273 assert!(tail.iter().any(|a| a == "https://example.com/r.git"));
3274 assert!(
3275 !args.iter().any(|a| a.contains("s3cr3t")),
3276 "secret not in argv"
3277 );
3278 let host = call
3281 .envs
3282 .iter()
3283 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_HOST"))
3284 .and_then(|(_, v)| v.as_ref())
3285 .and_then(|v| v.to_str());
3286 assert_eq!(
3287 host,
3288 Some("example.com"),
3289 "the clone URL's host scopes the credential helper"
3290 );
3291 assert!(
3294 args[..clone_at].iter().all(|a| !a.contains("example.com")),
3295 "host stays in env, not the credential config args: {:?}",
3296 &args[..clone_at]
3297 );
3298 }
3299
3300 #[tokio::test]
3303 async fn with_credentials_userpass_threads_username_through_env() {
3304 let rec = RecordingRunner::replying(Reply::ok(""));
3305 let git = Git::with_runner(&rec).with_credentials(Arc::new(StaticCredential::new(
3306 Credential::userpass("alice", "s3cr3t"),
3307 )));
3308 git.fetch(Path::new("/r")).await.unwrap();
3309 let call = rec.only_call();
3310 let user = call
3311 .envs
3312 .iter()
3313 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_USERNAME"))
3314 .and_then(|(_, v)| v.as_ref())
3315 .and_then(|v| v.to_str());
3316 assert_eq!(user, Some("alice"), "userpass username reaches the env");
3317 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads fetch too");
3318 assert!(call.args_str().contains(&"fetch".to_string()));
3319 }
3320
3321 #[tokio::test]
3324 async fn default_client_no_helper_on_fetch_and_clone() {
3325 let rec = RecordingRunner::replying(Reply::ok(""));
3326 Git::with_runner(&rec).fetch(Path::new("/r")).await.unwrap();
3327 assert_eq!(
3328 rec.only_call().args_str(),
3329 ["fetch", "--quiet"],
3330 "fetch unchanged without a provider"
3331 );
3332
3333 let rec = RecordingRunner::replying(Reply::ok(""));
3334 Git::with_runner(&rec)
3335 .clone_repo(
3336 "https://example.com/r.git",
3337 Path::new("/dest"),
3338 CloneSpec::default(),
3339 )
3340 .await
3341 .unwrap();
3342 assert_eq!(
3343 rec.only_call().args_str()[0],
3344 "clone",
3345 "clone leads with the subcommand (no `-c`) without a provider"
3346 );
3347 }
3348
3349 #[tokio::test]
3352 async fn with_token_convenience_authenticates_https_remote() {
3353 let rec = RecordingRunner::replying(Reply::ok(""));
3354 let git = Git::with_runner(&rec).with_token("ghp_conv");
3355 git.fetch(Path::new("/r")).await.unwrap();
3356 let call = rec.only_call();
3357 assert_eq!(call.args_str()[0], "-c", "helper `-c` leads");
3358 let pw = call
3359 .envs
3360 .iter()
3361 .find(|(k, _)| k.to_str() == Some("VCS_TOOLKIT_GIT_PASSWORD"))
3362 .and_then(|(_, v)| v.as_ref())
3363 .and_then(|v| v.to_str());
3364 assert_eq!(pw, Some("ghp_conv"), "secret carried in env");
3365 assert!(
3366 !call.args_str().iter().any(|a| a.contains("ghp_conv")),
3367 "secret not in argv"
3368 );
3369 }
3370
3371 #[tokio::test]
3372 async fn upstream_maps_unset_to_none() {
3373 let set = Git::with_runner(
3374 ScriptedRunner::new().on(["git", "rev-parse"], Reply::ok("origin/main\n")),
3375 );
3376 assert_eq!(
3377 set.upstream(Path::new(".")).await.unwrap().as_deref(),
3378 Some("origin/main")
3379 );
3380 let unset =
3383 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::fail(128, "")));
3384 assert!(unset.upstream(Path::new(".")).await.unwrap().is_none());
3385 let timed_out =
3388 Git::with_runner(ScriptedRunner::new().on(["git", "rev-parse"], Reply::timeout()));
3389 assert!(timed_out.upstream(Path::new(".")).await.is_err());
3390 }
3391
3392 #[tokio::test]
3396 async fn remote_head_branch_maps_exit_codes() {
3397 let set = Git::with_runner(ScriptedRunner::new().on(
3398 ["git", "symbolic-ref"],
3399 Reply::ok("refs/remotes/origin/release/v2\n"),
3400 ));
3401 assert_eq!(
3402 set.remote_head_branch(Path::new("."))
3403 .await
3404 .unwrap()
3405 .as_deref(),
3406 Some("release/v2"),
3407 "the full ref prefix is stripped, slashes preserved"
3408 );
3409 let unset =
3410 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
3411 assert!(
3412 unset
3413 .remote_head_branch(Path::new("."))
3414 .await
3415 .unwrap()
3416 .is_none()
3417 );
3418 let err = Git::with_runner(ScriptedRunner::new().on(
3420 ["git", "symbolic-ref"],
3421 Reply::fail(128, "fatal: not a git repository"),
3422 ));
3423 assert!(err.remote_head_branch(Path::new(".")).await.is_err());
3424 let timed_out =
3426 Git::with_runner(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::timeout()));
3427 assert!(timed_out.remote_head_branch(Path::new(".")).await.is_err());
3428 }
3429
3430 #[tokio::test]
3431 async fn set_upstream_builds_branch_flag() {
3432 let rec = RecordingRunner::replying(Reply::ok(""));
3433 let git = Git::with_runner(&rec);
3434 git.set_upstream(Path::new("/r"), "feat", "origin/feature")
3435 .await
3436 .unwrap();
3437 assert_eq!(
3438 rec.only_call().args_str(),
3439 ["branch", "--set-upstream-to=origin/feature", "feat"]
3440 );
3441 }
3442
3443 #[tokio::test]
3444 async fn remote_branches_parses_ls_remote() {
3445 let git = Git::with_runner(ScriptedRunner::new().on(
3446 ["git", "ls-remote"],
3447 Reply::ok("aaa\trefs/heads/main\nbbb\trefs/heads/feat/x\n"),
3448 ));
3449 let branches = git.remote_branches(Path::new("."), "origin").await.unwrap();
3450 assert_eq!(branches, ["main", "feat/x"]);
3451 }
3452
3453 #[tokio::test]
3454 async fn delete_branch_force_uses_capital_d() {
3455 let rec = RecordingRunner::replying(Reply::ok(""));
3456 let git = Git::with_runner(&rec);
3457 git.delete_branch(Path::new("/r"), "old", true)
3458 .await
3459 .unwrap();
3460 assert_eq!(rec.only_call().args_str(), ["branch", "-D", "old"]);
3461 }
3462
3463 #[tokio::test]
3466 async fn is_merged_strips_branch_markers() {
3467 let git = Git::with_runner(ScriptedRunner::new().on(
3468 ["git", "branch", "--merged"],
3469 Reply::ok(" main\n* feature\n+ wt-branch\n"),
3470 ));
3471 for name in ["main", "feature", "wt-branch"] {
3472 assert!(
3473 git.is_merged(Path::new("."), name, "main").await.unwrap(),
3474 "{name} should be reported merged"
3475 );
3476 }
3477 assert!(
3478 !git.is_merged(Path::new("."), "absent", "main")
3479 .await
3480 .unwrap()
3481 );
3482 }
3483
3484 #[tokio::test]
3487 async fn fetch_disables_terminal_prompt() {
3488 let rec = RecordingRunner::replying(Reply::ok(""));
3489 let git = Git::with_runner(&rec);
3490 git.fetch(Path::new("/r")).await.unwrap();
3491 let call = rec.only_call();
3492 assert_eq!(call.args_str(), ["fetch", "--quiet"]);
3493 assert!(call.envs.iter().any(|(k, v)| {
3494 k.to_str() == Some("GIT_TERMINAL_PROMPT")
3495 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
3496 }));
3497 }
3498
3499 #[tokio::test]
3501 async fn fetch_retries_transient_failures() {
3502 let rec = RecordingRunner::replying(Reply::fail(
3503 128,
3504 "fatal: unable to access: Could not resolve host: example.com",
3505 ));
3506 let git = Git::with_runner(&rec);
3507 assert!(git.fetch(Path::new("/r")).await.is_err());
3508 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
3509 }
3510
3511 #[tokio::test]
3525 async fn with_retry_retries_lock_contention_on_a_mutation() {
3526 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3527 ["git", "commit"],
3528 [
3529 Reply::fail(
3530 128,
3531 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3532 ),
3533 Reply::ok(""),
3534 ],
3535 ));
3536 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3537 git.commit(Path::new("/r"), "msg")
3538 .await
3539 .expect("retried past the lock");
3540 assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
3541 }
3542
3543 #[tokio::test]
3545 async fn default_client_does_not_retry_lock_contention() {
3546 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3547 ["git", "commit"],
3548 [
3549 Reply::fail(
3550 128,
3551 "fatal: Unable to create '/r/.git/index.lock': File exists.",
3552 ),
3553 Reply::ok(""),
3554 ],
3555 ));
3556 let git = Git::with_runner(&rec);
3557 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3558 assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
3559 }
3560
3561 #[tokio::test]
3564 async fn with_retry_does_not_retry_a_real_failure() {
3565 let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3566 ["git", "commit"],
3567 [
3568 Reply::fail(1, "error: pathspec 'x' did not match"),
3569 Reply::ok(""),
3570 ],
3571 ));
3572 let git = Git::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3573 assert!(git.commit(Path::new("/r"), "msg").await.is_err());
3574 assert_eq!(rec.calls().len(), 1, "a non-lock failure is not retried");
3575 }
3576
3577 #[tokio::test]
3579 async fn fetch_does_not_retry_permanent_failures() {
3580 let rec = RecordingRunner::replying(Reply::fail(1, "fatal: couldn't find remote ref"));
3581 let git = Git::with_runner(&rec);
3582 assert!(git.fetch(Path::new("/r")).await.is_err());
3583 assert_eq!(rec.calls().len(), 1);
3584 }
3585
3586 #[tokio::test(start_paused = true)]
3593 async fn fetch_cancels_and_does_not_retry() {
3594 use processkit::CancellationToken;
3595 let token = CancellationToken::new();
3596 let rec =
3597 RecordingRunner::new(ScriptedRunner::new().on(["git", "fetch"], Reply::pending()));
3598 let git = Git::with_runner(&rec).default_cancel_on(token.clone());
3599 let call = git.fetch(Path::new("/r"));
3600 tokio::pin!(call);
3601 assert!(
3602 tokio::time::timeout(std::time::Duration::from_secs(3600), &mut call)
3603 .await
3604 .is_err(),
3605 "fetch must park until the token fires"
3606 );
3607 token.cancel();
3608 assert!(matches!(call.await.unwrap_err(), Error::Cancelled { .. }));
3609 assert_eq!(
3610 rec.calls().len(),
3611 1,
3612 "cancellation is terminal — the fetch-retry must not replay it"
3613 );
3614 }
3615
3616 #[tokio::test]
3619 async fn flag_like_positionals_are_rejected_before_spawning() {
3620 let rec = RecordingRunner::replying(Reply::ok(""));
3621 let git = Git::with_runner(&rec);
3622 let dir = Path::new("/r");
3623
3624 assert!(git.checkout(dir, "-evil").await.is_err());
3625 assert!(git.create_branch(dir, "--force").await.is_err());
3626 assert!(git.delete_branch(dir, "-D", false).await.is_err());
3627 assert!(git.rename_branch(dir, "ok", "-bad").await.is_err());
3628 assert!(
3629 git.merge_commit(dir, MergeCommit::branch("-evil"))
3630 .await
3631 .is_err()
3632 );
3633 assert!(
3634 git.merge_no_commit(dir, MergeNoCommit::branch("-evil").no_ff())
3635 .await
3636 .is_err()
3637 );
3638 assert!(git.merge_squash(dir, "-evil").await.is_err());
3639 assert!(git.rebase(dir, "-i").await.is_err());
3640 assert!(git.cherry_pick(dir, "-n").await.is_err());
3641 assert!(git.revert(dir, "-evil").await.is_err());
3642 assert!(git.tag_create(dir, "-d", None).await.is_err());
3643 assert!(
3644 git.tag_create(dir, "ok", Some("-evil".into()))
3645 .await
3646 .is_err()
3647 );
3648 assert!(git.tag_delete(dir, "-evil").await.is_err());
3649 assert!(git.remote_add(dir, "-evil", "url").await.is_err());
3650 assert!(git.remote_set_url(dir, "-evil", "url").await.is_err());
3651 assert!(git.set_upstream(dir, "-evil", "origin/x").await.is_err());
3652 assert!(git.log(dir, "-evil", 5).await.is_err());
3653 assert!(git.rev_list_count(dir, "-evil").await.is_err());
3654 assert!(git.diff_stat(dir, "-evil").await.is_err());
3655 assert!(git.diff_range_is_empty(dir, "-evil").await.is_err());
3656 assert!(
3657 git.diff_text(dir, DiffSpec::Rev("-evil".into()))
3658 .await
3659 .is_err()
3660 );
3661 assert!(git.rev_parse(dir, "-evil").await.is_err());
3662 assert!(git.rev_parse_short(dir, "-evil").await.is_err());
3663 assert!(git.resolve_commit(dir, "-evil").await.is_err());
3664 assert!(git.reset_hard(dir, "-evil").await.is_err());
3665 assert!(git.checkout_detach(dir, "-evil").await.is_err());
3666 assert!(git.config_set(dir, "-evil", "v").await.is_err());
3667 assert!(
3668 git.push(dir, GitPush::branch("-evil")).await.is_err(),
3669 "refspec guard"
3670 );
3671 assert!(git.show_file(dir, "-evil", "f.txt").await.is_err());
3673 assert!(git.blame(dir, "f.txt", Some("-s".into())).await.is_err());
3674 assert!(git.remote_url(dir, "-evil").await.is_err());
3675 assert!(git.remote_branches(dir, "-evil").await.is_err());
3676 assert!(git.fetch_from(dir, "--upload-pack=x").await.is_err());
3677 assert!(
3679 git.clone_repo("--upload-pack=x", Path::new("/d"), CloneSpec::new())
3680 .await
3681 .is_err()
3682 );
3683 assert!(git.remote_add(dir, "ok", "--upload-pack=x").await.is_err());
3684 assert!(git.remote_set_url(dir, "ok", "-evil").await.is_err());
3685 assert!(git.is_merged(dir, "-evil", "main").await.is_err());
3686 assert!(git.config_get(dir, "-evil").await.is_err());
3687 assert!(
3688 git.worktree_add(
3689 dir,
3690 WorktreeAdd::create_branch(Path::new("/wt"), "-evil", "HEAD")
3691 )
3692 .await
3693 .is_err()
3694 );
3695 assert!(git.checkout(dir, "").await.is_err());
3697
3698 assert!(
3699 rec.calls().is_empty(),
3700 "nothing may spawn: {:?}",
3701 rec.calls()
3702 );
3703
3704 git.checkout(dir, "feature/x").await.expect("checkout");
3707 assert_eq!(rec.only_call().args_str(), ["checkout", "feature/x", "--"]);
3708 }
3709
3710 #[tokio::test]
3713 async fn harden_applies_env_profile_to_every_command() {
3714 let rec = RecordingRunner::replying(Reply::ok(""));
3715 let git = Git::with_runner(&rec).harden();
3716 git.status(Path::new("/r")).await.expect("status");
3717 git.fetch(Path::new("/r")).await.expect("fetch");
3718
3719 for call in rec.calls() {
3720 let has = |k: &str, v: &str| {
3721 call.envs.iter().any(|(key, val)| {
3722 key.to_str() == Some(k) && val.as_deref().and_then(|o| o.to_str()) == Some(v)
3723 })
3724 };
3725 let removed = |k: &str| {
3726 call.envs
3727 .iter()
3728 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3729 };
3730 assert!(has("GIT_CONFIG_NOSYSTEM", "1"), "{:?}", call.args_str());
3731 assert!(has("GIT_CONFIG_COUNT", "3"));
3732 assert!(has("GIT_CONFIG_KEY_0", "core.hooksPath"));
3733 assert!(has("GIT_CONFIG_VALUE_0", "/dev/null"));
3734 assert!(has("GIT_CONFIG_KEY_1", "core.fsmonitor"));
3735 assert!(has("GIT_CONFIG_KEY_2", "core.sshCommand"));
3737 assert!(has("GIT_CONFIG_VALUE_2", ""));
3738 assert!(has("GIT_TERMINAL_PROMPT", "0"));
3739 assert!(removed("GIT_DIR"), "GIT_DIR scrubbed");
3740 assert!(removed("GIT_CONFIG_GLOBAL"), "global config scrubbed");
3741 assert!(removed("GIT_SSH_COMMAND"), "GIT_SSH_COMMAND scrubbed");
3743 assert!(removed("GIT_ASKPASS"), "GIT_ASKPASS scrubbed");
3744 assert!(removed("GIT_EXTERNAL_DIFF"), "GIT_EXTERNAL_DIFF scrubbed");
3745 assert!(removed("GIT_PAGER"), "GIT_PAGER scrubbed");
3746 assert!(removed("GIT_PROXY_COMMAND"), "GIT_PROXY_COMMAND scrubbed");
3748 assert!(removed("GIT_EXEC_PATH"), "GIT_EXEC_PATH scrubbed");
3749 assert!(removed("GIT_TEMPLATE_DIR"), "GIT_TEMPLATE_DIR scrubbed");
3750 assert!(
3751 removed("GIT_ICASE_PATHSPECS"),
3752 "GIT_ICASE_PATHSPECS scrubbed"
3753 );
3754 }
3755 }
3756
3757 #[tokio::test]
3763 async fn default_client_scrubs_repo_redirector_env() {
3764 let rec = RecordingRunner::replying(Reply::ok(""));
3765 let git = Git::with_runner(&rec); git.status(Path::new("/r")).await.expect("status");
3767 let call = rec.only_call();
3768 let removed = |k: &str| {
3769 call.envs
3770 .iter()
3771 .any(|(key, val)| key.to_str() == Some(k) && val.is_none())
3772 };
3773 let has_key = |k: &str| call.envs.iter().any(|(key, _)| key.to_str() == Some(k));
3774 for var in [
3775 "GIT_DIR",
3776 "GIT_WORK_TREE",
3777 "GIT_INDEX_FILE",
3778 "GIT_COMMON_DIR",
3779 "GIT_OBJECT_DIRECTORY",
3780 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
3781 "GIT_NAMESPACE",
3782 ] {
3783 assert!(removed(var), "{var} must be scrubbed on the default client");
3784 }
3785 assert!(
3787 !has_key("GIT_SSH_COMMAND"),
3788 "command-hook scrub is harden()-only"
3789 );
3790 assert!(
3791 !has_key("GIT_CONFIG_NOSYSTEM"),
3792 "config pins are harden()-only"
3793 );
3794 }
3795
3796 #[test]
3798 fn ref_name_and_rev_spec_validate() {
3799 for ok in ["main", "feature/x", "v1.2.3", "a-b_c"] {
3800 assert!(RefName::new(ok).is_ok(), "{ok}");
3801 }
3802 for bad in [
3803 "", "-evil", ".hidden", "a..b", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b",
3804 "a\\b", "end/", "x.lock",
3805 ] {
3806 assert!(RefName::new(bad).is_err(), "{bad:?} must be rejected");
3807 }
3808 assert!(RevSpec::new("HEAD~2").is_ok());
3809 assert!(RevSpec::new("main..feature").is_ok());
3810 assert!(RevSpec::new("-evil").is_err());
3811 assert!(RevSpec::new("").is_err());
3812 }
3813
3814 #[tokio::test]
3817 async fn capabilities_parse_and_gate_versions() {
3818 let gh = Git::with_runner(ScriptedRunner::new().on(
3819 ["git", "--version"],
3820 Reply::ok("git version 2.54.0.windows.1\n"),
3821 ));
3822 let caps = gh.capabilities().await.expect("capabilities");
3823 assert_eq!(caps.version.to_string(), "2.54.0");
3824 assert!(caps.is_supported());
3825 caps.ensure_supported().expect("supported");
3826
3827 let old = Git::with_runner(
3830 ScriptedRunner::new().on(["git", "--version"], Reply::ok("git version 1.9\n")),
3831 );
3832 let caps = old.capabilities().await.expect("capabilities");
3833 assert_eq!(
3834 caps.version,
3835 GitVersion {
3836 major: 1,
3837 minor: 9,
3838 patch: 0
3839 }
3840 );
3841 let err = caps.ensure_supported().expect_err("unsupported");
3842 let Error::Spawn { source, .. } = &err else {
3844 panic!("expected Spawn, got {err:?}");
3845 };
3846 let message = source.to_string();
3847 assert!(message.contains(">= 2"), "names the floor: {message}");
3848 assert!(
3849 message.contains("1.9.0"),
3850 "names the found version: {message}"
3851 );
3852
3853 let garbage = Git::with_runner(
3855 ScriptedRunner::new().on(["git", "--version"], Reply::ok("not a version")),
3856 );
3857 assert!(matches!(
3858 garbage.capabilities().await.unwrap_err(),
3859 Error::Parse { .. }
3860 ));
3861 }
3862
3863 #[tokio::test]
3865 async fn clone_repo_builds_flags_and_runs_dirless() {
3866 let rec = RecordingRunner::replying(Reply::ok(""));
3867 let git = Git::with_runner(&rec);
3868 git.clone_repo(
3869 "https://example.com/r.git",
3870 Path::new("/dest"),
3871 CloneSpec::new().branch("main").depth(1).bare(),
3872 )
3873 .await
3874 .expect("clone");
3875 let call = rec.only_call();
3876 assert_eq!(
3877 call.args_str(),
3878 [
3879 "clone",
3880 "--branch",
3881 "main",
3882 "--depth",
3883 "1",
3884 "--bare",
3885 "https://example.com/r.git",
3886 "/dest"
3887 ]
3888 );
3889 assert_eq!(call.cwd, None, "clone runs without a working directory");
3890
3891 let bare = RecordingRunner::replying(Reply::ok(""));
3892 let git = Git::with_runner(&bare);
3893 git.clone_repo("u", Path::new("/d"), CloneSpec::new())
3894 .await
3895 .expect("clone");
3896 assert_eq!(bare.only_call().args_str(), ["clone", "u", "/d"]);
3897 }
3898
3899 #[tokio::test]
3905 async fn clone_failure_cleans_only_a_dest_it_could_have_created() {
3906 use vcs_testkit::TempDir;
3907 let tmp = TempDir::new("r7-clone");
3908 let git = Git::with_runner(ScriptedRunner::new().on(
3909 ["git", "clone"],
3910 Reply::fail(
3911 128,
3912 "fatal: could not read Username for 'https://x': prompts disabled",
3913 ),
3914 ));
3915
3916 let occupied = tmp.path().join("occupied");
3918 std::fs::create_dir(&occupied).unwrap();
3919 std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
3920 assert!(
3921 git.clone_repo("https://x/r", &occupied, CloneSpec::new())
3922 .await
3923 .is_err()
3924 );
3925 assert!(
3926 occupied.join("keep.txt").exists(),
3927 "a non-empty caller dir must survive a failed clone"
3928 );
3929
3930 let empty = tmp.path().join("empty");
3932 std::fs::create_dir(&empty).unwrap();
3933 assert!(
3934 git.clone_repo("https://x/r", &empty, CloneSpec::new())
3935 .await
3936 .is_err()
3937 );
3938 assert!(
3939 !empty.exists(),
3940 "an empty dest is cleaned so a retry isn't blocked"
3941 );
3942
3943 let file_dest = tmp.path().join("a-file");
3947 std::fs::write(&file_dest, b"caller file").unwrap();
3948 assert!(
3949 git.clone_repo("https://x/r", &file_dest, CloneSpec::new())
3950 .await
3951 .is_err()
3952 );
3953 assert!(
3954 file_dest.exists() && std::fs::read(&file_dest).unwrap() == b"caller file",
3955 "a caller's file at dest must survive a failed clone"
3956 );
3957
3958 #[cfg(unix)]
3963 {
3964 let target = tmp.path().join("link-target"); std::fs::create_dir(&target).unwrap();
3966 let sentinel = tmp.path().join("sibling.txt");
3967 std::fs::write(&sentinel, b"untouched").unwrap();
3968 let link = tmp.path().join("a-symlink");
3969 std::os::unix::fs::symlink(&target, &link).unwrap();
3970 assert!(
3971 git.clone_repo("https://x/r", &link, CloneSpec::new())
3972 .await
3973 .is_err()
3974 );
3975 assert!(
3976 target.exists() && sentinel.exists(),
3977 "a failed clone must unlink at most the symlink, never delete through it"
3978 );
3979 }
3980 }
3981
3982 #[tokio::test]
3983 async fn tag_methods_build_args() {
3984 let rec = RecordingRunner::replying(Reply::ok(""));
3985 let git = Git::with_runner(&rec);
3986 git.tag_create(Path::new("/r"), "v1", None).await.unwrap();
3987 git.tag_create(Path::new("/r"), "v1", Some("abc".into()))
3988 .await
3989 .unwrap();
3990 git.tag_create_annotated(Path::new("/r"), AnnotatedTag::new("v2", "notes"))
3991 .await
3992 .unwrap();
3993 git.tag_delete(Path::new("/r"), "v1").await.unwrap();
3994 let calls = rec.calls();
3995 assert_eq!(calls[0].args_str(), ["tag", "v1"]);
3996 assert_eq!(calls[1].args_str(), ["tag", "v1", "abc"]);
3997 assert_eq!(calls[2].args_str(), ["tag", "-a", "v2", "-m", "notes"]);
3998 assert_eq!(calls[3].args_str(), ["tag", "-d", "v1"]);
3999 }
4000
4001 #[tokio::test]
4002 async fn tag_list_splits_lines() {
4003 let git = Git::with_runner(
4004 ScriptedRunner::new().on(["git", "tag", "--list"], Reply::ok("v1\nv2.0\n")),
4005 );
4006 assert_eq!(git.tag_list(Path::new(".")).await.unwrap(), ["v1", "v2.0"]);
4007 }
4008
4009 #[tokio::test]
4015 async fn list_commands_disable_column_and_color() {
4016 let rec = RecordingRunner::replying(Reply::ok(""));
4017 let git = Git::with_runner(&rec);
4018 git.branches(Path::new(".")).await.unwrap();
4019 git.is_merged(Path::new("."), "b", "main").await.unwrap();
4020 git.tag_list(Path::new(".")).await.unwrap();
4021 let calls = rec.calls();
4022 assert_eq!(calls[0].args_str(), ["branch", "--no-column", "--no-color"]);
4023 assert_eq!(
4024 calls[1].args_str(),
4025 ["branch", "--merged", "main", "--no-column", "--no-color"]
4026 );
4027 assert_eq!(calls[2].args_str(), ["tag", "--list", "--no-column"]);
4028 }
4029
4030 #[tokio::test]
4033 async fn classified_commands_force_c_locale() {
4034 let rec = RecordingRunner::replying(Reply::ok(""));
4035 let git = Git::with_runner(&rec);
4036 git.commit(Path::new("."), "msg").await.unwrap();
4037 git.merge_commit(Path::new("."), MergeCommit::branch("b"))
4038 .await
4039 .unwrap();
4040 git.merge_squash(Path::new("."), "b").await.unwrap();
4041 git.merge_no_commit(Path::new("."), MergeNoCommit::branch("b"))
4042 .await
4043 .unwrap();
4044 git.cherry_pick(Path::new("."), "abc").await.unwrap();
4045 git.stash_pop(Path::new(".")).await.unwrap();
4046 git.fetch(Path::new(".")).await.unwrap();
4047 for call in rec.calls() {
4048 assert!(
4049 call.envs.iter().any(|(k, v)| {
4050 k.to_str() == Some("LC_ALL")
4051 && v.as_deref().and_then(|o| o.to_str()) == Some("C")
4052 }),
4053 "{:?} should force LC_ALL=C",
4054 call.args_str()
4055 );
4056 }
4057 }
4058
4059 #[cfg(windows)]
4062 #[tokio::test]
4063 async fn show_file_normalises_path_separators() {
4064 let rec = RecordingRunner::replying(Reply::ok("content\n"));
4065 let git = Git::with_runner(&rec);
4066 let out = git
4067 .show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4068 .await
4069 .expect("show_file");
4070 assert_eq!(out, "content\n");
4072 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub/dir/f.txt"]);
4073 }
4074
4075 #[tokio::test]
4078 async fn content_verbs_preserve_exact_trailing_bytes() {
4079 for raw in ["a\nb\n\n", "no-final-newline", "trailing spaces \n"] {
4080 let rec = RecordingRunner::replying(Reply::ok(raw));
4081 let git = Git::with_runner(&rec);
4082 let out = git
4083 .show_file(Path::new("/r"), "HEAD", "f.txt")
4084 .await
4085 .expect("show_file");
4086 assert_eq!(out, raw, "show_file returns bytes verbatim");
4087 }
4088 let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
4091 let rec = RecordingRunner::replying(Reply::ok(diff));
4092 let git = Git::with_runner(&rec);
4093 assert_eq!(
4094 git.diff_text(Path::new("/r"), DiffSpec::Rev("HEAD".into()))
4095 .await
4096 .expect("diff_text"),
4097 diff
4098 );
4099 }
4100
4101 #[cfg(not(windows))]
4104 #[tokio::test]
4105 async fn show_file_keeps_backslashes_on_unix() {
4106 let rec = RecordingRunner::replying(Reply::ok("content\n"));
4107 let git = Git::with_runner(&rec);
4108 git.show_file(Path::new("/r"), "HEAD", "sub\\dir\\f.txt")
4109 .await
4110 .expect("show_file");
4111 assert_eq!(rec.only_call().args_str(), ["show", "HEAD:sub\\dir\\f.txt"]);
4112 }
4113
4114 #[tokio::test]
4116 async fn config_get_maps_exit_codes() {
4117 let set = Git::with_runner(
4118 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("Alice\n")),
4119 );
4120 assert_eq!(
4121 set.config_get(Path::new("."), "user.name").await.unwrap(),
4122 Some("Alice".to_string())
4123 );
4124 let spaced = Git::with_runner(
4127 ScriptedRunner::new().on(["git", "config", "--get"], Reply::ok("prefix: \r\n")),
4128 );
4129 assert_eq!(
4130 spaced.config_get(Path::new("."), "x.y").await.unwrap(),
4131 Some("prefix: ".to_string())
4132 );
4133 let unset = Git::with_runner(
4134 ScriptedRunner::new().on(["git", "config", "--get"], Reply::fail(1, "")),
4135 );
4136 assert_eq!(
4137 unset.config_get(Path::new("."), "user.name").await.unwrap(),
4138 None
4139 );
4140 let multi = Git::with_runner(ScriptedRunner::new().on(
4142 ["git", "config", "--get"],
4143 Reply::fail(2, "multiple values"),
4144 ));
4145 assert!(
4146 multi
4147 .config_get(Path::new("."), "remote.all")
4148 .await
4149 .is_err()
4150 );
4151 }
4152
4153 #[tokio::test]
4154 async fn blame_builds_rev_before_pathspec_separator() {
4155 let rec = RecordingRunner::replying(Reply::ok(""));
4156 let git = Git::with_runner(&rec);
4157 git.blame(Path::new("/r"), "src/lib.rs", Some("HEAD~1".into()))
4158 .await
4159 .unwrap();
4160 git.blame(Path::new("/r"), "src/lib.rs", None)
4161 .await
4162 .unwrap();
4163 let calls = rec.calls();
4164 assert_eq!(
4165 calls[0].args_str(),
4166 ["blame", "--line-porcelain", "HEAD~1", "--", "src/lib.rs"]
4167 );
4168 assert_eq!(
4169 calls[1].args_str(),
4170 ["blame", "--line-porcelain", "--", "src/lib.rs"]
4171 );
4172 }
4173
4174 #[tokio::test]
4176 async fn sequencer_methods_suppress_editors() {
4177 let rec = RecordingRunner::replying(Reply::ok(""));
4178 let git = Git::with_runner(&rec);
4179 git.revert(Path::new("/r"), "abc").await.unwrap();
4180 git.cherry_pick(Path::new("/r"), "abc").await.unwrap();
4181 git.rebase_skip(Path::new("/r")).await.unwrap();
4182 let calls = rec.calls();
4183 assert_eq!(calls[0].args_str(), ["revert", "--no-edit", "abc"]);
4184 assert_eq!(calls[1].args_str(), ["cherry-pick", "abc"]);
4185 assert_eq!(calls[2].args_str(), ["rebase", "--skip"]);
4186 for call in &calls {
4187 assert!(
4188 call.envs
4189 .iter()
4190 .any(|(k, _)| k.to_str() == Some("GIT_EDITOR")),
4191 "editor suppressed on {:?}",
4192 call.args_str()
4193 );
4194 }
4195 }
4196
4197 #[tokio::test]
4206 async fn hardened_sequencer_keeps_its_no_op_editor() {
4207 let rec = RecordingRunner::replying(Reply::ok(""));
4208 let git = Git::with_runner(&rec).harden();
4209 git.revert(Path::new("/r"), "abc").await.unwrap();
4210 let call = rec.only_call();
4211 let effective = |var: &str| {
4213 call.envs
4214 .iter()
4215 .rfind(|(k, _)| k.to_str() == Some(var))
4216 .and_then(|(_, v)| v.as_deref())
4217 .and_then(|v| v.to_str())
4218 };
4219 assert_eq!(
4222 effective("GIT_EDITOR"),
4223 Some("true"),
4224 "the per-command no-op editor must survive harden()'s scrub"
4225 );
4226 assert_eq!(
4227 effective("GIT_SEQUENCE_EDITOR"),
4228 Some("true"),
4229 "the per-command no-op sequence editor must survive harden()'s scrub"
4230 );
4231 }
4232
4233 #[tokio::test]
4234 async fn remote_add_and_set_url_build_args() {
4235 let rec = RecordingRunner::replying(Reply::ok(""));
4236 let git = Git::with_runner(&rec);
4237 git.remote_add(Path::new("/r"), "up", "https://x/y.git")
4238 .await
4239 .unwrap();
4240 git.remote_set_url(Path::new("/r"), "up", "https://x/z.git")
4241 .await
4242 .unwrap();
4243 let calls = rec.calls();
4244 assert_eq!(
4245 calls[0].args_str(),
4246 ["remote", "add", "up", "https://x/y.git"]
4247 );
4248 assert_eq!(
4249 calls[1].args_str(),
4250 ["remote", "set-url", "up", "https://x/z.git"]
4251 );
4252 }
4253
4254 #[tokio::test]
4257 async fn switch_with_stash_round_trips_dirty_tree() {
4258 let rec = RecordingRunner::new(
4259 ScriptedRunner::new()
4260 .on(["git", "status"], Reply::ok(" M a.rs\0"))
4261 .on_sequence(
4263 ["git", "stash", "list"],
4264 [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4265 )
4266 .on(["git", "stash", "push"], Reply::ok(""))
4267 .on(["git", "checkout"], Reply::ok(""))
4268 .on(["git", "stash", "pop"], Reply::ok("")),
4269 );
4270 let git = Git::with_runner(&rec);
4271 git.switch_with_stash(Path::new("/r"), "feature")
4272 .await
4273 .expect("switch");
4274 let calls = rec.calls();
4275 assert_eq!(calls.len(), 6);
4276 assert_eq!(
4277 calls[2].args_str(),
4278 ["stash", "push", "--include-untracked"]
4279 );
4280 assert_eq!(calls[4].args_str(), ["checkout", "feature", "--"]);
4281 assert_eq!(calls[5].args_str(), ["stash", "pop", "--index"]);
4283 }
4284
4285 #[tokio::test]
4289 async fn switch_with_stash_does_not_pop_when_push_saved_nothing() {
4290 let rec = RecordingRunner::new(
4291 ScriptedRunner::new()
4292 .on(["git", "status"], Reply::ok(" M sub\0"))
4293 .on(
4295 ["git", "stash", "list"],
4296 Reply::ok("stash@{0}: someone else's WIP\n"),
4297 )
4298 .on(
4299 ["git", "stash", "push"],
4300 Reply::ok("No local changes to save\n"),
4301 )
4302 .on(["git", "checkout"], Reply::ok("")),
4303 );
4304 let git = Git::with_runner(&rec);
4305 git.switch_with_stash(Path::new("/r"), "feature")
4306 .await
4307 .expect("switch");
4308 assert!(
4309 rec.calls()
4310 .iter()
4311 .all(|c| c.args_str() != ["stash", "pop", "--index"]
4312 && c.args_str() != ["stash", "pop"]),
4313 "must not pop an unrelated stash when the push saved nothing"
4314 );
4315 }
4316
4317 #[tokio::test]
4320 async fn switch_with_stash_skips_stash_on_clean_tree() {
4321 let rec = RecordingRunner::new(
4322 ScriptedRunner::new()
4323 .on(["git", "status"], Reply::ok(""))
4324 .on(["git", "checkout"], Reply::ok("")),
4325 );
4326 let git = Git::with_runner(&rec);
4327 git.switch_with_stash(Path::new("/r"), "feature")
4328 .await
4329 .expect("switch");
4330 let calls = rec.calls();
4331 assert_eq!(calls.len(), 2);
4332 assert!(calls.iter().all(|c| c.args_str()[0] != "stash"));
4333 }
4334
4335 #[tokio::test]
4338 async fn switch_with_stash_restores_on_checkout_failure() {
4339 let rec = RecordingRunner::new(
4340 ScriptedRunner::new()
4341 .on(["git", "status"], Reply::ok(" M a.rs\0"))
4342 .on_sequence(
4343 ["git", "stash", "list"],
4344 [Reply::ok(""), Reply::ok("stash@{0}: WIP on main\n")],
4345 )
4346 .on(["git", "stash", "push"], Reply::ok(""))
4347 .on(
4348 ["git", "checkout"],
4349 Reply::fail(1, "error: pathspec 'nope'"),
4350 )
4351 .on(["git", "stash", "pop"], Reply::ok("")),
4352 );
4353 let git = Git::with_runner(&rec);
4354 let err = git
4355 .switch_with_stash(Path::new("/r"), "nope")
4356 .await
4357 .expect_err("checkout error must surface");
4358 assert!(matches!(err, Error::Exit { .. }));
4359 let calls = rec.calls();
4360 assert_eq!(
4361 calls.last().unwrap().args_str(),
4362 ["stash", "pop", "--index"],
4363 "restoring pop ran with --index"
4364 );
4365 }
4366
4367 #[tokio::test]
4370 async fn fetch_from_builds_args_and_retries() {
4371 let rec = RecordingRunner::replying(Reply::ok(""));
4372 let git = Git::with_runner(&rec);
4373 git.fetch_from(Path::new("/r"), "upstream")
4374 .await
4375 .expect("fetch_from");
4376 let call = rec.only_call();
4377 assert_eq!(call.args_str(), ["fetch", "--quiet", "upstream"]);
4378 assert!(call.envs.iter().any(|(k, v)| {
4379 k.to_str() == Some("GIT_TERMINAL_PROMPT")
4380 && v.as_deref().and_then(|o| o.to_str()) == Some("0")
4381 }));
4382
4383 let failing = RecordingRunner::replying(Reply::fail(128, "fatal: Connection timed out"));
4384 let git = Git::with_runner(&failing);
4385 assert!(git.fetch_from(Path::new("/r"), "upstream").await.is_err());
4386 assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
4387 }
4388
4389 #[cfg(feature = "mock")]
4392 #[tokio::test]
4393 async fn consumer_mocks_the_interface() {
4394 async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
4395 git.current_branch(Path::new(".")).await.unwrap().as_deref() == Some(want)
4396 }
4397 let mut mock = MockGitApi::new();
4398 mock.expect_current_branch()
4399 .returning(|_| Ok(Some("main".to_string())));
4400 assert!(on_branch(&mock, "main").await);
4401 }
4402}
4403
4404#[doc = include_str!("../docs/git.md")]
4406#[allow(rustdoc::broken_intra_doc_links)]
4407pub mod guide {
4408 #[doc = include_str!("../docs/security.md")]
4409 #[allow(rustdoc::broken_intra_doc_links)]
4410 pub mod security {}
4411 #[doc = include_str!("../docs/conflicts.md")]
4412 #[allow(rustdoc::broken_intra_doc_links)]
4413 pub mod conflicts {}
4414}