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, 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_LOCK: &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 pub(crate) 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: url.clone() }
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 .map(ReferenceOrOid::Oid)
311 .unwrap_or(ReferenceOrOid::Reference(reference));
312 if let Some(mut db) = db {
313 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
314 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
315
316 let resolved_commit_hash = match locked_rev {
317 Some(rev) => db.contains(rev).then_some(rev),
318 None => reference.resolve(&db.repo).ok(),
319 };
320
321 if let Some(rev) = resolved_commit_hash {
322 if with_lfs {
323 let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
324 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
325 db = db.with_lfs_ready(Some(lfs_ready));
326 }
327 return Ok((db, rev));
328 }
329 }
330
331 match fs_err::remove_dir_all(into) {
335 Ok(()) => {}
336 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
337 Err(e) => return Err(e.into()),
338 }
339
340 fs_err::create_dir_all(into)?;
341 let mut repo = GitRepository::init(into)?;
342 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
343 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
344 let rev = match locked_rev {
345 Some(rev) => rev,
346 None => reference.resolve(&repo)?,
347 };
348 let lfs_ready = with_lfs
349 .then(|| {
350 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
351 .with_context(|| format!("failed to fetch LFS objects at {rev}"))
352 })
353 .transpose()?;
354
355 Ok((
356 GitDatabase {
357 remote: self,
358 repo,
359 lfs_ready,
360 },
361 rev,
362 ))
363 }
364
365 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
367 let repo = GitRepository::open(db_path)?;
368 Ok(GitDatabase {
369 remote: self.clone(),
370 repo,
371 lfs_ready: None,
372 })
373 }
374}
375
376impl GitDatabase {
377 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
379 let checkout = match GitRepository::open(destination)
384 .ok()
385 .map(|repo| GitCheckout::new(rev, repo))
386 .filter(GitCheckout::is_fresh)
387 {
388 Some(co) => co.with_lfs_ready(self.lfs_ready),
389 None => GitCheckout::clone_into(destination, self, rev, self.remote.url())?,
390 };
391 Ok(checkout)
392 }
393
394 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
396 let output = GIT
397 .as_ref()
398 .cloned()?
399 .arg("rev-parse")
400 .arg("--short")
401 .arg(revision.as_str())
402 .cwd(&self.repo.path)
403 .exec_with_output()?;
404
405 let mut result = String::from_utf8(output.stdout)?;
406 result.truncate(result.trim_end().len());
407 Ok(result)
408 }
409
410 pub(crate) fn contains(&self, oid: GitOid) -> bool {
412 self.repo.rev_parse(&format!("{oid}^0")).is_ok()
413 }
414
415 pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
417 self.repo.lfs_fsck_objects(&format!("{oid}^0"))
418 }
419
420 #[must_use]
422 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
423 self.lfs_ready = lfs;
424 self
425 }
426}
427
428impl GitCheckout {
429 fn new(revision: GitOid, repo: GitRepository) -> Self {
434 Self {
435 revision,
436 repo,
437 lfs_ready: None,
438 }
439 }
440
441 fn clone_into(
444 into: &Path,
445 database: &GitDatabase,
446 revision: GitOid,
447 original_remote_url: &DisplaySafeUrl,
448 ) -> Result<Self> {
449 let dirname = into.parent().unwrap();
450 fs_err::create_dir_all(dirname)?;
451 match fs_err::remove_dir_all(into) {
452 Ok(()) => {}
453 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
454 Err(e) => return Err(e.into()),
455 }
456
457 let res = GIT
461 .as_ref()
462 .cloned()?
463 .arg("clone")
464 .arg("--local")
465 .arg(database.repo.path.simplified_display().to_string())
469 .arg(into.simplified_display().to_string())
470 .exec_with_output();
471
472 if let Err(e) = res {
473 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
474
475 GIT.as_ref()
476 .cloned()?
477 .arg("clone")
478 .arg("--no-hardlinks")
479 .arg(database.repo.path.simplified_display().to_string())
480 .arg(into.simplified_display().to_string())
481 .exec_with_output()?;
482 }
483
484 let repo = GitRepository::open(into)?;
485 let checkout = Self::new(revision, repo);
486 let lfs_ready = checkout.reset(database.lfs_ready, original_remote_url)?;
487 Ok(checkout.with_lfs_ready(lfs_ready))
488 }
489
490 fn is_fresh(&self) -> bool {
492 match self.repo.rev_parse("HEAD") {
493 Ok(id) if id == self.revision => {
494 self.repo.path.join(CHECKOUT_READY_LOCK).exists()
496 }
497 _ => false,
498 }
499 }
500
501 pub(crate) fn lfs_ready(&self) -> Option<bool> {
503 self.lfs_ready
504 }
505
506 #[must_use]
508 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
509 self.lfs_ready = lfs;
510 self
511 }
512
513 fn reset(
529 &self,
530 with_lfs: Option<bool>,
531 original_remote_url: &DisplaySafeUrl,
532 ) -> Result<Option<bool>> {
533 let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
534 let _ = paths::remove_file(&ok_file);
535
536 let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
540
541 debug!("Reset {} to {}", self.repo.path.display(), self.revision);
542
543 GIT.as_ref()
545 .cloned()?
546 .arg("reset")
547 .arg("--hard")
548 .arg(self.revision.as_str())
549 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
550 .cwd(&self.repo.path)
551 .exec_with_output()?;
552
553 let mut submodule_update = GIT.as_ref().cloned()?;
562 for config in submodule_update_config(original_remote_url) {
563 submodule_update.arg("-c").arg(config);
564 }
565
566 submodule_update
567 .arg("submodule")
568 .arg("update")
569 .arg("--init")
570 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
571 .cwd(&self.repo.path)
572 .exec_with_output()
573 .map(drop)?;
574
575 let mut submodule_update = GIT.as_ref().cloned()?;
579 for config in submodule_auth_config(original_remote_url) {
580 submodule_update.arg("-c").arg(config);
581 }
582
583 submodule_update
584 .arg("submodule")
585 .arg("update")
586 .arg("--recursive")
587 .arg("--init")
588 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
589 .cwd(&self.repo.path)
590 .exec_with_output()
591 .map(drop)?;
592
593 let lfs_validation = match with_lfs {
596 None => None,
597 Some(false) => Some(false),
598 Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
599 };
600
601 if with_lfs.is_none() || lfs_validation == Some(true) {
605 paths::create(ok_file)?;
606 }
607
608 Ok(lfs_validation)
609 }
610}
611
612fn submodule_update_config(original_remote_url: &DisplaySafeUrl) -> Vec<String> {
620 let remote_url = original_remote_url.without_credentials();
621 let mut config = vec![format!("remote.origin.url={}", remote_url.as_str())];
622
623 config.extend(submodule_auth_config(original_remote_url));
624 config
625}
626
627fn submodule_auth_config(original_remote_url: &DisplaySafeUrl) -> Vec<String> {
633 let remote_url = original_remote_url.without_credentials();
634 let mut config = Vec::new();
635
636 if remote_url.as_str() != original_remote_url.as_str() {
637 let safe_root = remote_url_root(remote_url.as_ref());
638 let credentialed_root = remote_url_root(original_remote_url);
639
640 if safe_root.as_str() != credentialed_root.as_str() {
641 config.push(format!(
642 "url.{}.insteadOf={}",
643 credentialed_root.as_str(),
644 safe_root.as_str()
645 ));
646 }
647 }
648
649 config
650}
651
652fn remote_url_root(url: &Url) -> Url {
658 let mut root = url.clone();
659 root.set_path("/");
660 root.set_query(None);
661 root.set_fragment(None);
662 root
663}
664
665fn fetch(
674 repo: &mut GitRepository,
675 remote_url: &DisplaySafeUrl,
676 reference: ReferenceOrOid<'_>,
677 disable_ssl: bool,
678 offline: bool,
679) -> Result<()> {
680 let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
681 let local_object = reference.resolve(repo).ok();
682 if let Some(local_object) = local_object {
683 if rev == local_object {
684 return Ok(());
685 }
686 }
687
688 Some(rev)
691 } else {
692 None
693 };
694
695 let mut refspecs = Vec::new();
698 let mut tags = false;
699 let mut refspec_strategy = RefspecStrategy::All;
700 match reference {
704 ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
707 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
708 }
709
710 ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
711 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
712 }
713
714 ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
715 refspecs.push(format!(
716 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
717 ));
718 refspecs.push(format!(
719 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
720 ));
721 refspec_strategy = RefspecStrategy::First;
722 }
723
724 ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
727 if let Some(oid_to_fetch) =
731 oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
732 {
733 refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
734 } else {
735 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
739 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
740 tags = true;
741 }
742 }
743
744 ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
745 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
746 }
747
748 ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
749 refspecs.push(format!("+{rev}:{rev}"));
750 }
751
752 ReferenceOrOid::Oid(rev) => {
753 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
754 }
755 }
756
757 debug!("Performing a Git fetch for: {remote_url}");
758 let result = match refspec_strategy {
759 RefspecStrategy::All => fetch_with_cli(
760 repo,
761 remote_url,
762 refspecs.as_slice(),
763 tags,
764 disable_ssl,
765 offline,
766 ),
767 RefspecStrategy::First => {
768 let mut errors = refspecs
770 .iter()
771 .map_while(|refspec| {
772 let fetch_result = fetch_with_cli(
773 repo,
774 remote_url,
775 std::slice::from_ref(refspec),
776 tags,
777 disable_ssl,
778 offline,
779 );
780
781 match fetch_result {
783 Err(ref err) => {
784 debug!("Failed to fetch refspec `{refspec}`: {err}");
785 Some(fetch_result)
786 }
787 Ok(()) => None,
788 }
789 })
790 .collect::<Vec<_>>();
791
792 if errors.len() == refspecs.len() {
793 if let Some(result) = errors.pop() {
794 result
796 } else {
797 Ok(())
799 }
800 } else {
801 Ok(())
802 }
803 }
804 };
805 match reference {
806 ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
808 _ => result.with_context(|| {
809 format!(
810 "failed to fetch {} `{}`",
811 reference.kind_str(),
812 reference.as_rev()
813 )
814 }),
815 }
816}
817
818fn fetch_with_cli(
820 repo: &mut GitRepository,
821 url: &DisplaySafeUrl,
822 refspecs: &[String],
823 tags: bool,
824 disable_ssl: bool,
825 offline: bool,
826) -> Result<()> {
827 let mut cmd = GIT.as_ref().cloned()?;
828 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
832
833 cmd.arg("fetch");
834 if tags {
835 cmd.arg("--tags");
836 }
837 if disable_ssl {
838 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
839 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
840 }
841 if offline {
842 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
843 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
844 }
845 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
848 .args(refspecs)
849 .cwd(&repo.path);
850
851 cmd.exec_with_output().map_err(|err| {
855 let msg = err.to_string();
856 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
857 return GitError::TransportNotAllowed.into();
858 }
859 err
860 })?;
861
862 Ok(())
863}
864
865pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
876 if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
877 return Err(anyhow!("Git LFS extension has been forcefully disabled."));
878 }
879
880 let mut cmd = GIT.as_ref()?.clone();
881 cmd.arg("lfs");
882
883 cmd.clone().arg("version").exec_with_output()?;
885 Ok(cmd)
886});
887
888fn fetch_lfs(
890 repo: &mut GitRepository,
891 url: &DisplaySafeUrl,
892 revision: &GitOid,
893 disable_ssl: bool,
894) -> Result<bool> {
895 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
896 debug!("Fetching Git LFS objects");
897 lfs.clone()
898 } else {
899 warn!("Git LFS is not available, skipping LFS fetch");
901 return Ok(false);
902 };
903
904 if disable_ssl {
905 debug!("Disabling SSL verification for Git LFS");
906 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
907 }
908
909 cmd.arg("fetch")
910 .arg(url.as_str())
911 .arg(revision.as_str())
912 .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
915 .cwd(&repo.path);
916
917 cmd.exec_with_output()?;
918
919 let validation_result = repo.lfs_fsck_objects(revision.as_str());
927
928 Ok(validation_result)
929}
930
931fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
933 let long_hash = oid.to_string();
934 match long_hash.get(..rev.len()) {
935 Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
936 None => false,
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 #[test]
945 fn submodule_update_config_strips_credentials_from_origin_override() {
946 let url = DisplaySafeUrl::parse("https://user:password@example.com/org/repo.git").unwrap();
947
948 assert_eq!(
949 submodule_update_config(&url),
950 vec![
951 "remote.origin.url=https://example.com/org/repo.git".to_string(),
952 "url.https://user:password@example.com/.insteadOf=https://example.com/".to_string(),
953 ]
954 );
955 }
956
957 #[test]
958 fn submodule_update_config_preserves_git_ssh_user() {
959 let url = DisplaySafeUrl::parse("ssh://git@example.com/org/repo.git").unwrap();
960
961 assert_eq!(
962 submodule_update_config(&url),
963 vec!["remote.origin.url=ssh://git@example.com/org/repo.git".to_string()]
964 );
965 }
966}