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    /// Port specified in the URL (e.g. `ssh://host:22/path`). `None` for
54    /// bare SSH (`git@host:path`) and for `ssh://` URLs without a port.
55    /// Threaded into `git-lfs-authenticate` invocations as `-p <port>`.
56    pub port: Option<String>,
57}
58
59/// LFS endpoint resolution result with the optional SSH metadata
60/// upstream's `git lfs env` displays alongside the HTTPS-equivalent
61/// endpoint URL.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct EndpointInfo {
64    pub url: String,
65    pub ssh: Option<SshInfo>,
66}
67
68/// Resolve the LFS endpoint URL for `cwd` + `remote`. Pass `None` for the
69/// default (`origin`, with a "single remote" fallback when origin doesn't
70/// exist and exactly one other remote does).
71pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
72    Ok(resolve_endpoint(cwd, remote)?.url)
73}
74
75/// Like [`endpoint_for_remote`], but also returns the SSH metadata
76/// when the underlying URL was SSH-shaped. Used by `git lfs env` to
77/// render the `  SSH=<user_and_host>:<path>` line alongside the
78/// HTTPS-equivalent endpoint.
79pub 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    // When the caller didn't pin a remote name and `origin` doesn't
95    // exist, fall back to the only configured remote. Mirrors
96    // upstream's `git remote` discovery in `lfsfetch` and is what
97    // `t-fetch.sh::fetch with no origin remote` exercises (rename
98    // origin → something, then bare `git lfs fetch`).
99    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        // Apply insteadOf rewrite *before* deriving the LFS suffix so
113        // a `gh:org/repo` style alias resolves to the real URL first
114        // and `derive_lfs_url` sees a URL it can parse.
115        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    // Last fallback: the caller may have passed a URL directly in
123    // place of a remote name (e.g. `git lfs push https://host/repo`).
124    // Treat anything that looks URL-shaped as the remote URL and run
125    // it through the same rewriter — same outcome as if they'd added
126    // a `remote.x.url = <URL>` entry first. Bare-SSH (`git@host:path`)
127    // also covers the SCP-style case the rewriter understands.
128    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    // No configured remote, no `lfs.url`, no URL-shaped argument.
137    // Last-resort fallback: parse `.git/FETCH_HEAD` for the URL of
138    // the most recent `git fetch <url>` and derive an endpoint from
139    // that. This is what makes `git archive` work in a bare repo
140    // that was populated via a one-off `git fetch <url> refs/...`
141    // without setting up a remote — the LFS smudge filter still has
142    // somewhere to look up objects (`t-no-remote` test 1). Skipped
143    // when the caller pinned a remote name, since that signals an
144    // intent that's incompatible with the FETCH_HEAD fallback.
145    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
156/// Best-effort parse of `.git/FETCH_HEAD` for the source URL of the
157/// most recent `git fetch`. Each line is `<sha1>\t[not-for-merge]\t
158/// branch 'X' of <url>` — we take the first match's `<url>` token.
159/// Returns `None` if the file is missing, empty, or unparseable.
160fn 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
182/// Build an `EndpointInfo` from a directly-configured LFS URL value
183/// (`GIT_LFS_URL`, `lfs.url`, `remote.X.lfsurl`). These values are
184/// returned to callers as-is — no `.git/info/lfs` derivation — but we
185/// still parse SSH metadata so `git lfs env` can echo the original
186/// SSH-shaped string back. Aliases are applied first so users can
187/// store something like `lfs.url = gh:org/repo` and have it resolve.
188fn 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
197/// `git remote` enumeration. Returns the configured remote names in
198/// definition order. Used by [`endpoint_for_remote`] to fall back from
199/// `origin` to the "only remote" when one exists.
200fn 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
217/// Quick syntactic check: does `s` look like one of the URL forms
218/// [`derive_lfs_url`] recognizes? Used to decide whether to treat a
219/// "remote name" argument as a literal URL.
220pub 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
232/// Read `remote.<name>.url` from the merged git config view (any
233/// scope, plus `include.path` and `GIT_CONFIG` resolution).
234///
235/// We don't currently honor `remote.<name>.pushurl` separately — that's a
236/// minor accuracy issue for `git push`-driven LFS uploads, captured in
237/// NOTES.md.
238pub fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
239    config::get_effective(cwd, &format!("remote.{remote}.url"))
240}
241
242/// Convert a clone URL into the matching LFS endpoint URL.
243///
244/// Rules (mirroring upstream's `NewEndpointFromCloneURL`):
245/// - `https://host/path` → `https://host/path.git/info/lfs`
246/// - `https://host/path.git` → `https://host/path.git/info/lfs`
247/// - `ssh://[user@]host[:port]/path` → `https://host/path.git/info/lfs`
248///   (port is dropped — LFS is HTTPS-only at the wire layer)
249/// - `git@host:path` → `https://host/path.git/info/lfs`
250/// - `git://host/path` → `https://host/path.git/info/lfs`
251/// - `file://path` → returned unchanged (used by upstream test infra)
252pub 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        // file:// URLs are local — pass through. The transfer/ basic
263        // adapter doesn't speak file:// today, but rewriting it would be
264        // worse than letting it fall over visibly.
265        return Ok(format!("file://{rest}"));
266    }
267
268    // URL schemes we handle by parsing.
269    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        // `git://` is the bare git protocol — LFS rides on top via HTTPS.
286        return Ok(append_lfs_path(&format!("https://{rest}")));
287    }
288
289    // Bare-SSH form: `[user@]host:path`. Distinguish from a Windows path
290    // (`C:\…`) by requiring the part before `:` to contain a `@` or be a
291    // hostname-shaped token (no backslash, no drive-letter pattern).
292    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
306/// Extract the SSH metadata from a remote URL — the `<user_and_host>`
307/// and `<path>` pieces `git lfs env` echoes back as
308/// `  SSH=<user_and_host>:<path>`. Returns `None` for URLs that don't
309/// look SSH-shaped (HTTP(S), git://, file://, plain paths).
310///
311/// Mirrors upstream's `EndpointFromSshUrl` / `EndpointFromBareSshUrl`
312/// for the metadata fields specifically; the URL itself is rewritten
313/// elsewhere (see [`derive_lfs_url`]).
314pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
315    let trimmed = rawurl.trim();
316    // Schemes upstream classifies as SSH: `ssh://`, `git+ssh://`,
317    // `ssh+git://`. Plain HTTP(S) and `git://` are not SSH; `file://`
318    // and bare paths aren't either.
319    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        // Split port from user_and_host — upstream's `EndpointFromSshUrl`
329        // keeps user@host but stores the port separately so it can pass
330        // `-p <port>` to ssh.
331        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            // Leading `/` preserved for ssh:// to match upstream.
338            path: format!("/{}", path.trim_start_matches('/')),
339            port,
340        });
341    }
342    // HTTP/HTTPS/git/file aren't SSH.
343    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    // Bare-SSH form: `[user@]host:path`. Strip leading `/` from path
351    // (upstream's `EndpointFromBareSshUrl` does this explicitly). Bare
352    // SSH has no port — that's an `ssh://` form thing.
353    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
361/// Split `<host>:<path>` if `rawurl` looks like a bare SSH URL. Returns
362/// `None` if it doesn't (e.g. a plain filesystem path like `/foo/bar` or
363/// a Windows drive letter `C:\foo`).
364fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
365    // Reject things that look like local paths.
366    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    // A single ASCII letter before `:` is almost certainly a Windows
378    // drive letter, not a hostname. `git@C:/foo` would be malformed
379    // anyway.
380    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
386/// Convert the post-scheme portion of an `ssh://` URL into the matching
387/// HTTPS endpoint.
388fn 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    // Strip off any `user@` prefix.
397    let host_with_port = authority.split('@').next_back().unwrap_or(authority);
398    // Drop the port: `ssh://host:22/foo` → host portion is just `host`.
399    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
406/// Append the LFS protocol suffix to an HTTPS URL — `.git/info/lfs` if
407/// the URL doesn't already end in `.git`, just `/info/lfs` if it does.
408/// Trailing slash on the input URL is collapsed first.
409fn 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    // ---- derive_lfs_url ---------------------------------------------------
423
424    #[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        // `host:path/to/repo.git` is a valid bare SSH form.
483        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        // `C:\repos\foo` would otherwise look like `host:path`, but a
527        // single drive letter is not a valid hostname.
528        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    // ---- parse_ssh_url ----------------------------------------------------
547
548    #[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    // ---- endpoint_for_remote ---------------------------------------------
599    //
600    // Every test in this section reads `GIT_LFS_URL` indirectly via
601    // `endpoint_for_remote`. cargo runs tests in parallel by default, so we
602    // serialize them through a single mutex to keep one test's env-var
603    // mutation from leaking into another's expectations.
604
605    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        // PoisonError can happen if a previous test panicked while holding
612        // the lock — that's a test bug, but recovering keeps the rest of
613        // the suite useful instead of cascading-failing.
614        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        // Write a .lfsconfig file (it's just a git config file).
729        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}