use std::path::Path;
use crate::Error;
use crate::aliases;
use crate::config;
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 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshInfo {
pub user_and_host: String,
pub path: String,
pub port: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointInfo {
pub url: String,
pub ssh: Option<SshInfo>,
}
pub fn endpoint_for_remote(cwd: &Path, remote: Option<&str>) -> Result<String, EndpointError> {
Ok(resolve_endpoint(cwd, remote)?.url)
}
pub fn resolve_endpoint(cwd: &Path, remote: Option<&str>) -> Result<EndpointInfo, 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 direct_endpoint(cwd, &s);
}
}
if let Some(v) = config::get_effective(cwd, "lfs.url")? {
return direct_endpoint(cwd, &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 direct_endpoint(cwd, &v);
}
if let Some(remote_url) = remote_url(cwd, &remote)? {
let rewritten = aliases::rewrite(cwd, &remote_url)?;
return Ok(EndpointInfo {
url: derive_lfs_url(&rewritten)?,
ssh: parse_ssh_url(&rewritten),
});
}
if looks_like_url(&remote) {
let rewritten = aliases::rewrite(cwd, &remote)?;
return Ok(EndpointInfo {
url: derive_lfs_url(&rewritten)?,
ssh: parse_ssh_url(&rewritten),
});
}
if !caller_specified_remote && let Some(url) = read_fetch_head_url(cwd)? {
let rewritten = aliases::rewrite(cwd, &url)?;
return Ok(EndpointInfo {
url: derive_lfs_url(&rewritten)?,
ssh: parse_ssh_url(&rewritten),
});
}
Err(EndpointError::Unresolved(remote))
}
fn read_fetch_head_url(cwd: &Path) -> Result<Option<String>, EndpointError> {
let git_dir = match crate::path::git_dir(cwd) {
Ok(p) => p,
Err(_) => return Ok(None),
};
let path = git_dir.join("FETCH_HEAD");
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(EndpointError::Git(Error::Io(e))),
};
for line in content.lines() {
if let Some(idx) = line.rfind(" of ") {
let url = line[idx + 4..].trim();
if !url.is_empty() {
return Ok(Some(url.to_owned()));
}
}
}
Ok(None)
}
fn direct_endpoint(cwd: &Path, value: &str) -> Result<EndpointInfo, EndpointError> {
let rewritten = aliases::rewrite(cwd, value)?;
let ssh = parse_ssh_url(&rewritten);
Ok(EndpointInfo {
url: rewritten,
ssh,
})
}
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())
}
pub 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('@')
}
pub fn remote_url(cwd: &Path, remote: &str) -> Result<Option<String>, Error> {
config::get_effective(cwd, &format!("remote.{remote}.url"))
}
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(),
})
}
pub fn parse_ssh_url(rawurl: &str) -> Option<SshInfo> {
let trimmed = rawurl.trim();
let ssh_rest = trimmed
.strip_prefix("ssh://")
.or_else(|| trimmed.strip_prefix("git+ssh://"))
.or_else(|| trimmed.strip_prefix("ssh+git://"));
if let Some(rest) = ssh_rest {
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
if authority.is_empty() {
return None;
}
let (user_and_host, port) = match authority.rsplit_once(':') {
Some((host, p)) => (host.to_owned(), Some(p.to_owned())),
None => (authority.to_owned(), None),
};
return Some(SshInfo {
user_and_host,
path: format!("/{}", path.trim_start_matches('/')),
port,
});
}
if trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
|| trimmed.starts_with("git://")
|| trimmed.starts_with("file://")
{
return None;
}
let (host, path) = bare_ssh_split(trimmed)?;
Some(SshInfo {
user_and_host: host.to_owned(),
path: path.trim_start_matches('/').to_owned(),
port: None,
})
}
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() {
let err = derive_lfs_url("C:\\repos\\foo").unwrap_err();
assert!(
matches!(err, EndpointError::InvalidUrl { .. }),
"got {err:?}"
);
}
#[test]
fn relative_path_is_rejected_not_treated_as_ssh() {
for input in ["./relative/path", "/abs/path"] {
let err = derive_lfs_url(input).unwrap_err();
assert!(
matches!(err, EndpointError::InvalidUrl { .. }),
"input {input:?} got {err:?}"
);
}
}
#[test]
fn ssh_metadata_for_bare_user_at_host() {
let info = parse_ssh_url("git@github.com:user/repo.git").unwrap();
assert_eq!(info.user_and_host, "git@github.com");
assert_eq!(info.path, "user/repo.git");
}
#[test]
fn ssh_metadata_for_bare_host_only() {
let info = parse_ssh_url("badalias:rest").unwrap();
assert_eq!(info.user_and_host, "badalias");
assert_eq!(info.path, "rest");
}
#[test]
fn ssh_metadata_for_ssh_scheme_keeps_leading_slash() {
let info = parse_ssh_url("ssh://git@host.example/path/to/repo.git").unwrap();
assert_eq!(info.user_and_host, "git@host.example");
assert_eq!(info.path, "/path/to/repo.git");
}
#[test]
fn ssh_metadata_for_ssh_scheme_drops_port_from_host() {
let info = parse_ssh_url("ssh://git@host.example:2222/path").unwrap();
assert_eq!(info.user_and_host, "git@host.example");
assert_eq!(info.path, "/path");
}
#[test]
fn ssh_metadata_for_https_returns_none() {
assert!(parse_ssh_url("https://host.example/path").is_none());
assert!(parse_ssh_url("http://host.example/path").is_none());
}
#[test]
fn ssh_metadata_for_git_protocol_returns_none() {
assert!(parse_ssh_url("git://host.example/path").is_none());
}
#[test]
fn ssh_metadata_for_file_url_returns_none() {
assert!(parse_ssh_url("file:///srv/repos/foo.git").is_none());
}
#[test]
fn ssh_metadata_for_local_path_returns_none() {
assert!(parse_ssh_url("/abs/path").is_none());
assert!(parse_ssh_url("./relative").is_none());
}
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"),
}
}
}
}