use std::path::Path;
use crate::Error;
use crate::config::{self, ConfigScope};
const DEFAULT_REMOTE: &str = "origin";
#[derive(Debug, thiserror::Error)]
pub enum EndpointError {
#[error(transparent)]
Git(#[from] Error),
#[error("no LFS endpoint could be determined for remote {0:?}")]
Unresolved(String),
#[error("invalid remote URL {url:?}: {reason}")]
InvalidUrl { url: String, reason: String },
}
pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
let caller_specified_remote = remote.is_some();
let mut remote = remote.unwrap_or(DEFAULT_REMOTE).to_owned();
if let Some(v) = std::env::var_os("GIT_LFS_URL") {
let s = v.to_string_lossy().into_owned();
if !s.is_empty() {
return Ok(s);
}
}
if let Some(v) = config::get_effective(cwd, "lfs.url")? {
return Ok(v);
}
if !caller_specified_remote && remote_url(cwd, &remote)?.is_none() {
let remotes = list_remotes(cwd)?;
if remotes.len() == 1 {
remote = remotes.into_iter().next().expect("len==1");
}
}
let remote_lfsurl_key = format!("remote.{remote}.lfsurl");
if let Some(v) = config::get_effective(cwd, &remote_lfsurl_key)? {
return Ok(v);
}
if let Some(remote_url) = remote_url(cwd, &remote)? {
return derive_lfs_url(&remote_url);
}
if looks_like_url(&remote) {
return derive_lfs_url(&remote);
}
Err(EndpointError::Unresolved(remote))
}
fn list_remotes(cwd: &Path) -> Result<Vec<String>, Error> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(["remote"])
.output()
.map_err(Error::Io)?;
if !out.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}
fn looks_like_url(s: &str) -> bool {
s.starts_with("http://")
|| s.starts_with("https://")
|| s.starts_with("ssh://")
|| s.starts_with("git+ssh://")
|| s.starts_with("ssh+git://")
|| s.starts_with("git://")
|| s.starts_with("file://")
|| s.contains("://")
|| s.contains('@')
}
fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
let key = format!("remote.{remote}.url");
if let Some(v) = config::get(cwd, ConfigScope::Local, &key)? {
return Ok(Some(v));
}
if let Some(v) = config::get(cwd, ConfigScope::Global, &key)? {
return Ok(Some(v));
}
config::get(cwd, ConfigScope::System, &key)
}
pub fn derive_lfs_url(remote_url: &str) -> Result<String, EndpointError> {
let trimmed = remote_url.trim();
if trimmed.is_empty() {
return Err(EndpointError::InvalidUrl {
url: remote_url.to_owned(),
reason: "empty URL".into(),
});
}
if let Some(rest) = trimmed.strip_prefix("file://") {
return Ok(format!("file://{rest}"));
}
if let Some(rest) = trimmed.strip_prefix("https://") {
return Ok(append_lfs_path(&format!("https://{rest}")));
}
if let Some(rest) = trimmed.strip_prefix("http://") {
return Ok(append_lfs_path(&format!("http://{rest}")));
}
if let Some(rest) = trimmed.strip_prefix("ssh://") {
return ssh_to_https(rest, "ssh://");
}
if let Some(rest) = trimmed.strip_prefix("git+ssh://") {
return ssh_to_https(rest, "git+ssh://");
}
if let Some(rest) = trimmed.strip_prefix("ssh+git://") {
return ssh_to_https(rest, "ssh+git://");
}
if let Some(rest) = trimmed.strip_prefix("git://") {
return Ok(append_lfs_path(&format!("https://{rest}")));
}
if let Some((host_part, path)) = bare_ssh_split(trimmed) {
let host = host_part.split('@').next_back().unwrap_or(host_part);
return Ok(append_lfs_path(&format!(
"https://{host}/{}",
path.trim_start_matches('/'),
)));
}
Err(EndpointError::InvalidUrl {
url: remote_url.to_owned(),
reason: "unrecognized URL form".into(),
})
}
fn bare_ssh_split(rawurl: &str) -> Option<(&str, &str)> {
if rawurl.starts_with('/') || rawurl.starts_with('.') {
return None;
}
if rawurl.contains('\\') {
return None;
}
let (host, path) = rawurl.split_once(':')?;
if host.is_empty() || path.is_empty() {
return None;
}
if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
return None;
}
Some((host, path))
}
fn ssh_to_https(rest: &str, scheme_for_error: &str) -> Result<String, EndpointError> {
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
if authority.is_empty() {
return Err(EndpointError::InvalidUrl {
url: format!("{scheme_for_error}{rest}"),
reason: "missing host".into(),
});
}
let host_with_port = authority.split('@').next_back().unwrap_or(authority);
let host = host_with_port.split(':').next().unwrap_or(host_with_port);
Ok(append_lfs_path(&format!(
"https://{host}/{}",
path.trim_start_matches('/'),
)))
}
fn append_lfs_path(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
if trimmed.ends_with(".git") {
format!("{trimmed}/info/lfs")
} else {
format!("{trimmed}.git/info/lfs")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_url_without_dotgit_gets_dotgit_info_lfs() {
assert_eq!(
derive_lfs_url("https://git-server.com/foo/bar").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn https_url_with_dotgit_gets_just_info_lfs() {
assert_eq!(
derive_lfs_url("https://git-server.com/foo/bar.git").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn http_url_is_preserved_as_http() {
assert_eq!(
derive_lfs_url("http://localhost:8080/foo/bar").unwrap(),
"http://localhost:8080/foo/bar.git/info/lfs",
);
}
#[test]
fn trailing_slash_is_collapsed() {
assert_eq!(
derive_lfs_url("https://git-server.com/foo/bar/").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn ssh_url_becomes_https() {
assert_eq!(
derive_lfs_url("ssh://git-server.com/foo/bar.git").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn ssh_url_strips_user_and_port() {
assert_eq!(
derive_lfs_url("ssh://git@git-server.com:22/foo/bar.git").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn bare_ssh_url_becomes_https() {
assert_eq!(
derive_lfs_url("git@github.com:user/repo.git").unwrap(),
"https://github.com/user/repo.git/info/lfs",
);
}
#[test]
fn bare_ssh_without_user_becomes_https() {
assert_eq!(
derive_lfs_url("git-server.com:foo/bar.git").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn git_protocol_url_becomes_https() {
assert_eq!(
derive_lfs_url("git://git-server.com/foo/bar.git").unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
#[test]
fn ssh_git_variants_are_recognized() {
for prefix in ["git+ssh", "ssh+git"] {
let url = format!("{prefix}://git@git-server.com/foo/bar.git");
assert_eq!(
derive_lfs_url(&url).unwrap(),
"https://git-server.com/foo/bar.git/info/lfs",
);
}
}
#[test]
fn file_url_is_passed_through_unchanged() {
assert_eq!(
derive_lfs_url("file:///srv/repos/foo.git").unwrap(),
"file:///srv/repos/foo.git",
);
}
#[test]
fn empty_url_errors() {
assert!(matches!(
derive_lfs_url(""),
Err(EndpointError::InvalidUrl { .. }),
));
}
#[test]
fn windows_path_is_not_misread_as_ssh() {
assert!(derive_lfs_url("C:\\repos\\foo").is_err());
}
#[test]
fn relative_path_is_rejected_not_treated_as_ssh() {
assert!(derive_lfs_url("./relative/path").is_err());
assert!(derive_lfs_url("/abs/path").is_err());
}
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn lock_env() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn fresh_repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let s = std::process::Command::new("git")
.args(["init", "--quiet"])
.arg(tmp.path())
.status()
.unwrap();
assert!(s.success());
tmp
}
fn git_in(repo: &Path, args: &[&str]) {
let s = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.status()
.unwrap();
assert!(s.success(), "git {args:?} failed");
}
#[test]
fn endpoint_prefers_explicit_lfs_url() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
git_in(repo.path(), &["config", "--local", "lfs.url", "https://example.com/lfs"]);
git_in(
repo.path(),
&["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
);
let url = endpoint_for_remote(repo.path(), None).unwrap();
assert_eq!(url, "https://example.com/lfs");
}
#[test]
fn endpoint_uses_remote_lfsurl_when_no_lfs_url() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
git_in(
repo.path(),
&["config", "--local", "remote.origin.lfsurl", "https://lfs.dev/repo"],
);
git_in(
repo.path(),
&["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
);
let url = endpoint_for_remote(repo.path(), None).unwrap();
assert_eq!(url, "https://lfs.dev/repo");
}
#[test]
fn endpoint_derives_from_remote_url() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
git_in(
repo.path(),
&["config", "--local", "remote.origin.url", "git@github.com:x/y.git"],
);
let url = endpoint_for_remote(repo.path(), None).unwrap();
assert_eq!(url, "https://github.com/x/y.git/info/lfs");
}
#[test]
fn endpoint_uses_named_remote_over_origin() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
git_in(
repo.path(),
&["config", "--local", "remote.upstream.url", "https://other.example.com/foo"],
);
let url = endpoint_for_remote(repo.path(), Some("upstream")).unwrap();
assert_eq!(url, "https://other.example.com/foo.git/info/lfs");
}
#[test]
fn endpoint_reads_lfsconfig_at_repo_root() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
std::fs::write(
repo.path().join(".lfsconfig"),
"[lfs]\n\turl = https://from-lfsconfig.example/\n",
)
.unwrap();
let url = endpoint_for_remote(repo.path(), None).unwrap();
assert_eq!(url, "https://from-lfsconfig.example/");
}
#[test]
fn endpoint_local_config_overrides_lfsconfig() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
std::fs::write(
repo.path().join(".lfsconfig"),
"[lfs]\n\turl = https://from-lfsconfig.example/\n",
)
.unwrap();
git_in(
repo.path(),
&["config", "--local", "lfs.url", "https://from-localconfig.example/"],
);
let url = endpoint_for_remote(repo.path(), None).unwrap();
assert_eq!(url, "https://from-localconfig.example/");
}
#[test]
fn endpoint_unresolved_when_nothing_configured() {
let _g = lock_env();
unsafe { std::env::remove_var("GIT_LFS_URL") };
let repo = fresh_repo();
let err = endpoint_for_remote(repo.path(), None).unwrap_err();
assert!(matches!(err, EndpointError::Unresolved(_)));
}
#[test]
fn endpoint_env_var_wins_over_everything() {
let _g = lock_env();
let repo = fresh_repo();
git_in(repo.path(), &["config", "--local", "lfs.url", "https://lo.cal/lfs"]);
let prev = std::env::var_os("GIT_LFS_URL");
unsafe { std::env::set_var("GIT_LFS_URL", "https://from-env.example/") };
let url = endpoint_for_remote(repo.path(), None).unwrap();
assert_eq!(url, "https://from-env.example/");
unsafe {
match prev {
Some(v) => std::env::set_var("GIT_LFS_URL", v),
None => std::env::remove_var("GIT_LFS_URL"),
}
}
}
}