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    Err(EndpointError::Unresolved(remote))
133}
134
135/// Build an `EndpointInfo` from a directly-configured LFS URL value
136/// (`GIT_LFS_URL`, `lfs.url`, `remote.X.lfsurl`). These values are
137/// returned to callers as-is — no `.git/info/lfs` derivation — but we
138/// still parse SSH metadata so `git lfs env` can echo the original
139/// SSH-shaped string back. Aliases are applied first so users can
140/// store something like `lfs.url = gh:org/repo` and have it resolve.
141fn direct_endpoint(cwd: &Path, value: &str) -> Result<EndpointInfo, EndpointError> {
142    let rewritten = aliases::rewrite(cwd, value)?;
143    let ssh = parse_ssh_url(&rewritten);
144    Ok(EndpointInfo {
145        url: rewritten,
146        ssh,
147    })
148}
149
150/// `git remote` enumeration. Returns the configured remote names in
151/// definition order. Used by [`endpoint_for_remote`] to fall back from
152/// `origin` to the "only remote" when one exists.
153fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
154    let out = std::process::Command::new("git")
155        .arg("-C")
156        .arg(cwd)
157        .args(["remote"])
158        .output()
159        .map_err(Error::Io)?;
160    if !out.status.success() {
161        return Ok(Vec::new());
162    }
163    Ok(String::from_utf8_lossy(&out.stdout)
164        .lines()
165        .filter(|l| !l.is_empty())
166        .map(str::to_owned)
167        .collect())
168}
169
170/// Quick syntactic check: does `s` look like one of the URL forms
171/// [`derive_lfs_url`] recognizes? Used to decide whether to treat a
172/// "remote name" argument as a literal URL.
173pub fn looks_like_url(s: &str) -> bool {
174    s.starts_with("http://")
175        || s.starts_with("https://")
176        || s.starts_with("ssh://")
177        || s.starts_with("git+ssh://")
178        || s.starts_with("ssh+git://")
179        || s.starts_with("git://")
180        || s.starts_with("file://")
181        || s.contains("://")
182        || s.contains('@')
183}
184
185/// Read `remote.<name>.url` from the merged git config view (any
186/// scope, plus `include.path` and `GIT_CONFIG` resolution).
187///
188/// We don't currently honor `remote.<name>.pushurl` separately — that's a
189/// minor accuracy issue for `git push`-driven LFS uploads, captured in
190/// NOTES.md.
191fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
192    config::get_effective(cwd, &format!("remote.{remote}.url"))
193}
194
195/// Convert a clone URL into the matching LFS endpoint URL.
196///
197/// Rules (mirroring upstream's `NewEndpointFromCloneURL`):
198/// - `https://host/path` → `https://host/path.git/info/lfs`
199/// - `https://host/path.git` → `https://host/path.git/info/lfs`
200/// - `ssh://[user@]host[:port]/path` → `https://host/path.git/info/lfs`
201///   (port is dropped — LFS is HTTPS-only at the wire layer)
202/// - `git@host:path` → `https://host/path.git/info/lfs`
203/// - `git://host/path` → `https://host/path.git/info/lfs`
204/// - `file://path` → returned unchanged (used by upstream test infra)
205pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
206    let trimmed = remote_url.trim();
207    if trimmed.is_empty() {
208        return Err(EndpointError::InvalidUrl {
209            url: remote_url.to_owned(),
210            reason: "empty URL".into(),
211        });
212    }
213
214    if let Some(rest) = trimmed.strip_prefix("file://") {
215        // file:// URLs are local — pass through. The transfer/ basic
216        // adapter doesn't speak file:// today, but rewriting it would be
217        // worse than letting it fall over visibly.
218        return Ok(format!("file://{rest}"));
219    }
220
221    // URL schemes we handle by parsing.
222    if let Some(rest) = trimmed.strip_prefix("https://") {
223        return Ok(append_lfs_path(&format!("https://{rest}")));
224    }
225    if let Some(rest) = trimmed.strip_prefix("http://") {
226        return Ok(append_lfs_path(&format!("http://{rest}")));
227    }
228    if let Some(rest) = trimmed.strip_prefix("ssh://") {
229        return ssh_to_https(rest, "ssh://");
230    }
231    if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
232        return ssh_to_https(rest, "git+ssh://");
233    }
234    if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
235        return ssh_to_https(rest, "ssh+git://");
236    }
237    if let Some(rest) = trimmed.strip_prefix("git://") {
238        // `git://` is the bare git protocol — LFS rides on top via HTTPS.
239        return Ok(append_lfs_path(&format!("https://{rest}")));
240    }
241
242    // Bare-SSH form: `[user@]host:path`. Distinguish from a Windows path
243    // (`C:\…`) by requiring the part before `:` to contain a `@` or be a
244    // hostname-shaped token (no backslash, no drive-letter pattern).
245    if let Some((host_part, path)) = bare_ssh_split(trimmed) {
246        let host = host_part.split('@').next_back().unwrap_or(host_part);
247        return Ok(append_lfs_path(&format!(
248            "https://{host}/{}",
249            path.trim_start_matches('/'),
250        )));
251    }
252
253    Err(EndpointError::InvalidUrl {
254        url: remote_url.to_owned(),
255        reason: "unrecognized URL form".into(),
256    })
257}
258
259/// Extract the SSH metadata from a remote URL — the `<user_and_host>`
260/// and `<path>` pieces `git lfs env` echoes back as
261/// `  SSH=<user_and_host>:<path>`. Returns `None` for URLs that don't
262/// look SSH-shaped (HTTP(S), git://, file://, plain paths).
263///
264/// Mirrors upstream's `EndpointFromSshUrl` / `EndpointFromBareSshUrl`
265/// for the metadata fields specifically; the URL itself is rewritten
266/// elsewhere (see [`derive_lfs_url`]).
267pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
268    let trimmed = rawurl.trim();
269    // Schemes upstream classifies as SSH: `ssh://`, `git+ssh://`,
270    // `ssh+git://`. Plain HTTP(S) and `git://` are not SSH; `file://`
271    // and bare paths aren't either.
272    let ssh_rest = trimmed
273        .strip_prefix("ssh://")
274        .or_else(|| trimmed.strip_prefix("git+ssh://"))
275        .or_else(|| trimmed.strip_prefix("ssh+git://"));
276    if let Some(rest) = ssh_rest {
277        let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
278        if authority.is_empty() {
279            return None;
280        }
281        // Drop the port component for the user_and_host string —
282        // upstream's `EndpointFromSshUrl` keeps user@host but stores
283        // the port separately.
284        let user_and_host = authority
285            .rsplit_once(':')
286            .map(|(host, _port)| host)
287            .unwrap_or(authority);
288        return Some(SshInfo {
289            user_and_host: user_and_host.to_owned(),
290            // Leading `/` preserved for ssh:// to match upstream.
291            path: format!("/{}", path.trim_start_matches('/')),
292        });
293    }
294    // HTTP/HTTPS/git/file aren't SSH.
295    if trimmed.starts_with("http://")
296        || trimmed.starts_with("https://")
297        || trimmed.starts_with("git://")
298        || trimmed.starts_with("file://")
299    {
300        return None;
301    }
302    // Bare-SSH form: `[user@]host:path`. Strip leading `/` from path
303    // (upstream's `EndpointFromBareSshUrl` does this explicitly).
304    let (host, path) = bare_ssh_split(trimmed)?;
305    Some(SshInfo {
306        user_and_host: host.to_owned(),
307        path: path.trim_start_matches('/').to_owned(),
308    })
309}
310
311/// Split `<host>:<path>` if `rawurl` looks like a bare SSH URL. Returns
312/// `None` if it doesn't (e.g. a plain filesystem path like `/foo/bar` or
313/// a Windows drive letter `C:\foo`).
314fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
315    // Reject things that look like local paths.
316    if rawurl.starts_with('/') || rawurl.starts_with('.') {
317        return None;
318    }
319    if rawurl.contains('\\') {
320        return None;
321    }
322
323    let (host, path) = rawurl.split_once(':')?;
324    if host.is_empty() || path.is_empty() {
325        return None;
326    }
327    // A single ASCII letter before `:` is almost certainly a Windows
328    // drive letter, not a hostname. `git@C:/foo` would be malformed
329    // anyway.
330    if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
331        return None;
332    }
333    Some((host, path))
334}
335
336/// Convert the post-scheme portion of an `ssh://` URL into the matching
337/// HTTPS endpoint.
338fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
339    let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
340    if authority.is_empty() {
341        return Err(EndpointError::InvalidUrl {
342            url: format!("{scheme_for_error}{rest}"),
343            reason: "missing host".into(),
344        });
345    }
346    // Strip off any `user@` prefix.
347    let host_with_port = authority.split('@').next_back().unwrap_or(authority);
348    // Drop the port: `ssh://host:22/foo` → host portion is just `host`.
349    let host = host_with_port.split(':').next().unwrap_or(host_with_port);
350    Ok(append_lfs_path(&format!(
351        "https://{host}/{}",
352        path.trim_start_matches('/'),
353    )))
354}
355
356/// Append the LFS protocol suffix to an HTTPS URL — `.git/info/lfs` if
357/// the URL doesn't already end in `.git`, just `/info/lfs` if it does.
358/// Trailing slash on the input URL is collapsed first.
359fn append_lfs_path(url: &str) -> String {
360    let trimmed = url.trim_end_matches('/');
361    if trimmed.ends_with(".git") {
362        format!("{trimmed}/info/lfs")
363    } else {
364        format!("{trimmed}.git/info/lfs")
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    // ---- derive_lfs_url ---------------------------------------------------
373
374    #[test]
375    fn https_url_without_dotgit_gets_dotgit_info_lfs() {
376        assert_eq!(
377            derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
378            "https://git-server.com/foo/bar.git/info/lfs",
379        );
380    }
381
382    #[test]
383    fn https_url_with_dotgit_gets_just_info_lfs() {
384        assert_eq!(
385            derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
386            "https://git-server.com/foo/bar.git/info/lfs",
387        );
388    }
389
390    #[test]
391    fn http_url_is_preserved_as_http() {
392        assert_eq!(
393            derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
394            "http://localhost:8080/foo/bar.git/info/lfs",
395        );
396    }
397
398    #[test]
399    fn trailing_slash_is_collapsed() {
400        assert_eq!(
401            derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
402            "https://git-server.com/foo/bar.git/info/lfs",
403        );
404    }
405
406    #[test]
407    fn ssh_url_becomes_https() {
408        assert_eq!(
409            derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
410            "https://git-server.com/foo/bar.git/info/lfs",
411        );
412    }
413
414    #[test]
415    fn ssh_url_strips_user_and_port() {
416        assert_eq!(
417            derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
418            "https://git-server.com/foo/bar.git/info/lfs",
419        );
420    }
421
422    #[test]
423    fn bare_ssh_url_becomes_https() {
424        assert_eq!(
425            derive_lfs_url("git@github.com:user/repo.git").unwrap(),
426            "https://github.com/user/repo.git/info/lfs",
427        );
428    }
429
430    #[test]
431    fn bare_ssh_without_user_becomes_https() {
432        // `host:path/to/repo.git` is a valid bare SSH form.
433        assert_eq!(
434            derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
435            "https://git-server.com/foo/bar.git/info/lfs",
436        );
437    }
438
439    #[test]
440    fn git_protocol_url_becomes_https() {
441        assert_eq!(
442            derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
443            "https://git-server.com/foo/bar.git/info/lfs",
444        );
445    }
446
447    #[test]
448    fn ssh_git_variants_are_recognized() {
449        for prefix in ["git+ssh", "ssh+git"] {
450            let url = format!("{prefix}://git@git-server.com/foo/bar.git");
451            assert_eq!(
452                derive_lfs_url(&url).unwrap(),
453                "https://git-server.com/foo/bar.git/info/lfs",
454            );
455        }
456    }
457
458    #[test]
459    fn file_url_is_passed_through_unchanged() {
460        assert_eq!(
461            derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
462            "file:///srv/repos/foo.git",
463        );
464    }
465
466    #[test]
467    fn empty_url_errors() {
468        assert!(matches!(
469            derive_lfs_url(""),
470            Err(EndpointError::InvalidUrl { .. }),
471        ));
472    }
473
474    #[test]
475    fn windows_path_is_not_misread_as_ssh() {
476        // `C:\repos\foo` would otherwise look like `host:path`, but a
477        // single drive letter is not a valid hostname.
478        assert!(derive_lfs_url("C:\\repos\\foo").is_err());
479    }
480
481    #[test]
482    fn relative_path_is_rejected_not_treated_as_ssh() {
483        assert!(derive_lfs_url("./relative/path").is_err());
484        assert!(derive_lfs_url("/abs/path").is_err());
485    }
486
487    // ---- parse_ssh_url ----------------------------------------------------
488
489    #[test]
490    fn ssh_metadata_for_bare_user_at_host() {
491        let info = parse_ssh_url("git@github.com:user/repo.git").unwrap();
492        assert_eq!(info.user_and_host, "git@github.com");
493        assert_eq!(info.path, "user/repo.git");
494    }
495
496    #[test]
497    fn ssh_metadata_for_bare_host_only() {
498        let info = parse_ssh_url("badalias:rest").unwrap();
499        assert_eq!(info.user_and_host, "badalias");
500        assert_eq!(info.path, "rest");
501    }
502
503    #[test]
504    fn ssh_metadata_for_ssh_scheme_keeps_leading_slash() {
505        let info = parse_ssh_url("ssh://git@host.example/path/to/repo.git").unwrap();
506        assert_eq!(info.user_and_host, "git@host.example");
507        assert_eq!(info.path, "/path/to/repo.git");
508    }
509
510    #[test]
511    fn ssh_metadata_for_ssh_scheme_drops_port_from_host() {
512        let info = parse_ssh_url("ssh://git@host.example:2222/path").unwrap();
513        assert_eq!(info.user_and_host, "git@host.example");
514        assert_eq!(info.path, "/path");
515    }
516
517    #[test]
518    fn ssh_metadata_for_https_returns_none() {
519        assert!(parse_ssh_url("https://host.example/path").is_none());
520        assert!(parse_ssh_url("http://host.example/path").is_none());
521    }
522
523    #[test]
524    fn ssh_metadata_for_git_protocol_returns_none() {
525        assert!(parse_ssh_url("git://host.example/path").is_none());
526    }
527
528    #[test]
529    fn ssh_metadata_for_file_url_returns_none() {
530        assert!(parse_ssh_url("file:///srv/repos/foo.git").is_none());
531    }
532
533    #[test]
534    fn ssh_metadata_for_local_path_returns_none() {
535        assert!(parse_ssh_url("/abs/path").is_none());
536        assert!(parse_ssh_url("./relative").is_none());
537    }
538
539    // ---- endpoint_for_remote ---------------------------------------------
540    //
541    // Every test in this section reads `GIT_LFS_URL` indirectly via
542    // `endpoint_for_remote`. cargo runs tests in parallel by default, so we
543    // serialize them through a single mutex to keep one test's env-var
544    // mutation from leaking into another's expectations.
545
546    use std::sync::{Mutex, MutexGuard};
547    use tempfile::TempDir;
548
549    static ENV_LOCK: Mutex<()> = Mutex::new(());
550
551    fn lock_env() -> MutexGuard<'static, ()> {
552        // PoisonError can happen if a previous test panicked while holding
553        // the lock — that's a test bug, but recovering keeps the rest of
554        // the suite useful instead of cascading-failing.
555        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
556    }
557
558    fn fresh_repo() -> TempDir {
559        let tmp = TempDir::new().unwrap();
560        let s = std::process::Command::new("git")
561            .args(["init", "--quiet"])
562            .arg(tmp.path())
563            .status()
564            .unwrap();
565        assert!(s.success());
566        tmp
567    }
568
569    fn git_in(repo: &Path, args: &[&str]) {
570        let s = std::process::Command::new("git")
571            .arg("-C")
572            .arg(repo)
573            .args(args)
574            .status()
575            .unwrap();
576        assert!(s.success(), "git {args:?} failed");
577    }
578
579    #[test]
580    fn endpoint_prefers_explicit_lfs_url() {
581        let _g = lock_env();
582        unsafe { std::env::remove_var("GIT_LFS_URL") };
583        let repo = fresh_repo();
584        git_in(
585            repo.path(),
586            &["config", "--local", "lfs.url", "https://example.com/lfs"],
587        );
588        git_in(
589            repo.path(),
590            &[
591                "config",
592                "--local",
593                "remote.origin.url",
594                "git@github.com:x/y.git",
595            ],
596        );
597        let url = endpoint_for_remote(repo.path(), None).unwrap();
598        assert_eq!(url, "https://example.com/lfs");
599    }
600
601    #[test]
602    fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
603        let _g = lock_env();
604        unsafe { std::env::remove_var("GIT_LFS_URL") };
605        let repo = fresh_repo();
606        git_in(
607            repo.path(),
608            &[
609                "config",
610                "--local",
611                "remote.origin.lfsurl",
612                "https://lfs.dev/repo",
613            ],
614        );
615        git_in(
616            repo.path(),
617            &[
618                "config",
619                "--local",
620                "remote.origin.url",
621                "git@github.com:x/y.git",
622            ],
623        );
624        let url = endpoint_for_remote(repo.path(), None).unwrap();
625        assert_eq!(url, "https://lfs.dev/repo");
626    }
627
628    #[test]
629    fn endpoint_derives_from_remote_url() {
630        let _g = lock_env();
631        unsafe { std::env::remove_var("GIT_LFS_URL") };
632        let repo = fresh_repo();
633        git_in(
634            repo.path(),
635            &[
636                "config",
637                "--local",
638                "remote.origin.url",
639                "git@github.com:x/y.git",
640            ],
641        );
642        let url = endpoint_for_remote(repo.path(), None).unwrap();
643        assert_eq!(url, "https://github.com/x/y.git/info/lfs");
644    }
645
646    #[test]
647    fn endpoint_uses_named_remote_over_origin() {
648        let _g = lock_env();
649        unsafe { std::env::remove_var("GIT_LFS_URL") };
650        let repo = fresh_repo();
651        git_in(
652            repo.path(),
653            &[
654                "config",
655                "--local",
656                "remote.upstream.url",
657                "https://other.example.com/foo",
658            ],
659        );
660        let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
661        assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
662    }
663
664    #[test]
665    fn endpoint_reads_lfsconfig_at_repo_root() {
666        let _g = lock_env();
667        unsafe { std::env::remove_var("GIT_LFS_URL") };
668        let repo = fresh_repo();
669        // Write a .lfsconfig file (it's just a git config file).
670        std::fs::write(
671            repo.path().join(".lfsconfig"),
672            "[lfs]\n\turl = https://from-lfsconfig.example/\n",
673        )
674        .unwrap();
675        let url = endpoint_for_remote(repo.path(), None).unwrap();
676        assert_eq!(url, "https://from-lfsconfig.example/");
677    }
678
679    #[test]
680    fn endpoint_local_config_overrides_lfsconfig() {
681        let _g = lock_env();
682        unsafe { std::env::remove_var("GIT_LFS_URL") };
683        let repo = fresh_repo();
684        std::fs::write(
685            repo.path().join(".lfsconfig"),
686            "[lfs]\n\turl = https://from-lfsconfig.example/\n",
687        )
688        .unwrap();
689        git_in(
690            repo.path(),
691            &[
692                "config",
693                "--local",
694                "lfs.url",
695                "https://from-localconfig.example/",
696            ],
697        );
698        let url = endpoint_for_remote(repo.path(), None).unwrap();
699        assert_eq!(url, "https://from-localconfig.example/");
700    }
701
702    #[test]
703    fn endpoint_unresolved_when_nothing_configured() {
704        let _g = lock_env();
705        unsafe { std::env::remove_var("GIT_LFS_URL") };
706        let repo = fresh_repo();
707        let err = endpoint_for_remote(repo.path(), None).unwrap_err();
708        assert!(matches!(err, EndpointError::Unresolved(_)));
709    }
710
711    #[test]
712    fn endpoint_env_var_wins_over_everything() {
713        let _g = lock_env();
714        let repo = fresh_repo();
715        git_in(
716            repo.path(),
717            &["config", "--local", "lfs.url", "https://lo.cal/lfs"],
718        );
719
720        let prev = std::env::var_os("GIT_LFS_URL");
721        unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
722        let url = endpoint_for_remote(repo.path(), None).unwrap();
723        assert_eq!(url, "https://from-env.example/");
724        unsafe {
725            match prev {
726                Some(v) => std::env::set_var("GIT_LFS_URL", v),
727                None => std::env::remove_var("GIT_LFS_URL"),
728            }
729        }
730    }
731}