1use std::path::Path;
25
26use crate::Error;
27use crate::aliases;
28use crate::config;
29
30const DEFAULT_REMOTE: &str = "origin";
31
32#[derive(Debug, thiserror::Error)]
33pub enum EndpointError {
34 #[error(transparent)]
35 Git(#[from] Error),
36 #[error("no LFS endpoint could be determined for remote {0:?}")]
37 Unresolved(String),
38 #[error("invalid remote URL {url:?}: {reason}")]
39 InvalidUrl { url: String, reason: String },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SshInfo {
46 pub user_and_host: String,
48 pub path: String,
53 pub port: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct EndpointInfo {
64 pub url: String,
65 pub ssh: Option<SshInfo>,
66}
67
68pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
72 Ok(resolve_endpoint(cwd, remote)?.url)
73}
74
75pub fn resolve_endpoint(cwd: &Path, remote: Option<&str>) -> Result<EndpointInfo, EndpointError> {
80 let caller_specified_remote = remote.is_some();
81 let mut remote = remote.unwrap_or(DEFAULT_REMOTE).to_owned();
82
83 if let Some(v) = std::env::var_os("GIT_LFS_URL") {
84 let s = v.to_string_lossy().into_owned();
85 if !s.is_empty() {
86 return direct_endpoint(cwd, &s);
87 }
88 }
89
90 if let Some(v) = config::get_effective(cwd, "lfs.url")? {
91 return direct_endpoint(cwd, &v);
92 }
93
94 if !caller_specified_remote && remote_url(cwd, &remote)?.is_none() {
100 let remotes = list_remotes(cwd)?;
101 if remotes.len() == 1 {
102 remote = remotes.into_iter().next().expect("len==1");
103 }
104 }
105
106 let remote_lfsurl_key = format!("remote.{remote}.lfsurl");
107 if let Some(v) = config::get_effective(cwd, &remote_lfsurl_key)? {
108 return direct_endpoint(cwd, &v);
109 }
110
111 if let Some(remote_url) = remote_url(cwd, &remote)? {
112 let rewritten = aliases::rewrite(cwd, &remote_url)?;
116 return Ok(EndpointInfo {
117 url: derive_lfs_url(&rewritten)?,
118 ssh: parse_ssh_url(&rewritten),
119 });
120 }
121
122 if looks_like_url(&remote) {
129 let rewritten = aliases::rewrite(cwd, &remote)?;
130 return Ok(EndpointInfo {
131 url: derive_lfs_url(&rewritten)?,
132 ssh: parse_ssh_url(&rewritten),
133 });
134 }
135
136 if !caller_specified_remote && let Some(url) = read_fetch_head_url(cwd)? {
146 let rewritten = aliases::rewrite(cwd, &url)?;
147 return Ok(EndpointInfo {
148 url: derive_lfs_url(&rewritten)?,
149 ssh: parse_ssh_url(&rewritten),
150 });
151 }
152
153 Err(EndpointError::Unresolved(remote))
154}
155
156fn read_fetch_head_url(cwd: &Path) -> Result<Option<String>, EndpointError> {
161 let git_dir = match crate::path::git_dir(cwd) {
162 Ok(p) => p,
163 Err(_) => return Ok(None),
164 };
165 let path = git_dir.join("FETCH_HEAD");
166 let content = match std::fs::read_to_string(&path) {
167 Ok(s) => s,
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
169 Err(e) => return Err(EndpointError::Git(Error::Io(e))),
170 };
171 for line in content.lines() {
172 if let Some(idx) = line.rfind(" of ") {
173 let url = line[idx + 4..].trim();
174 if !url.is_empty() {
175 return Ok(Some(url.to_owned()));
176 }
177 }
178 }
179 Ok(None)
180}
181
182fn direct_endpoint(cwd: &Path, value: &str) -> Result<EndpointInfo, EndpointError> {
189 let rewritten = aliases::rewrite(cwd, value)?;
190 let ssh = parse_ssh_url(&rewritten);
191 Ok(EndpointInfo {
192 url: rewritten,
193 ssh,
194 })
195}
196
197fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
201 let out = std::process::Command::new("git")
202 .arg("-C")
203 .arg(cwd)
204 .args(["remote"])
205 .output()
206 .map_err(Error::Io)?;
207 if !out.status.success() {
208 return Ok(Vec::new());
209 }
210 Ok(String::from_utf8_lossy(&out.stdout)
211 .lines()
212 .filter(|l| !l.is_empty())
213 .map(str::to_owned)
214 .collect())
215}
216
217pub fn looks_like_url(s: &str) -> bool {
221 s.starts_with("http://")
222 || s.starts_with("https://")
223 || s.starts_with("ssh://")
224 || s.starts_with("git+ssh://")
225 || s.starts_with("ssh+git://")
226 || s.starts_with("git://")
227 || s.starts_with("file://")
228 || s.contains("://")
229 || s.contains('@')
230}
231
232pub fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
239 config::get_effective(cwd, &format!("remote.{remote}.url"))
240}
241
242pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
253 let trimmed = remote_url.trim();
254 if trimmed.is_empty() {
255 return Err(EndpointError::InvalidUrl {
256 url: remote_url.to_owned(),
257 reason: "empty URL".into(),
258 });
259 }
260
261 if let Some(rest) = trimmed.strip_prefix("file://") {
262 return Ok(format!("file://{rest}"));
266 }
267
268 if let Some(rest) = trimmed.strip_prefix("https://") {
270 return Ok(append_lfs_path(&format!("https://{rest}")));
271 }
272 if let Some(rest) = trimmed.strip_prefix("http://") {
273 return Ok(append_lfs_path(&format!("http://{rest}")));
274 }
275 if let Some(rest) = trimmed.strip_prefix("ssh://") {
276 return ssh_to_https(rest, "ssh://");
277 }
278 if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
279 return ssh_to_https(rest, "git+ssh://");
280 }
281 if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
282 return ssh_to_https(rest, "ssh+git://");
283 }
284 if let Some(rest) = trimmed.strip_prefix("git://") {
285 return Ok(append_lfs_path(&format!("https://{rest}")));
287 }
288
289 if let Some((host_part, path)) = bare_ssh_split(trimmed) {
293 let host = host_part.split('@').next_back().unwrap_or(host_part);
294 return Ok(append_lfs_path(&format!(
295 "https://{host}/{}",
296 path.trim_start_matches('/'),
297 )));
298 }
299
300 Err(EndpointError::InvalidUrl {
301 url: remote_url.to_owned(),
302 reason: "unrecognized URL form".into(),
303 })
304}
305
306pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
315 let trimmed = rawurl.trim();
316 let ssh_rest = trimmed
320 .strip_prefix("ssh://")
321 .or_else(|| trimmed.strip_prefix("git+ssh://"))
322 .or_else(|| trimmed.strip_prefix("ssh+git://"));
323 if let Some(rest) = ssh_rest {
324 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
325 if authority.is_empty() {
326 return None;
327 }
328 let (user_and_host, port) = match authority.rsplit_once(':') {
332 Some((host, p)) => (host.to_owned(), Some(p.to_owned())),
333 None => (authority.to_owned(), None),
334 };
335 return Some(SshInfo {
336 user_and_host,
337 path: format!("/{}", path.trim_start_matches('/')),
339 port,
340 });
341 }
342 if trimmed.starts_with("http://")
344 || trimmed.starts_with("https://")
345 || trimmed.starts_with("git://")
346 || trimmed.starts_with("file://")
347 {
348 return None;
349 }
350 let (host, path) = bare_ssh_split(trimmed)?;
354 Some(SshInfo {
355 user_and_host: host.to_owned(),
356 path: path.trim_start_matches('/').to_owned(),
357 port: None,
358 })
359}
360
361fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
365 if rawurl.starts_with('/') || rawurl.starts_with('.') {
367 return None;
368 }
369 if rawurl.contains('\\') {
370 return None;
371 }
372
373 let (host, path) = rawurl.split_once(':')?;
374 if host.is_empty() || path.is_empty() {
375 return None;
376 }
377 if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
381 return None;
382 }
383 Some((host, path))
384}
385
386fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
389 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
390 if authority.is_empty() {
391 return Err(EndpointError::InvalidUrl {
392 url: format!("{scheme_for_error}{rest}"),
393 reason: "missing host".into(),
394 });
395 }
396 let host_with_port = authority.split('@').next_back().unwrap_or(authority);
398 let host = host_with_port.split(':').next().unwrap_or(host_with_port);
400 Ok(append_lfs_path(&format!(
401 "https://{host}/{}",
402 path.trim_start_matches('/'),
403 )))
404}
405
406fn append_lfs_path(url: &str) -> String {
410 let trimmed = url.trim_end_matches('/');
411 if trimmed.ends_with(".git") {
412 format!("{trimmed}/info/lfs")
413 } else {
414 format!("{trimmed}.git/info/lfs")
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
425 fn https_url_without_dotgit_gets_dotgit_info_lfs() {
426 assert_eq!(
427 derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
428 "https://git-server.com/foo/bar.git/info/lfs",
429 );
430 }
431
432 #[test]
433 fn https_url_with_dotgit_gets_just_info_lfs() {
434 assert_eq!(
435 derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
436 "https://git-server.com/foo/bar.git/info/lfs",
437 );
438 }
439
440 #[test]
441 fn http_url_is_preserved_as_http() {
442 assert_eq!(
443 derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
444 "http://localhost:8080/foo/bar.git/info/lfs",
445 );
446 }
447
448 #[test]
449 fn trailing_slash_is_collapsed() {
450 assert_eq!(
451 derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
452 "https://git-server.com/foo/bar.git/info/lfs",
453 );
454 }
455
456 #[test]
457 fn ssh_url_becomes_https() {
458 assert_eq!(
459 derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
460 "https://git-server.com/foo/bar.git/info/lfs",
461 );
462 }
463
464 #[test]
465 fn ssh_url_strips_user_and_port() {
466 assert_eq!(
467 derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
468 "https://git-server.com/foo/bar.git/info/lfs",
469 );
470 }
471
472 #[test]
473 fn bare_ssh_url_becomes_https() {
474 assert_eq!(
475 derive_lfs_url("git@github.com:user/repo.git").unwrap(),
476 "https://github.com/user/repo.git/info/lfs",
477 );
478 }
479
480 #[test]
481 fn bare_ssh_without_user_becomes_https() {
482 assert_eq!(
484 derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
485 "https://git-server.com/foo/bar.git/info/lfs",
486 );
487 }
488
489 #[test]
490 fn git_protocol_url_becomes_https() {
491 assert_eq!(
492 derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
493 "https://git-server.com/foo/bar.git/info/lfs",
494 );
495 }
496
497 #[test]
498 fn ssh_git_variants_are_recognized() {
499 for prefix in ["git+ssh", "ssh+git"] {
500 let url = format!("{prefix}://git@git-server.com/foo/bar.git");
501 assert_eq!(
502 derive_lfs_url(&url).unwrap(),
503 "https://git-server.com/foo/bar.git/info/lfs",
504 );
505 }
506 }
507
508 #[test]
509 fn file_url_is_passed_through_unchanged() {
510 assert_eq!(
511 derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
512 "file:///srv/repos/foo.git",
513 );
514 }
515
516 #[test]
517 fn empty_url_errors() {
518 assert!(matches!(
519 derive_lfs_url(""),
520 Err(EndpointError::InvalidUrl { .. }),
521 ));
522 }
523
524 #[test]
525 fn windows_path_is_not_misread_as_ssh() {
526 let err = derive_lfs_url("C:\\repos\\foo").unwrap_err();
529 assert!(
530 matches!(err, EndpointError::InvalidUrl { .. }),
531 "got {err:?}"
532 );
533 }
534
535 #[test]
536 fn relative_path_is_rejected_not_treated_as_ssh() {
537 for input in ["./relative/path", "/abs/path"] {
538 let err = derive_lfs_url(input).unwrap_err();
539 assert!(
540 matches!(err, EndpointError::InvalidUrl { .. }),
541 "input {input:?} got {err:?}"
542 );
543 }
544 }
545
546 #[test]
549 fn ssh_metadata_for_bare_user_at_host() {
550 let info = parse_ssh_url("git@github.com:user/repo.git").unwrap();
551 assert_eq!(info.user_and_host, "git@github.com");
552 assert_eq!(info.path, "user/repo.git");
553 }
554
555 #[test]
556 fn ssh_metadata_for_bare_host_only() {
557 let info = parse_ssh_url("badalias:rest").unwrap();
558 assert_eq!(info.user_and_host, "badalias");
559 assert_eq!(info.path, "rest");
560 }
561
562 #[test]
563 fn ssh_metadata_for_ssh_scheme_keeps_leading_slash() {
564 let info = parse_ssh_url("ssh://git@host.example/path/to/repo.git").unwrap();
565 assert_eq!(info.user_and_host, "git@host.example");
566 assert_eq!(info.path, "/path/to/repo.git");
567 }
568
569 #[test]
570 fn ssh_metadata_for_ssh_scheme_drops_port_from_host() {
571 let info = parse_ssh_url("ssh://git@host.example:2222/path").unwrap();
572 assert_eq!(info.user_and_host, "git@host.example");
573 assert_eq!(info.path, "/path");
574 }
575
576 #[test]
577 fn ssh_metadata_for_https_returns_none() {
578 assert!(parse_ssh_url("https://host.example/path").is_none());
579 assert!(parse_ssh_url("http://host.example/path").is_none());
580 }
581
582 #[test]
583 fn ssh_metadata_for_git_protocol_returns_none() {
584 assert!(parse_ssh_url("git://host.example/path").is_none());
585 }
586
587 #[test]
588 fn ssh_metadata_for_file_url_returns_none() {
589 assert!(parse_ssh_url("file:///srv/repos/foo.git").is_none());
590 }
591
592 #[test]
593 fn ssh_metadata_for_local_path_returns_none() {
594 assert!(parse_ssh_url("/abs/path").is_none());
595 assert!(parse_ssh_url("./relative").is_none());
596 }
597
598 use std::sync::{Mutex, MutexGuard};
606 use tempfile::TempDir;
607
608 static ENV_LOCK: Mutex<()> = Mutex::new(());
609
610 fn lock_env() -> MutexGuard<'static, ()> {
611 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
615 }
616
617 fn fresh_repo() -> TempDir {
618 let tmp = TempDir::new().unwrap();
619 let s = std::process::Command::new("git")
620 .args(["init", "--quiet"])
621 .arg(tmp.path())
622 .status()
623 .unwrap();
624 assert!(s.success());
625 tmp
626 }
627
628 fn git_in(repo: &Path, args: &[&str]) {
629 let s = std::process::Command::new("git")
630 .arg("-C")
631 .arg(repo)
632 .args(args)
633 .status()
634 .unwrap();
635 assert!(s.success(), "git {args:?} failed");
636 }
637
638 #[test]
639 fn endpoint_prefers_explicit_lfs_url() {
640 let _g = lock_env();
641 unsafe { std::env::remove_var("GIT_LFS_URL") };
642 let repo = fresh_repo();
643 git_in(
644 repo.path(),
645 &["config", "--local", "lfs.url", "https://example.com/lfs"],
646 );
647 git_in(
648 repo.path(),
649 &[
650 "config",
651 "--local",
652 "remote.origin.url",
653 "git@github.com:x/y.git",
654 ],
655 );
656 let url = endpoint_for_remote(repo.path(), None).unwrap();
657 assert_eq!(url, "https://example.com/lfs");
658 }
659
660 #[test]
661 fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
662 let _g = lock_env();
663 unsafe { std::env::remove_var("GIT_LFS_URL") };
664 let repo = fresh_repo();
665 git_in(
666 repo.path(),
667 &[
668 "config",
669 "--local",
670 "remote.origin.lfsurl",
671 "https://lfs.dev/repo",
672 ],
673 );
674 git_in(
675 repo.path(),
676 &[
677 "config",
678 "--local",
679 "remote.origin.url",
680 "git@github.com:x/y.git",
681 ],
682 );
683 let url = endpoint_for_remote(repo.path(), None).unwrap();
684 assert_eq!(url, "https://lfs.dev/repo");
685 }
686
687 #[test]
688 fn endpoint_derives_from_remote_url() {
689 let _g = lock_env();
690 unsafe { std::env::remove_var("GIT_LFS_URL") };
691 let repo = fresh_repo();
692 git_in(
693 repo.path(),
694 &[
695 "config",
696 "--local",
697 "remote.origin.url",
698 "git@github.com:x/y.git",
699 ],
700 );
701 let url = endpoint_for_remote(repo.path(), None).unwrap();
702 assert_eq!(url, "https://github.com/x/y.git/info/lfs");
703 }
704
705 #[test]
706 fn endpoint_uses_named_remote_over_origin() {
707 let _g = lock_env();
708 unsafe { std::env::remove_var("GIT_LFS_URL") };
709 let repo = fresh_repo();
710 git_in(
711 repo.path(),
712 &[
713 "config",
714 "--local",
715 "remote.upstream.url",
716 "https://other.example.com/foo",
717 ],
718 );
719 let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
720 assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
721 }
722
723 #[test]
724 fn endpoint_reads_lfsconfig_at_repo_root() {
725 let _g = lock_env();
726 unsafe { std::env::remove_var("GIT_LFS_URL") };
727 let repo = fresh_repo();
728 std::fs::write(
730 repo.path().join(".lfsconfig"),
731 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
732 )
733 .unwrap();
734 let url = endpoint_for_remote(repo.path(), None).unwrap();
735 assert_eq!(url, "https://from-lfsconfig.example/");
736 }
737
738 #[test]
739 fn endpoint_local_config_overrides_lfsconfig() {
740 let _g = lock_env();
741 unsafe { std::env::remove_var("GIT_LFS_URL") };
742 let repo = fresh_repo();
743 std::fs::write(
744 repo.path().join(".lfsconfig"),
745 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
746 )
747 .unwrap();
748 git_in(
749 repo.path(),
750 &[
751 "config",
752 "--local",
753 "lfs.url",
754 "https://from-localconfig.example/",
755 ],
756 );
757 let url = endpoint_for_remote(repo.path(), None).unwrap();
758 assert_eq!(url, "https://from-localconfig.example/");
759 }
760
761 #[test]
762 fn endpoint_unresolved_when_nothing_configured() {
763 let _g = lock_env();
764 unsafe { std::env::remove_var("GIT_LFS_URL") };
765 let repo = fresh_repo();
766 let err = endpoint_for_remote(repo.path(), None).unwrap_err();
767 assert!(matches!(err, EndpointError::Unresolved(_)));
768 }
769
770 #[test]
771 fn endpoint_env_var_wins_over_everything() {
772 let _g = lock_env();
773 let repo = fresh_repo();
774 git_in(
775 repo.path(),
776 &["config", "--local", "lfs.url", "https://lo.cal/lfs"],
777 );
778
779 let prev = std::env::var_os("GIT_LFS_URL");
780 unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
781 let url = endpoint_for_remote(repo.path(), None).unwrap();
782 assert_eq!(url, "https://from-env.example/");
783 unsafe {
784 match prev {
785 Some(v) => std::env::set_var("GIT_LFS_URL", v),
786 None => std::env::remove_var("GIT_LFS_URL"),
787 }
788 }
789 }
790}