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}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct EndpointInfo {
60 pub url: String,
61 pub ssh: Option<SshInfo>,
62}
63
64pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
68 Ok(resolve_endpoint(cwd, remote)?.url)
69}
70
71pub fn resolve_endpoint(cwd: &Path, remote: Option<&str>) -> Result<EndpointInfo, EndpointError> {
76 let caller_specified_remote = remote.is_some();
77 let mut remote = remote.unwrap_or(DEFAULT_REMOTE).to_owned();
78
79 if let Some(v) = std::env::var_os("GIT_LFS_URL") {
80 let s = v.to_string_lossy().into_owned();
81 if !s.is_empty() {
82 return direct_endpoint(cwd, &s);
83 }
84 }
85
86 if let Some(v) = config::get_effective(cwd, "lfs.url")? {
87 return direct_endpoint(cwd, &v);
88 }
89
90 if !caller_specified_remote && remote_url(cwd, &remote)?.is_none() {
96 let remotes = list_remotes(cwd)?;
97 if remotes.len() == 1 {
98 remote = remotes.into_iter().next().expect("len==1");
99 }
100 }
101
102 let remote_lfsurl_key = format!("remote.{remote}.lfsurl");
103 if let Some(v) = config::get_effective(cwd, &remote_lfsurl_key)? {
104 return direct_endpoint(cwd, &v);
105 }
106
107 if let Some(remote_url) = remote_url(cwd, &remote)? {
108 let rewritten = aliases::rewrite(cwd, &remote_url)?;
112 return Ok(EndpointInfo {
113 url: derive_lfs_url(&rewritten)?,
114 ssh: parse_ssh_url(&rewritten),
115 });
116 }
117
118 if looks_like_url(&remote) {
125 let rewritten = aliases::rewrite(cwd, &remote)?;
126 return Ok(EndpointInfo {
127 url: derive_lfs_url(&rewritten)?,
128 ssh: parse_ssh_url(&rewritten),
129 });
130 }
131
132 if !caller_specified_remote && let Some(url) = read_fetch_head_url(cwd)? {
142 let rewritten = aliases::rewrite(cwd, &url)?;
143 return Ok(EndpointInfo {
144 url: derive_lfs_url(&rewritten)?,
145 ssh: parse_ssh_url(&rewritten),
146 });
147 }
148
149 Err(EndpointError::Unresolved(remote))
150}
151
152fn read_fetch_head_url(cwd: &Path) -> Result<Option<String>, EndpointError> {
157 let git_dir = match crate::path::git_dir(cwd) {
158 Ok(p) => p,
159 Err(_) => return Ok(None),
160 };
161 let path = git_dir.join("FETCH_HEAD");
162 let content = match std::fs::read_to_string(&path) {
163 Ok(s) => s,
164 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
165 Err(e) => return Err(EndpointError::Git(Error::Io(e))),
166 };
167 for line in content.lines() {
168 if let Some(idx) = line.rfind(" of ") {
169 let url = line[idx + 4..].trim();
170 if !url.is_empty() {
171 return Ok(Some(url.to_owned()));
172 }
173 }
174 }
175 Ok(None)
176}
177
178fn direct_endpoint(cwd: &Path, value: &str) -> Result<EndpointInfo, EndpointError> {
185 let rewritten = aliases::rewrite(cwd, value)?;
186 let ssh = parse_ssh_url(&rewritten);
187 Ok(EndpointInfo {
188 url: rewritten,
189 ssh,
190 })
191}
192
193fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
197 let out = std::process::Command::new("git")
198 .arg("-C")
199 .arg(cwd)
200 .args(["remote"])
201 .output()
202 .map_err(Error::Io)?;
203 if !out.status.success() {
204 return Ok(Vec::new());
205 }
206 Ok(String::from_utf8_lossy(&out.stdout)
207 .lines()
208 .filter(|l| !l.is_empty())
209 .map(str::to_owned)
210 .collect())
211}
212
213pub fn looks_like_url(s: &str) -> bool {
217 s.starts_with("http://")
218 || s.starts_with("https://")
219 || s.starts_with("ssh://")
220 || s.starts_with("git+ssh://")
221 || s.starts_with("ssh+git://")
222 || s.starts_with("git://")
223 || s.starts_with("file://")
224 || s.contains("://")
225 || s.contains('@')
226}
227
228fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
235 config::get_effective(cwd, &format!("remote.{remote}.url"))
236}
237
238pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
249 let trimmed = remote_url.trim();
250 if trimmed.is_empty() {
251 return Err(EndpointError::InvalidUrl {
252 url: remote_url.to_owned(),
253 reason: "empty URL".into(),
254 });
255 }
256
257 if let Some(rest) = trimmed.strip_prefix("file://") {
258 return Ok(format!("file://{rest}"));
262 }
263
264 if let Some(rest) = trimmed.strip_prefix("https://") {
266 return Ok(append_lfs_path(&format!("https://{rest}")));
267 }
268 if let Some(rest) = trimmed.strip_prefix("http://") {
269 return Ok(append_lfs_path(&format!("http://{rest}")));
270 }
271 if let Some(rest) = trimmed.strip_prefix("ssh://") {
272 return ssh_to_https(rest, "ssh://");
273 }
274 if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
275 return ssh_to_https(rest, "git+ssh://");
276 }
277 if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
278 return ssh_to_https(rest, "ssh+git://");
279 }
280 if let Some(rest) = trimmed.strip_prefix("git://") {
281 return Ok(append_lfs_path(&format!("https://{rest}")));
283 }
284
285 if let Some((host_part, path)) = bare_ssh_split(trimmed) {
289 let host = host_part.split('@').next_back().unwrap_or(host_part);
290 return Ok(append_lfs_path(&format!(
291 "https://{host}/{}",
292 path.trim_start_matches('/'),
293 )));
294 }
295
296 Err(EndpointError::InvalidUrl {
297 url: remote_url.to_owned(),
298 reason: "unrecognized URL form".into(),
299 })
300}
301
302pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
311 let trimmed = rawurl.trim();
312 let ssh_rest = trimmed
316 .strip_prefix("ssh://")
317 .or_else(|| trimmed.strip_prefix("git+ssh://"))
318 .or_else(|| trimmed.strip_prefix("ssh+git://"));
319 if let Some(rest) = ssh_rest {
320 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
321 if authority.is_empty() {
322 return None;
323 }
324 let user_and_host = authority
328 .rsplit_once(':')
329 .map(|(host, _port)| host)
330 .unwrap_or(authority);
331 return Some(SshInfo {
332 user_and_host: user_and_host.to_owned(),
333 path: format!("/{}", path.trim_start_matches('/')),
335 });
336 }
337 if trimmed.starts_with("http://")
339 || trimmed.starts_with("https://")
340 || trimmed.starts_with("git://")
341 || trimmed.starts_with("file://")
342 {
343 return None;
344 }
345 let (host, path) = bare_ssh_split(trimmed)?;
348 Some(SshInfo {
349 user_and_host: host.to_owned(),
350 path: path.trim_start_matches('/').to_owned(),
351 })
352}
353
354fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
358 if rawurl.starts_with('/') || rawurl.starts_with('.') {
360 return None;
361 }
362 if rawurl.contains('\\') {
363 return None;
364 }
365
366 let (host, path) = rawurl.split_once(':')?;
367 if host.is_empty() || path.is_empty() {
368 return None;
369 }
370 if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
374 return None;
375 }
376 Some((host, path))
377}
378
379fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
382 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
383 if authority.is_empty() {
384 return Err(EndpointError::InvalidUrl {
385 url: format!("{scheme_for_error}{rest}"),
386 reason: "missing host".into(),
387 });
388 }
389 let host_with_port = authority.split('@').next_back().unwrap_or(authority);
391 let host = host_with_port.split(':').next().unwrap_or(host_with_port);
393 Ok(append_lfs_path(&format!(
394 "https://{host}/{}",
395 path.trim_start_matches('/'),
396 )))
397}
398
399fn append_lfs_path(url: &str) -> String {
403 let trimmed = url.trim_end_matches('/');
404 if trimmed.ends_with(".git") {
405 format!("{trimmed}/info/lfs")
406 } else {
407 format!("{trimmed}.git/info/lfs")
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
418 fn https_url_without_dotgit_gets_dotgit_info_lfs() {
419 assert_eq!(
420 derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
421 "https://git-server.com/foo/bar.git/info/lfs",
422 );
423 }
424
425 #[test]
426 fn https_url_with_dotgit_gets_just_info_lfs() {
427 assert_eq!(
428 derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
429 "https://git-server.com/foo/bar.git/info/lfs",
430 );
431 }
432
433 #[test]
434 fn http_url_is_preserved_as_http() {
435 assert_eq!(
436 derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
437 "http://localhost:8080/foo/bar.git/info/lfs",
438 );
439 }
440
441 #[test]
442 fn trailing_slash_is_collapsed() {
443 assert_eq!(
444 derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
445 "https://git-server.com/foo/bar.git/info/lfs",
446 );
447 }
448
449 #[test]
450 fn ssh_url_becomes_https() {
451 assert_eq!(
452 derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
453 "https://git-server.com/foo/bar.git/info/lfs",
454 );
455 }
456
457 #[test]
458 fn ssh_url_strips_user_and_port() {
459 assert_eq!(
460 derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
461 "https://git-server.com/foo/bar.git/info/lfs",
462 );
463 }
464
465 #[test]
466 fn bare_ssh_url_becomes_https() {
467 assert_eq!(
468 derive_lfs_url("git@github.com:user/repo.git").unwrap(),
469 "https://github.com/user/repo.git/info/lfs",
470 );
471 }
472
473 #[test]
474 fn bare_ssh_without_user_becomes_https() {
475 assert_eq!(
477 derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
478 "https://git-server.com/foo/bar.git/info/lfs",
479 );
480 }
481
482 #[test]
483 fn git_protocol_url_becomes_https() {
484 assert_eq!(
485 derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
486 "https://git-server.com/foo/bar.git/info/lfs",
487 );
488 }
489
490 #[test]
491 fn ssh_git_variants_are_recognized() {
492 for prefix in ["git+ssh", "ssh+git"] {
493 let url = format!("{prefix}://git@git-server.com/foo/bar.git");
494 assert_eq!(
495 derive_lfs_url(&url).unwrap(),
496 "https://git-server.com/foo/bar.git/info/lfs",
497 );
498 }
499 }
500
501 #[test]
502 fn file_url_is_passed_through_unchanged() {
503 assert_eq!(
504 derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
505 "file:///srv/repos/foo.git",
506 );
507 }
508
509 #[test]
510 fn empty_url_errors() {
511 assert!(matches!(
512 derive_lfs_url(""),
513 Err(EndpointError::InvalidUrl { .. }),
514 ));
515 }
516
517 #[test]
518 fn windows_path_is_not_misread_as_ssh() {
519 assert!(derive_lfs_url("C:\\repos\\foo").is_err());
522 }
523
524 #[test]
525 fn relative_path_is_rejected_not_treated_as_ssh() {
526 assert!(derive_lfs_url("./relative/path").is_err());
527 assert!(derive_lfs_url("/abs/path").is_err());
528 }
529
530 #[test]
533 fn ssh_metadata_for_bare_user_at_host() {
534 let info = parse_ssh_url("git@github.com:user/repo.git").unwrap();
535 assert_eq!(info.user_and_host, "git@github.com");
536 assert_eq!(info.path, "user/repo.git");
537 }
538
539 #[test]
540 fn ssh_metadata_for_bare_host_only() {
541 let info = parse_ssh_url("badalias:rest").unwrap();
542 assert_eq!(info.user_and_host, "badalias");
543 assert_eq!(info.path, "rest");
544 }
545
546 #[test]
547 fn ssh_metadata_for_ssh_scheme_keeps_leading_slash() {
548 let info = parse_ssh_url("ssh://git@host.example/path/to/repo.git").unwrap();
549 assert_eq!(info.user_and_host, "git@host.example");
550 assert_eq!(info.path, "/path/to/repo.git");
551 }
552
553 #[test]
554 fn ssh_metadata_for_ssh_scheme_drops_port_from_host() {
555 let info = parse_ssh_url("ssh://git@host.example:2222/path").unwrap();
556 assert_eq!(info.user_and_host, "git@host.example");
557 assert_eq!(info.path, "/path");
558 }
559
560 #[test]
561 fn ssh_metadata_for_https_returns_none() {
562 assert!(parse_ssh_url("https://host.example/path").is_none());
563 assert!(parse_ssh_url("http://host.example/path").is_none());
564 }
565
566 #[test]
567 fn ssh_metadata_for_git_protocol_returns_none() {
568 assert!(parse_ssh_url("git://host.example/path").is_none());
569 }
570
571 #[test]
572 fn ssh_metadata_for_file_url_returns_none() {
573 assert!(parse_ssh_url("file:///srv/repos/foo.git").is_none());
574 }
575
576 #[test]
577 fn ssh_metadata_for_local_path_returns_none() {
578 assert!(parse_ssh_url("/abs/path").is_none());
579 assert!(parse_ssh_url("./relative").is_none());
580 }
581
582 use std::sync::{Mutex, MutexGuard};
590 use tempfile::TempDir;
591
592 static ENV_LOCK: Mutex<()> = Mutex::new(());
593
594 fn lock_env() -> MutexGuard<'static, ()> {
595 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
599 }
600
601 fn fresh_repo() -> TempDir {
602 let tmp = TempDir::new().unwrap();
603 let s = std::process::Command::new("git")
604 .args(["init", "--quiet"])
605 .arg(tmp.path())
606 .status()
607 .unwrap();
608 assert!(s.success());
609 tmp
610 }
611
612 fn git_in(repo: &Path, args: &[&str]) {
613 let s = std::process::Command::new("git")
614 .arg("-C")
615 .arg(repo)
616 .args(args)
617 .status()
618 .unwrap();
619 assert!(s.success(), "git {args:?} failed");
620 }
621
622 #[test]
623 fn endpoint_prefers_explicit_lfs_url() {
624 let _g = lock_env();
625 unsafe { std::env::remove_var("GIT_LFS_URL") };
626 let repo = fresh_repo();
627 git_in(
628 repo.path(),
629 &["config", "--local", "lfs.url", "https://example.com/lfs"],
630 );
631 git_in(
632 repo.path(),
633 &[
634 "config",
635 "--local",
636 "remote.origin.url",
637 "git@github.com:x/y.git",
638 ],
639 );
640 let url = endpoint_for_remote(repo.path(), None).unwrap();
641 assert_eq!(url, "https://example.com/lfs");
642 }
643
644 #[test]
645 fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
646 let _g = lock_env();
647 unsafe { std::env::remove_var("GIT_LFS_URL") };
648 let repo = fresh_repo();
649 git_in(
650 repo.path(),
651 &[
652 "config",
653 "--local",
654 "remote.origin.lfsurl",
655 "https://lfs.dev/repo",
656 ],
657 );
658 git_in(
659 repo.path(),
660 &[
661 "config",
662 "--local",
663 "remote.origin.url",
664 "git@github.com:x/y.git",
665 ],
666 );
667 let url = endpoint_for_remote(repo.path(), None).unwrap();
668 assert_eq!(url, "https://lfs.dev/repo");
669 }
670
671 #[test]
672 fn endpoint_derives_from_remote_url() {
673 let _g = lock_env();
674 unsafe { std::env::remove_var("GIT_LFS_URL") };
675 let repo = fresh_repo();
676 git_in(
677 repo.path(),
678 &[
679 "config",
680 "--local",
681 "remote.origin.url",
682 "git@github.com:x/y.git",
683 ],
684 );
685 let url = endpoint_for_remote(repo.path(), None).unwrap();
686 assert_eq!(url, "https://github.com/x/y.git/info/lfs");
687 }
688
689 #[test]
690 fn endpoint_uses_named_remote_over_origin() {
691 let _g = lock_env();
692 unsafe { std::env::remove_var("GIT_LFS_URL") };
693 let repo = fresh_repo();
694 git_in(
695 repo.path(),
696 &[
697 "config",
698 "--local",
699 "remote.upstream.url",
700 "https://other.example.com/foo",
701 ],
702 );
703 let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
704 assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
705 }
706
707 #[test]
708 fn endpoint_reads_lfsconfig_at_repo_root() {
709 let _g = lock_env();
710 unsafe { std::env::remove_var("GIT_LFS_URL") };
711 let repo = fresh_repo();
712 std::fs::write(
714 repo.path().join(".lfsconfig"),
715 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
716 )
717 .unwrap();
718 let url = endpoint_for_remote(repo.path(), None).unwrap();
719 assert_eq!(url, "https://from-lfsconfig.example/");
720 }
721
722 #[test]
723 fn endpoint_local_config_overrides_lfsconfig() {
724 let _g = lock_env();
725 unsafe { std::env::remove_var("GIT_LFS_URL") };
726 let repo = fresh_repo();
727 std::fs::write(
728 repo.path().join(".lfsconfig"),
729 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
730 )
731 .unwrap();
732 git_in(
733 repo.path(),
734 &[
735 "config",
736 "--local",
737 "lfs.url",
738 "https://from-localconfig.example/",
739 ],
740 );
741 let url = endpoint_for_remote(repo.path(), None).unwrap();
742 assert_eq!(url, "https://from-localconfig.example/");
743 }
744
745 #[test]
746 fn endpoint_unresolved_when_nothing_configured() {
747 let _g = lock_env();
748 unsafe { std::env::remove_var("GIT_LFS_URL") };
749 let repo = fresh_repo();
750 let err = endpoint_for_remote(repo.path(), None).unwrap_err();
751 assert!(matches!(err, EndpointError::Unresolved(_)));
752 }
753
754 #[test]
755 fn endpoint_env_var_wins_over_everything() {
756 let _g = lock_env();
757 let repo = fresh_repo();
758 git_in(
759 repo.path(),
760 &["config", "--local", "lfs.url", "https://lo.cal/lfs"],
761 );
762
763 let prev = std::env::var_os("GIT_LFS_URL");
764 unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
765 let url = endpoint_for_remote(repo.path(), None).unwrap();
766 assert_eq!(url, "https://from-env.example/");
767 unsafe {
768 match prev {
769 Some(v) => std::env::set_var("GIT_LFS_URL", v),
770 None => std::env::remove_var("GIT_LFS_URL"),
771 }
772 }
773 }
774}