1use std::path::{Path, PathBuf};
5use std::str::{self};
6use std::sync::LazyLock;
7
8use anyhow::{Context, Result, anyhow};
9use cargo_util::{ProcessBuilder, ProcessError, paths};
10use owo_colors::OwoColorize;
11use tracing::{debug, instrument, warn};
12
13use uv_fs::Simplified;
14use uv_git_types::{GitOid, GitReference};
15use uv_redacted::SafeUrl;
16use uv_static::EnvVars;
17use uv_warnings::warn_user_once;
18
19const CHECKOUT_READY_EXTENSION: &str = "ok";
22
23#[derive(Debug, thiserror::Error)]
24pub enum GitError {
25 #[error("Git executable not found. Ensure that Git is installed and available.")]
26 GitNotFound,
27 #[error("Git LFS extension not found. Ensure that Git LFS is installed and available.")]
28 GitLfsNotFound,
29 #[error("Is Git LFS configured? Run `{}` to initialize Git LFS.", "git lfs install".green())]
30 GitLfsNotConfigured,
31 #[error(transparent)]
32 Other(#[from] which::Error),
33 #[error(
34 "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
35 )]
36 TransportNotAllowed,
37}
38
39pub static GIT: LazyLock<Result<ProcessBuilder, GitError>> = LazyLock::new(|| {
44 let path = which::which("git").map_err(|err| match err {
45 which::Error::CannotFindBinaryPath => GitError::GitNotFound,
46 err => GitError::Other(err),
47 })?;
48
49 let mut cmd = ProcessBuilder::new(path);
50
51 cmd.env_remove(EnvVars::GIT_DIR)
58 .env_remove(EnvVars::GIT_WORK_TREE)
59 .env_remove(EnvVars::GIT_INDEX_FILE)
60 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
61 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
62 .env_remove(EnvVars::GIT_COMMON_DIR);
63
64 Ok(cmd)
65});
66
67enum RefspecStrategy {
69 All,
71 First,
73}
74
75#[derive(PartialEq, Clone, Debug)]
77pub(crate) struct GitRemote {
78 url: SafeUrl,
80}
81
82pub(crate) struct GitDatabase {
85 remote: GitRemote,
87 repo: GitRepository,
89 lfs_ready: Option<bool>,
91}
92
93pub(crate) struct GitCheckout {
95 revision: GitOid,
97 repo: GitRepository,
99 lfs_ready: Option<bool>,
101}
102
103pub(crate) struct GitRepository {
105 path: PathBuf,
107}
108
109impl GitRepository {
110 fn open(path: &Path) -> Result<Self> {
112 GIT.as_ref()
114 .cloned()?
115 .arg("rev-parse")
116 .cwd(path)
117 .exec_with_output()?;
118
119 Ok(Self {
120 path: path.to_path_buf(),
121 })
122 }
123
124 fn init(path: &Path) -> Result<Self> {
126 GIT.as_ref()
134 .cloned()?
135 .arg("init")
136 .cwd(path)
137 .exec_with_output()?;
138
139 Ok(Self {
140 path: path.to_path_buf(),
141 })
142 }
143
144 fn resolve(&self, reference: &GitReference) -> Result<GitOid> {
146 let result = match reference {
147 GitReference::Tag(tag) => self.rev_parse(&format!("refs/remotes/origin/tags/{tag}^0")),
149 GitReference::Branch(branch) => self.rev_parse(&format!("origin/{branch}^0")),
150 GitReference::BranchOrTag(reference) => self
151 .rev_parse(&format!("origin/{reference}^0"))
152 .or_else(|_| self.rev_parse(&format!("refs/remotes/origin/tags/{reference}^0"))),
153 GitReference::BranchOrTagOrCommit(reference) => self
154 .rev_parse(&format!("origin/{reference}^0"))
155 .or_else(|_| self.rev_parse(&format!("refs/remotes/origin/tags/{reference}^0")))
156 .or_else(|_| self.rev_parse(&format!("{reference}^0"))),
157 GitReference::DefaultBranch => self.rev_parse("refs/remotes/origin/HEAD"),
158 GitReference::NamedRef(reference) => self.rev_parse(&format!("{reference}^0")),
159 GitReference::Commit(commit) => self.rev_parse(&format!("{commit}^0")),
160 };
161
162 result.with_context(|| format!("failed to find {} `{reference}`", reference.kind_str()))
163 }
164
165 fn rev_parse(&self, refname: &str) -> Result<GitOid> {
167 let result = GIT
168 .as_ref()
169 .cloned()?
170 .arg("rev-parse")
171 .arg(refname)
172 .cwd(&self.path)
173 .exec_with_output()?;
174
175 let mut result = String::from_utf8(result.stdout)?;
176 result.truncate(result.trim_end().len());
177 Ok(result.parse()?)
178 }
179
180 #[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
182 fn lfs_fsck_objects(&self, refname: &str) -> bool {
183 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
184 lfs.clone()
185 } else {
186 warn!("Git LFS is not available, skipping LFS fetch");
187 return false;
188 };
189
190 let result = cmd
192 .arg("fsck")
193 .arg("--objects")
194 .arg(refname)
195 .cwd(&self.path)
196 .exec_with_output();
197
198 match result {
199 Ok(_) => true,
200 Err(err) => {
201 let lfs_error = err.to_string();
202 if lfs_error.contains("unknown flag: --objects") {
203 warn_user_once!(
204 "Skipping Git LFS validation as Git LFS extension is outdated. \
205 Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
206 properly fetched after the current operation finishes."
207 );
208 true
209 } else {
210 debug!("Git LFS validation failed: {err}");
211 false
212 }
213 }
214 }
215 }
216}
217
218impl GitRemote {
219 pub(crate) fn new(url: SafeUrl) -> Self {
221 Self { url }
222 }
223
224 pub(crate) fn url(&self) -> &SafeUrl {
226 &self.url
227 }
228
229 pub(crate) fn checkout(
242 self,
243 into: &Path,
244 db: Option<GitDatabase>,
245 reference: &GitReference,
246 locked_rev: Option<GitOid>,
247 disable_ssl: bool,
248 offline: bool,
249 with_lfs: bool,
250 ) -> Result<(GitDatabase, GitOid)> {
251 let locked_reference = locked_rev.map(GitReference::Commit);
252 let reference = locked_reference.as_ref().unwrap_or(reference);
253 if let Some(mut db) = db {
254 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
255 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
256
257 if let Ok(rev) = db.repo.resolve(reference) {
258 if with_lfs {
259 let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
260 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
261 db = db.with_lfs_ready(Some(lfs_ready));
262 }
263 return Ok((db, rev));
264 }
265 }
266
267 match fs_err::remove_dir_all(into) {
271 Ok(()) => {}
272 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
273 Err(e) => return Err(e.into()),
274 }
275
276 fs_err::create_dir_all(into)?;
277 let mut repo = GitRepository::init(into)?;
278 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
279 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
280 let rev = repo.resolve(reference)?;
281 let lfs_ready = with_lfs
282 .then(|| {
283 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
284 .with_context(|| format!("failed to fetch LFS objects at {rev}"))
285 })
286 .transpose()?;
287
288 Ok((
289 GitDatabase {
290 remote: self,
291 repo,
292 lfs_ready,
293 },
294 rev,
295 ))
296 }
297
298 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
300 let repo = GitRepository::open(db_path)?;
301 Ok(GitDatabase {
302 remote: self.clone(),
303 repo,
304 lfs_ready: None,
305 })
306 }
307}
308
309impl GitDatabase {
310 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
312 let checkout = match GitRepository::open(destination)
317 .ok()
318 .map(|repo| GitCheckout::new(rev, repo))
319 .filter(GitCheckout::is_fresh)
320 {
321 Some(co) => co.with_lfs_ready(self.lfs_ready),
322 None => GitCheckout::clone_into(destination, self, rev, self.remote.url())?,
323 };
324 Ok(checkout)
325 }
326
327 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
329 let output = GIT
330 .as_ref()
331 .cloned()?
332 .arg("rev-parse")
333 .arg("--short")
334 .arg(revision.as_str())
335 .cwd(&self.repo.path)
336 .exec_with_output()?;
337
338 let mut result = String::from_utf8(output.stdout)?;
339 result.truncate(result.trim_end().len());
340 Ok(result)
341 }
342
343 pub(crate) fn contains(&self, oid: GitOid) -> bool {
345 self.repo
346 .rev_parse(&format!("{oid}^0"))
347 .is_ok_and(|commit| commit == oid)
348 }
349
350 pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
352 self.repo.lfs_fsck_objects(&format!("{oid}^0"))
353 }
354
355 #[must_use]
357 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
358 self.lfs_ready = lfs;
359 self
360 }
361}
362
363impl GitCheckout {
364 fn new(revision: GitOid, repo: GitRepository) -> Self {
369 Self {
370 revision,
371 repo,
372 lfs_ready: None,
373 }
374 }
375
376 fn clone_into(
379 into: &Path,
380 database: &GitDatabase,
381 revision: GitOid,
382 original_remote_url: &SafeUrl,
383 ) -> Result<Self> {
384 match fs_err::remove_file(into.with_extension(CHECKOUT_READY_EXTENSION)) {
386 Ok(()) => {}
387 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
388 Err(err) => return Err(err.into()),
389 }
390
391 let dirname = into.parent().unwrap();
392 fs_err::create_dir_all(dirname)?;
393 match fs_err::remove_dir_all(into) {
394 Ok(()) => {}
395 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
396 Err(e) => return Err(e.into()),
397 }
398
399 let res = GIT
403 .as_ref()
404 .cloned()?
405 .arg("clone")
406 .arg("--local")
407 .arg(database.repo.path.simplified_display().to_string())
411 .arg(into.simplified_display().to_string())
412 .exec_with_output();
413
414 if let Err(e) = res {
415 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
416
417 GIT.as_ref()
418 .cloned()?
419 .arg("clone")
420 .arg("--no-hardlinks")
421 .arg(database.repo.path.simplified_display().to_string())
422 .arg(into.simplified_display().to_string())
423 .exec_with_output()?;
424 }
425
426 let repo = GitRepository::open(into)?;
427 let checkout = Self::new(revision, repo);
428 let lfs_ready = checkout.reset(database.lfs_ready, original_remote_url)?;
429 Ok(checkout.with_lfs_ready(lfs_ready))
430 }
431
432 fn is_fresh(&self) -> bool {
434 match self.repo.rev_parse("HEAD") {
435 Ok(id) if id == self.revision => {
436 self.repo
438 .path
439 .with_extension(CHECKOUT_READY_EXTENSION)
440 .exists()
441 }
442 _ => false,
443 }
444 }
445
446 pub(crate) fn lfs_ready(&self) -> Option<bool> {
448 self.lfs_ready
449 }
450
451 #[must_use]
453 fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
454 self.lfs_ready = lfs;
455 self
456 }
457
458 fn reset(&self, with_lfs: Option<bool>, original_remote_url: &SafeUrl) -> Result<Option<bool>> {
475 let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
479
480 debug!("Reset `{}` to {}", self.repo.path.display(), self.revision);
481
482 GIT.as_ref()
484 .cloned()?
485 .arg("reset")
486 .arg("--hard")
487 .arg(self.revision.as_str())
488 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
489 .cwd(&self.repo.path)
490 .exec_with_output()?;
491
492 let mut submodule_update = GIT.as_ref().cloned()?;
501 for config in submodule_update_config(original_remote_url) {
502 submodule_update.arg("-c").arg(config);
503 }
504
505 submodule_update
506 .arg("submodule")
507 .arg("update")
508 .arg("--init")
509 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
510 .cwd(&self.repo.path)
511 .exec_with_output()
512 .map_err(|err| redact_git_error(err, original_remote_url))
513 .map(drop)?;
514
515 let mut submodule_update = GIT.as_ref().cloned()?;
519 for config in submodule_auth_config(original_remote_url) {
520 submodule_update.arg("-c").arg(config);
521 }
522
523 submodule_update
524 .arg("submodule")
525 .arg("update")
526 .arg("--recursive")
527 .arg("--init")
528 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
529 .cwd(&self.repo.path)
530 .exec_with_output()
531 .map_err(|err| redact_git_error(err, original_remote_url))
532 .map(drop)?;
533
534 let lfs_validation = match with_lfs {
537 None => None,
538 Some(false) => Some(false),
539 Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
540 };
541
542 if with_lfs.is_none() || lfs_validation == Some(true) {
546 paths::create(self.repo.path.with_extension(CHECKOUT_READY_EXTENSION))?;
547 }
548
549 Ok(lfs_validation)
550 }
551}
552
553fn submodule_update_config(original_remote_url: &SafeUrl) -> Vec<String> {
561 let remote_url = original_remote_url.without_credentials();
562 let mut config = vec![format!("remote.origin.url={}", remote_url.as_str())];
563
564 config.extend(submodule_auth_config(original_remote_url));
565 config
566}
567
568fn submodule_auth_config(original_remote_url: &SafeUrl) -> Vec<String> {
574 let remote_url = original_remote_url.without_credentials();
575 let mut config = Vec::new();
576
577 if remote_url.as_str() != original_remote_url.as_str() {
578 let safe_root = remote_url_root(remote_url.into_owned());
579 let credentialed_root = remote_url_root(original_remote_url.clone());
580
581 if safe_root.as_str() != credentialed_root.as_str() {
582 config.push(format!(
583 "url.{}.insteadOf={}",
584 credentialed_root.as_str(),
585 safe_root.as_str()
586 ));
587 }
588 }
589
590 config
591}
592
593fn remote_url_root(mut url: SafeUrl) -> SafeUrl {
599 url.set_path("/");
600 url.set_query(None);
601 url.set_fragment(None);
602 url
603}
604
605fn fetch(
614 repo: &mut GitRepository,
615 remote_url: &SafeUrl,
616 reference: &GitReference,
617 disable_ssl: bool,
618 offline: bool,
619) -> Result<()> {
620 if let GitReference::Commit(commit) = reference
621 && repo
622 .resolve(reference)
623 .is_ok_and(|local_commit| *commit == local_commit)
624 {
625 return Ok(());
626 }
627
628 let mut refspecs = Vec::new();
631 let mut tags = false;
632 let mut refspec_strategy = RefspecStrategy::All;
633 match reference {
637 GitReference::Branch(branch) => {
640 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
641 }
642
643 GitReference::Tag(tag) => {
644 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
645 }
646
647 GitReference::BranchOrTag(branch_or_tag) => {
648 refspecs.push(format!(
649 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
650 ));
651 refspecs.push(format!(
652 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
653 ));
654 refspec_strategy = RefspecStrategy::First;
655 }
656
657 GitReference::BranchOrTagOrCommit(_) => {
660 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
661 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
662 tags = true;
663 }
664
665 GitReference::DefaultBranch => {
666 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
667 }
668
669 GitReference::NamedRef(rev) => {
670 refspecs.push(format!("+{rev}:{rev}"));
671 }
672
673 GitReference::Commit(rev) => {
674 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
675 }
676 }
677
678 debug!("Performing a Git fetch for: {remote_url}");
679 let result = match refspec_strategy {
680 RefspecStrategy::All => fetch_with_cli(
681 repo,
682 remote_url,
683 refspecs.as_slice(),
684 tags,
685 disable_ssl,
686 offline,
687 ),
688 RefspecStrategy::First => {
689 let mut errors = refspecs
691 .iter()
692 .map_while(|refspec| {
693 let fetch_result = fetch_with_cli(
694 repo,
695 remote_url,
696 std::slice::from_ref(refspec),
697 tags,
698 disable_ssl,
699 offline,
700 );
701
702 match fetch_result {
704 Err(ref err) => {
705 debug!("Failed to fetch refspec `{refspec}`: {err}");
706 Some(fetch_result)
707 }
708 Ok(()) => None,
709 }
710 })
711 .collect::<Vec<_>>();
712
713 if errors.len() == refspecs.len() {
714 if let Some(result) = errors.pop() {
715 result
717 } else {
718 Ok(())
720 }
721 } else {
722 Ok(())
723 }
724 }
725 };
726 match reference {
727 GitReference::DefaultBranch => result,
729 GitReference::Commit(_)
730 | GitReference::Branch(_)
731 | GitReference::Tag(_)
732 | GitReference::BranchOrTag(_)
733 | GitReference::BranchOrTagOrCommit(_)
734 | GitReference::NamedRef(_) => result.with_context(|| {
735 format!(
736 "failed to fetch {} `{}`",
737 reference.kind_str(),
738 reference.as_rev()
739 )
740 }),
741 }
742}
743
744fn fetch_with_cli(
746 repo: &mut GitRepository,
747 url: &SafeUrl,
748 refspecs: &[String],
749 tags: bool,
750 disable_ssl: bool,
751 offline: bool,
752) -> Result<()> {
753 let mut cmd = GIT.as_ref().cloned()?;
754 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
758
759 cmd.arg("fetch");
760 if tags {
761 cmd.arg("--tags");
762 }
763 if disable_ssl {
764 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
765 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
766 }
767 if offline {
768 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
769 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
770 }
771 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
774 .args(refspecs)
775 .cwd(&repo.path);
776
777 cmd.exec_with_output().map_err(|err| {
781 let msg = err.to_string();
782 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
783 return GitError::TransportNotAllowed.into();
784 }
785 redact_git_error(err, url)
786 })?;
787
788 Ok(())
789}
790
791pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
802 if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
803 return Err(anyhow!("Git LFS extension has been forcefully disabled."));
804 }
805
806 let mut cmd = GIT.as_ref()?.clone();
807 cmd.arg("lfs");
808
809 cmd.clone().arg("version").exec_with_output()?;
811 Ok(cmd)
812});
813
814fn fetch_lfs(
816 repo: &mut GitRepository,
817 url: &SafeUrl,
818 revision: &GitOid,
819 disable_ssl: bool,
820) -> Result<bool> {
821 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
822 debug!("Fetching Git LFS objects");
823 lfs.clone()
824 } else {
825 warn!("Git LFS is not available, skipping LFS fetch");
827 return Ok(false);
828 };
829
830 if disable_ssl {
831 debug!("Disabling SSL verification for Git LFS");
832 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
833 }
834
835 cmd.arg("fetch")
836 .arg(url.as_str())
837 .arg(revision.as_str())
838 .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
841 .cwd(&repo.path);
842
843 cmd.exec_with_output()
844 .map_err(|err| redact_git_error(err, url))?;
845
846 let validation_result = repo.lfs_fsck_objects(revision.as_str());
854
855 Ok(validation_result)
856}
857
858fn redact_git_error(mut error: anyhow::Error, url: &SafeUrl) -> anyhow::Error {
860 let credentialed_root = remote_url_root(url.clone());
861 let redact = |message: &str| credentialed_root.redact_in(&url.redact_in(message));
862
863 if let Some(process_error) = error.downcast_mut::<ProcessError>() {
864 process_error.desc = redact(&process_error.desc);
865 return error;
866 }
867
868 anyhow!("{}", redact(&error.to_string()))
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
876 fn submodule_update_config_strips_credentials_from_origin_override() {
877 let url = SafeUrl::parse("https://user:password@example.com/org/repo.git").unwrap();
878
879 assert_eq!(
880 submodule_update_config(&url),
881 vec![
882 "remote.origin.url=https://example.com/org/repo.git".to_string(),
883 "url.https://user:password@example.com/.insteadOf=https://example.com/".to_string(),
884 ]
885 );
886 }
887
888 #[test]
889 fn submodule_update_config_preserves_git_ssh_user() {
890 let url = SafeUrl::parse("ssh://git@example.com/org/repo.git").unwrap();
891
892 assert_eq!(
893 submodule_update_config(&url),
894 vec!["remote.origin.url=ssh://git@example.com/org/repo.git".to_string()]
895 );
896 }
897
898 #[test]
899 fn git_process_error_redacts_credentials() -> Result<()> {
900 let url = SafeUrl::parse("https://git:secret-token@example.com/org/repo.git")?;
901 let stderr = format!("fatal: Authentication failed for '{}'", url.as_str());
902 let error = ProcessError::new_raw(
903 &format!(
904 "process didn't exit successfully: `git fetch --force '{}' '+HEAD:refs/remotes/origin/HEAD'`",
905 url.as_str()
906 ),
907 Some(128),
908 "exit status: 128",
909 Some(b"git output"),
910 Some(stderr.as_bytes()),
911 )
912 .into();
913
914 let error = redact_git_error(error, &url);
915 let process_error = error
916 .downcast_ref::<ProcessError>()
917 .context("expected Git process error")?;
918
919 assert_eq!(
920 error.to_string(),
921 "process didn't exit successfully: `git fetch --force 'https://git:****@example.com/org/repo.git' '+HEAD:refs/remotes/origin/HEAD'` (exit status: 128)\n--- stdout\ngit output\n--- stderr\nfatal: Authentication failed for 'https://git:****@example.com/org/repo.git'"
922 );
923 assert_eq!(process_error.code, Some(128));
924 assert_eq!(
925 process_error.stdout.as_deref(),
926 Some(b"git output".as_slice())
927 );
928 assert_eq!(process_error.stderr.as_deref(), Some(stderr.as_bytes()));
929
930 Ok(())
931 }
932
933 #[test]
934 fn git_submodule_process_error_redacts_credentials() -> Result<()> {
935 let url = SafeUrl::parse("https://git:secret-token@example.com/org/repo.git")?;
936
937 for args in ["--init", "--recursive --init"] {
938 let error = anyhow!(
939 "process didn't exit successfully: `git -c 'url.https://git:secret-token@example.com/.insteadOf=https://example.com/' submodule update {args}` (exit status: 128)"
940 );
941 let redacted = redact_git_error(error, &url).to_string();
942
943 assert!(!redacted.contains("secret-token"));
944 assert_eq!(
945 redacted,
946 format!(
947 "process didn't exit successfully: `git -c 'url.https://git:****@example.com/.insteadOf=https://example.com/' submodule update {args}` (exit status: 128)"
948 )
949 );
950 }
951
952 Ok(())
953 }
954}