1use std::fmt::Display;
5use std::path::{Path, PathBuf};
6use std::str::{self};
7use std::sync::LazyLock;
8
9use anyhow::{Context, Result, anyhow};
10use cargo_util::{ProcessBuilder, ProcessError, paths};
11use owo_colors::OwoColorize;
12use tracing::{debug, instrument, warn};
13use url::Url;
14
15use uv_fs::Simplified;
16use uv_git_types::{GitOid, GitReference};
17use uv_redacted::DisplaySafeUrl;
18use uv_static::EnvVars;
19use uv_warnings::warn_user_once;
20
21const CHECKOUT_READY_EXTENSION: &str = "ok";
24
25#[derive(Debug, thiserror::Error)]
26pub enum GitError {
27 #[error("Git executable not found. Ensure that Git is installed and available.")]
28 GitNotFound,
29 #[error("Git LFS extension not found. Ensure that Git LFS is installed and available.")]
30 GitLfsNotFound,
31 #[error("Is Git LFS configured? Run `{}` to initialize Git LFS.", "git lfs install".green())]
32 GitLfsNotConfigured,
33 #[error(transparent)]
34 Other(#[from] which::Error),
35 #[error(
36 "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
37 )]
38 TransportNotAllowed,
39}
40
41pub static GIT: LazyLock<Result<ProcessBuilder, GitError>> = LazyLock::new(|| {
46 let path = which::which("git").map_err(|err| match err {
47 which::Error::CannotFindBinaryPath => GitError::GitNotFound,
48 err => GitError::Other(err),
49 })?;
50
51 let mut cmd = ProcessBuilder::new(path);
52
53 cmd.env_remove(EnvVars::GIT_DIR)
60 .env_remove(EnvVars::GIT_WORK_TREE)
61 .env_remove(EnvVars::GIT_INDEX_FILE)
62 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
63 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
64 .env_remove(EnvVars::GIT_COMMON_DIR);
65
66 Ok(cmd)
67});
68
69enum RefspecStrategy {
71 All,
73 First,
75}
76
77#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
79enum ReferenceOrOid<'reference> {
80 Reference(&'reference GitReference),
82 Oid(GitOid),
84}
85
86impl ReferenceOrOid<'_> {
87 fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
89 let refkind = self.kind_str();
90 let result = match self {
91 Self::Reference(GitReference::Tag(s)) => {
96 repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
97 }
98
99 Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
101
102 Self::Reference(GitReference::BranchOrTag(s)) => repo
104 .rev_parse(&format!("origin/{s}^0"))
105 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
106
107 Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
109 .rev_parse(&format!("origin/{s}^0"))
110 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
111 .or_else(|_| repo.rev_parse(&format!("{s}^0"))),
112
113 Self::Reference(GitReference::DefaultBranch) => {
115 repo.rev_parse("refs/remotes/origin/HEAD")
116 }
117
118 Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
120
121 Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
123 };
124
125 result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
126 }
127
128 fn kind_str(&self) -> &str {
130 match self {
131 Self::Reference(reference) => reference.kind_str(),
132 Self::Oid(_) => "commit",
133 }
134 }
135
136 fn as_rev(&self) -> &str {
138 match self {
139 Self::Reference(r) => r.as_rev(),
140 Self::Oid(rev) => rev.as_str(),
141 }
142 }
143}
144
145impl Display for ReferenceOrOid<'_> {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 match self {
148 Self::Reference(reference) => write!(f, "{reference}"),
149 Self::Oid(oid) => write!(f, "{oid}"),
150 }
151 }
152}
153
154#[derive(PartialEq, Clone, Debug)]
156pub(crate) struct GitRemote {
157 url: DisplaySafeUrl,
159}
160
161pub(crate) struct GitDatabase {
164 remote: GitRemote,
166 repo: GitRepository,
168 lfs_ready: Option<bool>,
170}
171
172pub(crate) struct GitCheckout {
174 revision: GitOid,
176 repo: GitRepository,
178 lfs_ready: Option<bool>,
180}
181
182pub(crate) struct GitRepository {
184 path: PathBuf,
186}
187
188impl GitRepository {
189 fn open(path: &Path) -> Result<Self> {
191 GIT.as_ref()
193 .cloned()?
194 .arg("rev-parse")
195 .cwd(path)
196 .exec_with_output()?;
197
198 Ok(Self {
199 path: path.to_path_buf(),
200 })
201 }
202
203 fn init(path: &Path) -> Result<Self> {
205 GIT.as_ref()
213 .cloned()?
214 .arg("init")
215 .cwd(path)
216 .exec_with_output()?;
217
218 Ok(Self {
219 path: path.to_path_buf(),
220 })
221 }
222
223 fn rev_parse(&self, refname: &str) -> Result<GitOid> {
225 let result = GIT
226 .as_ref()
227 .cloned()?
228 .arg("rev-parse")
229 .arg(refname)
230 .cwd(&self.path)
231 .exec_with_output()?;
232
233 let mut result = String::from_utf8(result.stdout)?;
234 result.truncate(result.trim_end().len());
235 Ok(result.parse()?)
236 }
237
238 #[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
240 fn lfs_fsck_objects(&self, refname: &str) -> bool {
241 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
242 lfs.clone()
243 } else {
244 warn!("Git LFS is not available, skipping LFS fetch");
245 return false;
246 };
247
248 let result = cmd
250 .arg("fsck")
251 .arg("--objects")
252 .arg(refname)
253 .cwd(&self.path)
254 .exec_with_output();
255
256 match result {
257 Ok(_) => true,
258 Err(err) => {
259 let lfs_error = err.to_string();
260 if lfs_error.contains("unknown flag: --objects") {
261 warn_user_once!(
262 "Skipping Git LFS validation as Git LFS extension is outdated. \
263 Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
264 properly fetched after the current operation finishes."
265 );
266 true
267 } else {
268 debug!("Git LFS validation failed: {err}");
269 false
270 }
271 }
272 }
273 }
274}
275
276impl GitRemote {
277 pub(crate) fn new(url: DisplaySafeUrl) -> Self {
279 Self { url }
280 }
281
282 pub(crate) fn url(&self) -> &DisplaySafeUrl {
284 &self.url
285 }
286
287 pub(crate) fn checkout(
300 self,
301 into: &Path,
302 db: Option<GitDatabase>,
303 reference: &GitReference,
304 locked_rev: Option<GitOid>,
305 disable_ssl: bool,
306 offline: bool,
307 with_lfs: bool,
308 ) -> Result<(GitDatabase, GitOid)> {
309 let reference = locked_rev
310 .or_else(|| {
311 if let GitReference::BranchOrTagOrCommit(revision) = reference {
312 revision.parse::<GitOid>().ok()
313 } else {
314 None
315 }
316 })
317 .map(ReferenceOrOid::Oid)
318 .unwrap_or(ReferenceOrOid::Reference(reference));
319 if let Some(mut db) = db {
320 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
321 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
322
323 let resolved_commit_hash = match locked_rev {
324 Some(rev) => db.contains(rev).then_some(rev),
325 None => reference.resolve(&db.repo).ok(),
326 };
327
328 if let Some(rev) = resolved_commit_hash {
329 if with_lfs {
330 let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
331 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
332 db = db.with_lfs_ready(Some(lfs_ready));
333 }
334 return Ok((db, rev));
335 }
336 }
337
338 match fs_err::remove_dir_all(into) {
342 Ok(()) => {}
343 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
344 Err(e) => return Err(e.into()),
345 }
346
347 fs_err::create_dir_all(into)?;
348 let mut repo = GitRepository::init(into)?;
349 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
350 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
351 let rev = match locked_rev {
352 Some(rev) => rev,
353 None => reference.resolve(&repo)?,
354 };
355 let lfs_ready = with_lfs
356 .then(|| {
357 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
358 .with_context(|| format!("failed to fetch LFS objects at {rev}"))
359 })
360 .transpose()?;
361
362 Ok((
363 GitDatabase {
364 remote: self,
365 repo,
366 lfs_ready,
367 },
368 rev,
369 ))
370 }
371
372 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
374 let repo = GitRepository::open(db_path)?;
375 Ok(GitDatabase {
376 remote: self.clone(),
377 repo,
378 lfs_ready: None,
379 })
380 }
381}
382
383impl GitDatabase {
384 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
386 let checkout = match GitRepository::open(destination)
391 .ok()
392 .map(|repo| GitCheckout::new(rev, repo))
393 .filter(GitCheckout::is_fresh)
394 {
395 Some(co) => co.with_lfs_ready(self.lfs_ready),
396 None => GitCheckout::clone_into(destination, self, rev, self.remote.url())?,
397 };
398 Ok(checkout)
399 }
400
401 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
403 let output = GIT
404 .as_ref()
405 .cloned()?
406 .arg("rev-parse")
407 .arg("--short")
408 .arg(revision.as_str())
409 .cwd(&self.repo.path)
410 .exec_with_output()?;
411
412 let mut result = String::from_utf8(output.stdout)?;
413 result.truncate(result.trim_end().len());
414 Ok(result)
415 }
416
417 pub(crate) fn contains(&self, oid: GitOid) -> bool {
419 self.repo.rev_parse(&format!("{oid}^0")).is_ok()
420 }
421
422 pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
424 self.repo.lfs_fsck_objects(&format!("{oid}^0"))
425 }
426
427 #[must_use]
429 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
430 self.lfs_ready = lfs;
431 self
432 }
433}
434
435impl GitCheckout {
436 fn new(revision: GitOid, repo: GitRepository) -> Self {
441 Self {
442 revision,
443 repo,
444 lfs_ready: None,
445 }
446 }
447
448 fn clone_into(
451 into: &Path,
452 database: &GitDatabase,
453 revision: GitOid,
454 original_remote_url: &DisplaySafeUrl,
455 ) -> Result<Self> {
456 match fs_err::remove_file(into.with_extension(CHECKOUT_READY_EXTENSION)) {
458 Ok(()) => {}
459 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
460 Err(err) => return Err(err.into()),
461 }
462
463 let dirname = into.parent().unwrap();
464 fs_err::create_dir_all(dirname)?;
465 match fs_err::remove_dir_all(into) {
466 Ok(()) => {}
467 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
468 Err(e) => return Err(e.into()),
469 }
470
471 let res = GIT
475 .as_ref()
476 .cloned()?
477 .arg("clone")
478 .arg("--local")
479 .arg(database.repo.path.simplified_display().to_string())
483 .arg(into.simplified_display().to_string())
484 .exec_with_output();
485
486 if let Err(e) = res {
487 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
488
489 GIT.as_ref()
490 .cloned()?
491 .arg("clone")
492 .arg("--no-hardlinks")
493 .arg(database.repo.path.simplified_display().to_string())
494 .arg(into.simplified_display().to_string())
495 .exec_with_output()?;
496 }
497
498 let repo = GitRepository::open(into)?;
499 let checkout = Self::new(revision, repo);
500 let lfs_ready = checkout.reset(database.lfs_ready, original_remote_url)?;
501 Ok(checkout.with_lfs_ready(lfs_ready))
502 }
503
504 fn is_fresh(&self) -> bool {
506 match self.repo.rev_parse("HEAD") {
507 Ok(id) if id == self.revision => {
508 self.repo
510 .path
511 .with_extension(CHECKOUT_READY_EXTENSION)
512 .exists()
513 }
514 _ => false,
515 }
516 }
517
518 pub(crate) fn lfs_ready(&self) -> Option<bool> {
520 self.lfs_ready
521 }
522
523 #[must_use]
525 fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
526 self.lfs_ready = lfs;
527 self
528 }
529
530 fn reset(
547 &self,
548 with_lfs: Option<bool>,
549 original_remote_url: &DisplaySafeUrl,
550 ) -> Result<Option<bool>> {
551 let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
555
556 debug!("Reset {} to {}", self.repo.path.display(), self.revision);
557
558 GIT.as_ref()
560 .cloned()?
561 .arg("reset")
562 .arg("--hard")
563 .arg(self.revision.as_str())
564 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
565 .cwd(&self.repo.path)
566 .exec_with_output()?;
567
568 let mut submodule_update = GIT.as_ref().cloned()?;
577 for config in submodule_update_config(original_remote_url) {
578 submodule_update.arg("-c").arg(config);
579 }
580
581 submodule_update
582 .arg("submodule")
583 .arg("update")
584 .arg("--init")
585 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
586 .cwd(&self.repo.path)
587 .exec_with_output()
588 .map_err(|err| redact_git_error(err, original_remote_url))
589 .map(drop)?;
590
591 let mut submodule_update = GIT.as_ref().cloned()?;
595 for config in submodule_auth_config(original_remote_url) {
596 submodule_update.arg("-c").arg(config);
597 }
598
599 submodule_update
600 .arg("submodule")
601 .arg("update")
602 .arg("--recursive")
603 .arg("--init")
604 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
605 .cwd(&self.repo.path)
606 .exec_with_output()
607 .map_err(|err| redact_git_error(err, original_remote_url))
608 .map(drop)?;
609
610 let lfs_validation = match with_lfs {
613 None => None,
614 Some(false) => Some(false),
615 Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
616 };
617
618 if with_lfs.is_none() || lfs_validation == Some(true) {
622 paths::create(self.repo.path.with_extension(CHECKOUT_READY_EXTENSION))?;
623 }
624
625 Ok(lfs_validation)
626 }
627}
628
629fn submodule_update_config(original_remote_url: &DisplaySafeUrl) -> Vec<String> {
637 let remote_url = original_remote_url.without_credentials();
638 let mut config = vec![format!("remote.origin.url={}", remote_url.as_str())];
639
640 config.extend(submodule_auth_config(original_remote_url));
641 config
642}
643
644fn submodule_auth_config(original_remote_url: &DisplaySafeUrl) -> Vec<String> {
650 let remote_url = original_remote_url.without_credentials();
651 let mut config = Vec::new();
652
653 if remote_url.as_str() != original_remote_url.as_str() {
654 let safe_root = remote_url_root(remote_url.into_owned());
655 let credentialed_root = remote_url_root((**original_remote_url).clone());
656
657 if safe_root.as_str() != credentialed_root.as_str() {
658 config.push(format!(
659 "url.{}.insteadOf={}",
660 credentialed_root.as_str(),
661 safe_root.as_str()
662 ));
663 }
664 }
665
666 config
667}
668
669fn remote_url_root(mut url: Url) -> Url {
675 url.set_path("/");
676 url.set_query(None);
677 url.set_fragment(None);
678 url
679}
680
681fn fetch(
690 repo: &mut GitRepository,
691 remote_url: &DisplaySafeUrl,
692 reference: ReferenceOrOid<'_>,
693 disable_ssl: bool,
694 offline: bool,
695) -> Result<()> {
696 if let ReferenceOrOid::Oid(rev) = reference {
697 let local_object = reference.resolve(repo).ok();
698 if let Some(local_object) = local_object {
699 if rev == local_object {
700 return Ok(());
701 }
702 }
703 }
704
705 let mut refspecs = Vec::new();
708 let mut tags = false;
709 let mut refspec_strategy = RefspecStrategy::All;
710 match reference {
714 ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
717 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
718 }
719
720 ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
721 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
722 }
723
724 ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
725 refspecs.push(format!(
726 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
727 ));
728 refspecs.push(format!(
729 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
730 ));
731 refspec_strategy = RefspecStrategy::First;
732 }
733
734 ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(_)) => {
737 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
738 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
739 tags = true;
740 }
741
742 ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
743 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
744 }
745
746 ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
747 refspecs.push(format!("+{rev}:{rev}"));
748 }
749
750 ReferenceOrOid::Oid(rev) => {
751 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
752 }
753 }
754
755 debug!("Performing a Git fetch for: {remote_url}");
756 let result = match refspec_strategy {
757 RefspecStrategy::All => fetch_with_cli(
758 repo,
759 remote_url,
760 refspecs.as_slice(),
761 tags,
762 disable_ssl,
763 offline,
764 ),
765 RefspecStrategy::First => {
766 let mut errors = refspecs
768 .iter()
769 .map_while(|refspec| {
770 let fetch_result = fetch_with_cli(
771 repo,
772 remote_url,
773 std::slice::from_ref(refspec),
774 tags,
775 disable_ssl,
776 offline,
777 );
778
779 match fetch_result {
781 Err(ref err) => {
782 debug!("Failed to fetch refspec `{refspec}`: {err}");
783 Some(fetch_result)
784 }
785 Ok(()) => None,
786 }
787 })
788 .collect::<Vec<_>>();
789
790 if errors.len() == refspecs.len() {
791 if let Some(result) = errors.pop() {
792 result
794 } else {
795 Ok(())
797 }
798 } else {
799 Ok(())
800 }
801 }
802 };
803 match reference {
804 ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
806 _ => result.with_context(|| {
807 format!(
808 "failed to fetch {} `{}`",
809 reference.kind_str(),
810 reference.as_rev()
811 )
812 }),
813 }
814}
815
816fn fetch_with_cli(
818 repo: &mut GitRepository,
819 url: &DisplaySafeUrl,
820 refspecs: &[String],
821 tags: bool,
822 disable_ssl: bool,
823 offline: bool,
824) -> Result<()> {
825 let mut cmd = GIT.as_ref().cloned()?;
826 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
830
831 cmd.arg("fetch");
832 if tags {
833 cmd.arg("--tags");
834 }
835 if disable_ssl {
836 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
837 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
838 }
839 if offline {
840 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
841 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
842 }
843 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
846 .args(refspecs)
847 .cwd(&repo.path);
848
849 cmd.exec_with_output().map_err(|err| {
853 let msg = err.to_string();
854 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
855 return GitError::TransportNotAllowed.into();
856 }
857 redact_git_error(err, url)
858 })?;
859
860 Ok(())
861}
862
863pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
874 if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
875 return Err(anyhow!("Git LFS extension has been forcefully disabled."));
876 }
877
878 let mut cmd = GIT.as_ref()?.clone();
879 cmd.arg("lfs");
880
881 cmd.clone().arg("version").exec_with_output()?;
883 Ok(cmd)
884});
885
886fn fetch_lfs(
888 repo: &mut GitRepository,
889 url: &DisplaySafeUrl,
890 revision: &GitOid,
891 disable_ssl: bool,
892) -> Result<bool> {
893 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
894 debug!("Fetching Git LFS objects");
895 lfs.clone()
896 } else {
897 warn!("Git LFS is not available, skipping LFS fetch");
899 return Ok(false);
900 };
901
902 if disable_ssl {
903 debug!("Disabling SSL verification for Git LFS");
904 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
905 }
906
907 cmd.arg("fetch")
908 .arg(url.as_str())
909 .arg(revision.as_str())
910 .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
913 .cwd(&repo.path);
914
915 cmd.exec_with_output()
916 .map_err(|err| redact_git_error(err, url))?;
917
918 let validation_result = repo.lfs_fsck_objects(revision.as_str());
926
927 Ok(validation_result)
928}
929
930fn redact_git_error(mut error: anyhow::Error, url: &DisplaySafeUrl) -> anyhow::Error {
932 let credentialed_root = DisplaySafeUrl::from_url(remote_url_root((**url).clone()));
933 let redact = |message: &str| credentialed_root.redact_in(&url.redact_in(message));
934
935 if let Some(process_error) = error.downcast_mut::<ProcessError>() {
936 process_error.desc = redact(&process_error.desc);
937 return error;
938 }
939
940 anyhow!("{}", redact(&error.to_string()))
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 #[test]
948 fn submodule_update_config_strips_credentials_from_origin_override() {
949 let url = DisplaySafeUrl::parse("https://user:password@example.com/org/repo.git").unwrap();
950
951 assert_eq!(
952 submodule_update_config(&url),
953 vec![
954 "remote.origin.url=https://example.com/org/repo.git".to_string(),
955 "url.https://user:password@example.com/.insteadOf=https://example.com/".to_string(),
956 ]
957 );
958 }
959
960 #[test]
961 fn submodule_update_config_preserves_git_ssh_user() {
962 let url = DisplaySafeUrl::parse("ssh://git@example.com/org/repo.git").unwrap();
963
964 assert_eq!(
965 submodule_update_config(&url),
966 vec!["remote.origin.url=ssh://git@example.com/org/repo.git".to_string()]
967 );
968 }
969
970 #[test]
971 fn git_process_error_redacts_credentials() -> Result<()> {
972 let url = DisplaySafeUrl::parse("https://git:secret-token@example.com/org/repo.git")?;
973 let stderr = format!("fatal: Authentication failed for '{}'", url.as_str());
974 let error = ProcessError::new_raw(
975 &format!(
976 "process didn't exit successfully: `git fetch --force '{}' '+HEAD:refs/remotes/origin/HEAD'`",
977 url.as_str()
978 ),
979 Some(128),
980 "exit status: 128",
981 Some(b"git output"),
982 Some(stderr.as_bytes()),
983 )
984 .into();
985
986 let error = redact_git_error(error, &url);
987 let process_error = error
988 .downcast_ref::<ProcessError>()
989 .context("expected Git process error")?;
990
991 assert_eq!(
992 error.to_string(),
993 "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'"
994 );
995 assert_eq!(process_error.code, Some(128));
996 assert_eq!(
997 process_error.stdout.as_deref(),
998 Some(b"git output".as_slice())
999 );
1000 assert_eq!(process_error.stderr.as_deref(), Some(stderr.as_bytes()));
1001
1002 Ok(())
1003 }
1004
1005 #[test]
1006 fn git_submodule_process_error_redacts_credentials() -> Result<()> {
1007 let url = DisplaySafeUrl::parse("https://git:secret-token@example.com/org/repo.git")?;
1008
1009 for args in ["--init", "--recursive --init"] {
1010 let error = anyhow!(
1011 "process didn't exit successfully: `git -c 'url.https://git:secret-token@example.com/.insteadOf=https://example.com/' submodule update {args}` (exit status: 128)"
1012 );
1013 let redacted = redact_git_error(error, &url).to_string();
1014
1015 assert!(!redacted.contains("secret-token"));
1016 assert_eq!(
1017 redacted,
1018 format!(
1019 "process didn't exit successfully: `git -c 'url.https://git:****@example.com/.insteadOf=https://example.com/' submodule update {args}` (exit status: 128)"
1020 )
1021 );
1022 }
1023
1024 Ok(())
1025 }
1026}