1use std::path::Path;
25
26use crate::Error;
27use crate::config::{self, ConfigScope};
28
29const DEFAULT_REMOTE: &str = "origin";
30
31#[derive(Debug, thiserror::Error)]
32pub enum EndpointError {
33 #[error(transparent)]
34 Git(#[from] Error),
35 #[error("no LFS endpoint could be determined for remote {0:?}")]
36 Unresolved(String),
37 #[error("invalid remote URL {url:?}: {reason}")]
38 InvalidUrl { url: String, reason: String },
39}
40
41pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
45 let caller_specified_remote = remote.is_some();
46 let mut remote = remote.unwrap_or(DEFAULT_REMOTE).to_owned();
47
48 if let Some(v) = std::env::var_os("GIT_LFS_URL") {
49 let s = v.to_string_lossy().into_owned();
50 if !s.is_empty() {
51 return Ok(s);
52 }
53 }
54
55 if let Some(v) = config::get_effective(cwd, "lfs.url")? {
56 return Ok(v);
57 }
58
59 if !caller_specified_remote && remote_url(cwd, &remote)?.is_none() {
65 let remotes = list_remotes(cwd)?;
66 if remotes.len() == 1 {
67 remote = remotes.into_iter().next().expect("len==1");
68 }
69 }
70
71 let remote_lfsurl_key = format!("remote.{remote}.lfsurl");
72 if let Some(v) = config::get_effective(cwd, &remote_lfsurl_key)? {
73 return Ok(v);
74 }
75
76 if let Some(remote_url) = remote_url(cwd, &remote)? {
77 return derive_lfs_url(&remote_url);
78 }
79
80 if looks_like_url(&remote) {
87 return derive_lfs_url(&remote);
88 }
89
90 Err(EndpointError::Unresolved(remote))
91}
92
93fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
97 let out = std::process::Command::new("git")
98 .arg("-C")
99 .arg(cwd)
100 .args(["remote"])
101 .output()
102 .map_err(Error::Io)?;
103 if !out.status.success() {
104 return Ok(Vec::new());
105 }
106 Ok(String::from_utf8_lossy(&out.stdout)
107 .lines()
108 .filter(|l| !l.is_empty())
109 .map(str::to_owned)
110 .collect())
111}
112
113fn looks_like_url(s: &str) -> bool {
117 s.starts_with("http://")
118 || s.starts_with("https://")
119 || s.starts_with("ssh://")
120 || s.starts_with("git+ssh://")
121 || s.starts_with("ssh+git://")
122 || s.starts_with("git://")
123 || s.starts_with("file://")
124 || s.contains("://")
125 || s.contains('@')
126}
127
128fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
134 let key = format!("remote.{remote}.url");
135 if let Some(v) = config::get(cwd, ConfigScope::Local, &key)? {
136 return Ok(Some(v));
137 }
138 if let Some(v) = config::get(cwd, ConfigScope::Global, &key)? {
139 return Ok(Some(v));
140 }
141 config::get(cwd, ConfigScope::System, &key)
142}
143
144pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
155 let trimmed = remote_url.trim();
156 if trimmed.is_empty() {
157 return Err(EndpointError::InvalidUrl {
158 url: remote_url.to_owned(),
159 reason: "empty URL".into(),
160 });
161 }
162
163 if let Some(rest) = trimmed.strip_prefix("file://") {
164 return Ok(format!("file://{rest}"));
168 }
169
170 if let Some(rest) = trimmed.strip_prefix("https://") {
172 return Ok(append_lfs_path(&format!("https://{rest}")));
173 }
174 if let Some(rest) = trimmed.strip_prefix("http://") {
175 return Ok(append_lfs_path(&format!("http://{rest}")));
176 }
177 if let Some(rest) = trimmed.strip_prefix("ssh://") {
178 return ssh_to_https(rest, "ssh://");
179 }
180 if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
181 return ssh_to_https(rest, "git+ssh://");
182 }
183 if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
184 return ssh_to_https(rest, "ssh+git://");
185 }
186 if let Some(rest) = trimmed.strip_prefix("git://") {
187 return Ok(append_lfs_path(&format!("https://{rest}")));
189 }
190
191 if let Some((host_part, path)) = bare_ssh_split(trimmed) {
195 let host = host_part.split('@').next_back().unwrap_or(host_part);
196 return Ok(append_lfs_path(&format!(
197 "https://{host}/{}",
198 path.trim_start_matches('/'),
199 )));
200 }
201
202 Err(EndpointError::InvalidUrl {
203 url: remote_url.to_owned(),
204 reason: "unrecognized URL form".into(),
205 })
206}
207
208fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
212 if rawurl.starts_with('/') || rawurl.starts_with('.') {
214 return None;
215 }
216 if rawurl.contains('\\') {
217 return None;
218 }
219
220 let (host, path) = rawurl.split_once(':')?;
221 if host.is_empty() || path.is_empty() {
222 return None;
223 }
224 if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
228 return None;
229 }
230 Some((host, path))
231}
232
233fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
236 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
237 if authority.is_empty() {
238 return Err(EndpointError::InvalidUrl {
239 url: format!("{scheme_for_error}{rest}"),
240 reason: "missing host".into(),
241 });
242 }
243 let host_with_port = authority.split('@').next_back().unwrap_or(authority);
245 let host = host_with_port.split(':').next().unwrap_or(host_with_port);
247 Ok(append_lfs_path(&format!(
248 "https://{host}/{}",
249 path.trim_start_matches('/'),
250 )))
251}
252
253fn append_lfs_path(url: &str) -> String {
257 let trimmed = url.trim_end_matches('/');
258 if trimmed.ends_with(".git") {
259 format!("{trimmed}/info/lfs")
260 } else {
261 format!("{trimmed}.git/info/lfs")
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
272 fn https_url_without_dotgit_gets_dotgit_info_lfs() {
273 assert_eq!(
274 derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
275 "https://git-server.com/foo/bar.git/info/lfs",
276 );
277 }
278
279 #[test]
280 fn https_url_with_dotgit_gets_just_info_lfs() {
281 assert_eq!(
282 derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
283 "https://git-server.com/foo/bar.git/info/lfs",
284 );
285 }
286
287 #[test]
288 fn http_url_is_preserved_as_http() {
289 assert_eq!(
290 derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
291 "http://localhost:8080/foo/bar.git/info/lfs",
292 );
293 }
294
295 #[test]
296 fn trailing_slash_is_collapsed() {
297 assert_eq!(
298 derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
299 "https://git-server.com/foo/bar.git/info/lfs",
300 );
301 }
302
303 #[test]
304 fn ssh_url_becomes_https() {
305 assert_eq!(
306 derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
307 "https://git-server.com/foo/bar.git/info/lfs",
308 );
309 }
310
311 #[test]
312 fn ssh_url_strips_user_and_port() {
313 assert_eq!(
314 derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
315 "https://git-server.com/foo/bar.git/info/lfs",
316 );
317 }
318
319 #[test]
320 fn bare_ssh_url_becomes_https() {
321 assert_eq!(
322 derive_lfs_url("git@github.com:user/repo.git").unwrap(),
323 "https://github.com/user/repo.git/info/lfs",
324 );
325 }
326
327 #[test]
328 fn bare_ssh_without_user_becomes_https() {
329 assert_eq!(
331 derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
332 "https://git-server.com/foo/bar.git/info/lfs",
333 );
334 }
335
336 #[test]
337 fn git_protocol_url_becomes_https() {
338 assert_eq!(
339 derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
340 "https://git-server.com/foo/bar.git/info/lfs",
341 );
342 }
343
344 #[test]
345 fn ssh_git_variants_are_recognized() {
346 for prefix in ["git+ssh", "ssh+git"] {
347 let url = format!("{prefix}://git@git-server.com/foo/bar.git");
348 assert_eq!(
349 derive_lfs_url(&url).unwrap(),
350 "https://git-server.com/foo/bar.git/info/lfs",
351 );
352 }
353 }
354
355 #[test]
356 fn file_url_is_passed_through_unchanged() {
357 assert_eq!(
358 derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
359 "file:///srv/repos/foo.git",
360 );
361 }
362
363 #[test]
364 fn empty_url_errors() {
365 assert!(matches!(
366 derive_lfs_url(""),
367 Err(EndpointError::InvalidUrl { .. }),
368 ));
369 }
370
371 #[test]
372 fn windows_path_is_not_misread_as_ssh() {
373 assert!(derive_lfs_url("C:\\repos\\foo").is_err());
376 }
377
378 #[test]
379 fn relative_path_is_rejected_not_treated_as_ssh() {
380 assert!(derive_lfs_url("./relative/path").is_err());
381 assert!(derive_lfs_url("/abs/path").is_err());
382 }
383
384 use std::sync::{Mutex, MutexGuard};
392 use tempfile::TempDir;
393
394 static ENV_LOCK: Mutex<()> = Mutex::new(());
395
396 fn lock_env() -> MutexGuard<'static, ()> {
397 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
401 }
402
403 fn fresh_repo() -> TempDir {
404 let tmp = TempDir::new().unwrap();
405 let s = std::process::Command::new("git")
406 .args(["init", "--quiet"])
407 .arg(tmp.path())
408 .status()
409 .unwrap();
410 assert!(s.success());
411 tmp
412 }
413
414 fn git_in(repo: &Path, args: &[&str]) {
415 let s = std::process::Command::new("git")
416 .arg("-C")
417 .arg(repo)
418 .args(args)
419 .status()
420 .unwrap();
421 assert!(s.success(), "git {args:?} failed");
422 }
423
424 #[test]
425 fn endpoint_prefers_explicit_lfs_url() {
426 let _g = lock_env();
427 unsafe { std::env::remove_var("GIT_LFS_URL") };
428 let repo = fresh_repo();
429 git_in(repo.path(), &["config", "--local", "lfs.url", "https://example.com/lfs"]);
430 git_in(
431 repo.path(),
432 &["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
433 );
434 let url = endpoint_for_remote(repo.path(), None).unwrap();
435 assert_eq!(url, "https://example.com/lfs");
436 }
437
438 #[test]
439 fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
440 let _g = lock_env();
441 unsafe { std::env::remove_var("GIT_LFS_URL") };
442 let repo = fresh_repo();
443 git_in(
444 repo.path(),
445 &["config", "--local", "remote.origin.lfsurl", "https://lfs.dev/repo"],
446 );
447 git_in(
448 repo.path(),
449 &["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
450 );
451 let url = endpoint_for_remote(repo.path(), None).unwrap();
452 assert_eq!(url, "https://lfs.dev/repo");
453 }
454
455 #[test]
456 fn endpoint_derives_from_remote_url() {
457 let _g = lock_env();
458 unsafe { std::env::remove_var("GIT_LFS_URL") };
459 let repo = fresh_repo();
460 git_in(
461 repo.path(),
462 &["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
463 );
464 let url = endpoint_for_remote(repo.path(), None).unwrap();
465 assert_eq!(url, "https://github.com/x/y.git/info/lfs");
466 }
467
468 #[test]
469 fn endpoint_uses_named_remote_over_origin() {
470 let _g = lock_env();
471 unsafe { std::env::remove_var("GIT_LFS_URL") };
472 let repo = fresh_repo();
473 git_in(
474 repo.path(),
475 &["config", "--local", "remote.upstream.url", "https://other.example.com/foo"],
476 );
477 let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
478 assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
479 }
480
481 #[test]
482 fn endpoint_reads_lfsconfig_at_repo_root() {
483 let _g = lock_env();
484 unsafe { std::env::remove_var("GIT_LFS_URL") };
485 let repo = fresh_repo();
486 std::fs::write(
488 repo.path().join(".lfsconfig"),
489 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
490 )
491 .unwrap();
492 let url = endpoint_for_remote(repo.path(), None).unwrap();
493 assert_eq!(url, "https://from-lfsconfig.example/");
494 }
495
496 #[test]
497 fn endpoint_local_config_overrides_lfsconfig() {
498 let _g = lock_env();
499 unsafe { std::env::remove_var("GIT_LFS_URL") };
500 let repo = fresh_repo();
501 std::fs::write(
502 repo.path().join(".lfsconfig"),
503 "[lfs]\n\turl = https://from-lfsconfig.example/\n",
504 )
505 .unwrap();
506 git_in(
507 repo.path(),
508 &["config", "--local", "lfs.url", "https://from-localconfig.example/"],
509 );
510 let url = endpoint_for_remote(repo.path(), None).unwrap();
511 assert_eq!(url, "https://from-localconfig.example/");
512 }
513
514 #[test]
515 fn endpoint_unresolved_when_nothing_configured() {
516 let _g = lock_env();
517 unsafe { std::env::remove_var("GIT_LFS_URL") };
518 let repo = fresh_repo();
519 let err = endpoint_for_remote(repo.path(), None).unwrap_err();
520 assert!(matches!(err, EndpointError::Unresolved(_)));
521 }
522
523 #[test]
524 fn endpoint_env_var_wins_over_everything() {
525 let _g = lock_env();
526 let repo = fresh_repo();
527 git_in(repo.path(), &["config", "--local", "lfs.url", "https://lo.cal/lfs"]);
528
529 let prev = std::env::var_os("GIT_LFS_URL");
530 unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
531 let url = endpoint_for_remote(repo.path(), None).unwrap();
532 assert_eq!(url, "https://from-env.example/");
533 unsafe {
534 match prev {
535 Some(v) => std::env::set_var("GIT_LFS_URL", v),
536 None => std::env::remove_var("GIT_LFS_URL"),
537 }
538 }
539 }
540}