Skip to main content

git_lfs_git/
endpoint.rs

1//! Resolve the LFS server endpoint for a repo.
2//!
3//! Implements the priority chain documented in
4//! `docs/api/server-discovery.md`, plus the SSH/git URL → HTTPS rewriting
5//! upstream does so a `git@github.com:foo/bar.git` remote yields the
6//! expected `https://github.com/foo/bar.git/info/lfs` endpoint.
7//!
8//! # Priority order
9//!
10//! 1. `GIT_LFS_URL` environment variable (matches upstream's escape hatch).
11//! 2. `lfs.url` from git config — local → global → system → `.lfsconfig`.
12//! 3. `remote.<name>.lfsurl` (same scopes as above).
13//! 4. `remote.<name>.url` rewritten via [`derive_lfs_url`].
14//!
15//! `<name>` defaults to `origin` when the caller hasn't passed a remote.
16//!
17//! # SSH-style URLs
18//!
19//! `git lfs` itself only speaks HTTP(S); for SSH remotes the *protocol* is
20//! still HTTPS, just inferred from the remote's host/path. Upstream also
21//! supports the `git-lfs-authenticate` SSH command for handing back a
22//! token; that's deferred (see NOTES.md).
23
24use 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/// SSH-shaped remote/endpoint URL parsed into the components `git lfs
43/// env` echoes back as `  SSH=<user_and_host>:<path>`.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SshInfo {
46    /// Either `<user>@<host>` if a user was present, or just `<host>`.
47    pub user_and_host: String,
48    /// The path portion — `:foo/bar.git` from `git@host:foo/bar.git`,
49    /// or `/foo/bar.git` from `ssh://host/foo/bar.git` (we keep
50    /// upstream's exact form: leading `/` preserved for `ssh://`,
51    /// stripped for bare SSH).
52    pub path: String,
53}
54
55/// LFS endpoint resolution result with the optional SSH metadata
56/// upstream's `git lfs env` displays alongside the HTTPS-equivalent
57/// endpoint URL.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct EndpointInfo {
60    pub url: String,
61    pub ssh: Option<SshInfo>,
62}
63
64/// Resolve the LFS endpoint URL for `cwd` + `remote`. Pass `None` for the
65/// default (`origin`, with a "single remote" fallback when origin doesn't
66/// exist and exactly one other remote does).
67pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
68    Ok(resolve_endpoint(cwd, remote)?.url)
69}
70
71/// Like [`endpoint_for_remote`], but also returns the SSH metadata
72/// when the underlying URL was SSH-shaped. Used by `git lfs env` to
73/// render the `  SSH=<user_and_host>:<path>` line alongside the
74/// HTTPS-equivalent endpoint.
75pub 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    // When the caller didn't pin a remote name and `origin` doesn't
91    // exist, fall back to the only configured remote. Mirrors
92    // upstream's `git remote` discovery in `lfsfetch` and is what
93    // `t-fetch.sh::fetch with no origin remote` exercises (rename
94    // origin → something, then bare `git lfs fetch`).
95    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        // Apply insteadOf rewrite *before* deriving the LFS suffix so
109        // a `gh:org/repo` style alias resolves to the real URL first
110        // and `derive_lfs_url` sees a URL it can parse.
111        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    // Last fallback: the caller may have passed a URL directly in
119    // place of a remote name (e.g. `git lfs push https://host/repo`).
120    // Treat anything that looks URL-shaped as the remote URL and run
121    // it through the same rewriter — same outcome as if they'd added
122    // a `remote.x.url = <URL>` entry first. Bare-SSH (`git@host:path`)
123    // also covers the SCP-style case the rewriter understands.
124    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    // No configured remote, no `lfs.url`, no URL-shaped argument.
133    // Last-resort fallback: parse `.git/FETCH_HEAD` for the URL of
134    // the most recent `git fetch <url>` and derive an endpoint from
135    // that. This is what makes `git archive` work in a bare repo
136    // that was populated via a one-off `git fetch <url> refs/...`
137    // without setting up a remote — the LFS smudge filter still has
138    // somewhere to look up objects (`t-no-remote` test 1). Skipped
139    // when the caller pinned a remote name, since that signals an
140    // intent that's incompatible with the FETCH_HEAD fallback.
141    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
152/// Best-effort parse of `.git/FETCH_HEAD` for the source URL of the
153/// most recent `git fetch`. Each line is `<sha1>\t[not-for-merge]\t
154/// branch 'X' of <url>` — we take the first match's `<url>` token.
155/// Returns `None` if the file is missing, empty, or unparseable.
156fn 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
178/// Build an `EndpointInfo` from a directly-configured LFS URL value
179/// (`GIT_LFS_URL`, `lfs.url`, `remote.X.lfsurl`). These values are
180/// returned to callers as-is — no `.git/info/lfs` derivation — but we
181/// still parse SSH metadata so `git lfs env` can echo the original
182/// SSH-shaped string back. Aliases are applied first so users can
183/// store something like `lfs.url = gh:org/repo` and have it resolve.
184fn 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
193/// `git remote` enumeration. Returns the configured remote names in
194/// definition order. Used by [`endpoint_for_remote`] to fall back from
195/// `origin` to the "only remote" when one exists.
196fn 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
213/// Quick syntactic check: does `s` look like one of the URL forms
214/// [`derive_lfs_url`] recognizes? Used to decide whether to treat a
215/// "remote name" argument as a literal URL.
216pub 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
228/// Read `remote.<name>.url` from the merged git config view (any
229/// scope, plus `include.path` and `GIT_CONFIG` resolution).
230///
231/// We don't currently honor `remote.<name>.pushurl` separately — that's a
232/// minor accuracy issue for `git push`-driven LFS uploads, captured in
233/// NOTES.md.
234fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
235    config::get_effective(cwd, &format!("remote.{remote}.url"))
236}
237
238/// Convert a clone URL into the matching LFS endpoint URL.
239///
240/// Rules (mirroring upstream's `NewEndpointFromCloneURL`):
241/// - `https://host/path` → `https://host/path.git/info/lfs`
242/// - `https://host/path.git` → `https://host/path.git/info/lfs`
243/// - `ssh://[user@]host[:port]/path` → `https://host/path.git/info/lfs`
244///   (port is dropped — LFS is HTTPS-only at the wire layer)
245/// - `git@host:path` → `https://host/path.git/info/lfs`
246/// - `git://host/path` → `https://host/path.git/info/lfs`
247/// - `file://path` → returned unchanged (used by upstream test infra)
248pub 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        // file:// URLs are local — pass through. The transfer/ basic
259        // adapter doesn't speak file:// today, but rewriting it would be
260        // worse than letting it fall over visibly.
261        return Ok(format!("file://{rest}"));
262    }
263
264    // URL schemes we handle by parsing.
265    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        // `git://` is the bare git protocol — LFS rides on top via HTTPS.
282        return Ok(append_lfs_path(&format!("https://{rest}")));
283    }
284
285    // Bare-SSH form: `[user@]host:path`. Distinguish from a Windows path
286    // (`C:\…`) by requiring the part before `:` to contain a `@` or be a
287    // hostname-shaped token (no backslash, no drive-letter pattern).
288    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
302/// Extract the SSH metadata from a remote URL — the `<user_and_host>`
303/// and `<path>` pieces `git lfs env` echoes back as
304/// `  SSH=<user_and_host>:<path>`. Returns `None` for URLs that don't
305/// look SSH-shaped (HTTP(S), git://, file://, plain paths).
306///
307/// Mirrors upstream's `EndpointFromSshUrl` / `EndpointFromBareSshUrl`
308/// for the metadata fields specifically; the URL itself is rewritten
309/// elsewhere (see [`derive_lfs_url`]).
310pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
311    let trimmed = rawurl.trim();
312    // Schemes upstream classifies as SSH: `ssh://`, `git+ssh://`,
313    // `ssh+git://`. Plain HTTP(S) and `git://` are not SSH; `file://`
314    // and bare paths aren't either.
315    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        // Drop the port component for the user_and_host string —
325        // upstream's `EndpointFromSshUrl` keeps user@host but stores
326        // the port separately.
327        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            // Leading `/` preserved for ssh:// to match upstream.
334            path: format!("/{}", path.trim_start_matches('/')),
335        });
336    }
337    // HTTP/HTTPS/git/file aren't SSH.
338    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    // Bare-SSH form: `[user@]host:path`. Strip leading `/` from path
346    // (upstream's `EndpointFromBareSshUrl` does this explicitly).
347    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
354/// Split `<host>:<path>` if `rawurl` looks like a bare SSH URL. Returns
355/// `None` if it doesn't (e.g. a plain filesystem path like `/foo/bar` or
356/// a Windows drive letter `C:\foo`).
357fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
358    // Reject things that look like local paths.
359    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    // A single ASCII letter before `:` is almost certainly a Windows
371    // drive letter, not a hostname. `git@C:/foo` would be malformed
372    // anyway.
373    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
379/// Convert the post-scheme portion of an `ssh://` URL into the matching
380/// HTTPS endpoint.
381fn 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    // Strip off any `user@` prefix.
390    let host_with_port = authority.split('@').next_back().unwrap_or(authority);
391    // Drop the port: `ssh://host:22/foo` → host portion is just `host`.
392    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
399/// Append the LFS protocol suffix to an HTTPS URL — `.git/info/lfs` if
400/// the URL doesn't already end in `.git`, just `/info/lfs` if it does.
401/// Trailing slash on the input URL is collapsed first.
402fn 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    // ---- derive_lfs_url ---------------------------------------------------
416
417    #[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        // `host:path/to/repo.git` is a valid bare SSH form.
476        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        // `C:\repos\foo` would otherwise look like `host:path`, but a
520        // single drive letter is not a valid hostname.
521        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    // ---- parse_ssh_url ----------------------------------------------------
531
532    #[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    // ---- endpoint_for_remote ---------------------------------------------
583    //
584    // Every test in this section reads `GIT_LFS_URL` indirectly via
585    // `endpoint_for_remote`. cargo runs tests in parallel by default, so we
586    // serialize them through a single mutex to keep one test's env-var
587    // mutation from leaking into another's expectations.
588
589    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        // PoisonError can happen if a previous test panicked while holding
596        // the lock — that's a test bug, but recovering keeps the rest of
597        // the suite useful instead of cascading-failing.
598        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        // Write a .lfsconfig file (it's just a git config file).
713        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}