use std::{
hash::{Hash, Hasher},
path::{Path, PathBuf}
};
use tracing::{debug, info};
const GIT_URL_PREFIXES: &[&str] = &["https://", "http://", "git://", "ssh://", "git@", "file://"];
#[derive(Debug, Clone)]
pub enum RepoSource {
Local(PathBuf),
Remote {
url: String,
cache_path: PathBuf
}
}
impl RepoSource {
#[must_use]
pub fn parse(input: &str) -> Self {
if Self::is_git_url(input) {
let url = input.to_string();
let cache_path = Self::compute_cache_path(&url);
Self::Remote { url, cache_path }
} else {
Self::Local(PathBuf::from(input))
}
}
#[must_use]
pub fn is_git_url(input: &str) -> bool {
for prefix in GIT_URL_PREFIXES {
if input.starts_with(prefix) {
return true;
}
}
if input.contains('@')
&& input.contains(':')
&& !input.contains("://")
&& let Some(pos) = input.find(':')
&& pos > 1
{
return true;
}
false
}
#[must_use]
pub fn compute_cache_path(url: &str) -> PathBuf {
let hash = Self::hash_url(url);
let name = Self::extract_repo_name(url);
let cache_base = dirs_cache_dir().join("omnifuse");
cache_base.join(format!("{name}-{hash}"))
}
#[must_use]
fn extract_repo_name(url: &str) -> String {
let url = url.trim_end_matches(".git");
let name = url
.rsplit('/')
.next()
.or_else(|| url.rsplit(':').next())
.unwrap_or("repo");
name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.take(32)
.collect()
}
fn hash_url(url: &str) -> String {
let mut hasher = std::hash::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[must_use]
pub fn local_path(&self) -> &Path {
match self {
Self::Local(path) => path,
Self::Remote { cache_path, .. } => cache_path
}
}
#[must_use]
pub const fn is_remote(&self) -> bool {
matches!(self, Self::Remote { .. })
}
#[must_use]
pub fn remote_url(&self) -> Option<&str> {
match self {
Self::Remote { url, .. } => Some(url),
Self::Local(_) => None
}
}
#[must_use]
pub fn exists(&self) -> bool {
self.local_path().exists()
}
#[must_use]
pub fn is_git_repo(&self) -> bool {
self.local_path().join(".git").exists()
}
pub async fn ensure_available(&self, branch: &str) -> anyhow::Result<PathBuf> {
match self {
Self::Local(path) => Self::ensure_local_repo(path),
Self::Remote { cache_path, .. } => self.ensure_available_at(branch, cache_path).await
}
}
pub async fn ensure_available_at(&self, branch: &str, target: &Path) -> anyhow::Result<PathBuf> {
match self {
Self::Local(path) => Self::ensure_local_repo(path),
Self::Remote { url, .. } => Self::ensure_remote_repo(url, target, branch).await
}
}
fn ensure_local_repo(path: &Path) -> anyhow::Result<PathBuf> {
if !path.exists() {
anyhow::bail!("path not found: {}", path.display());
}
if !path.join(".git").exists() {
anyhow::bail!("not a git repository: {}", path.display());
}
Ok(path.to_path_buf())
}
async fn ensure_remote_repo(url: &str, target: &Path, branch: &str) -> anyhow::Result<PathBuf> {
if target.join(".git").exists() {
info!(url, path = %target.display(), "using cached repository");
Self::fetch_updates(target).await?;
} else {
info!(url, path = %target.display(), "cloning");
Self::clone_repo(url, target, branch).await?;
}
Ok(target.to_path_buf())
}
async fn clone_repo(url: &str, target: &Path, branch: &str) -> anyhow::Result<()> {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
debug!(url, target = %target.display(), branch, "cloning");
let output = tokio::process::Command::new("git")
.args(["clone", "--branch", branch, "--single-branch", "--depth", "1", url])
.arg(target)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") || stderr.contains("Could not find remote branch") {
debug!("branch {branch} not found, trying default");
let output = tokio::process::Command::new("git")
.args(["clone", "--single-branch", "--depth", "1", url])
.arg(target)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git clone failed: {stderr}");
}
} else {
anyhow::bail!("git clone failed: {stderr}");
}
}
let _ = tokio::process::Command::new("git")
.args(["fetch", "--unshallow"])
.current_dir(target)
.output()
.await;
info!(url, "clone completed");
Ok(())
}
async fn fetch_updates(repo_path: &Path) -> anyhow::Result<()> {
debug!(path = %repo_path.display(), "fetching updates");
let output = tokio::process::Command::new("git")
.args(["fetch", "--all"])
.current_dir(repo_path)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("fetch failed (continuing): {stderr}");
}
Ok(())
}
}
impl std::fmt::Display for RepoSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local(path) => write!(f, "{}", path.display()),
Self::Remote { url, .. } => write!(f, "{url}")
}
}
}
fn dirs_cache_dir() -> PathBuf {
if let Ok(cache) = std::env::var("XDG_CACHE_HOME") {
return PathBuf::from(cache);
}
#[cfg(unix)]
{
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".cache");
}
}
#[cfg(windows)]
{
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
return PathBuf::from(local_app_data);
}
}
PathBuf::from("/tmp")
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_is_git_url() {
assert!(RepoSource::is_git_url("https://github.com/user/repo.git"));
assert!(RepoSource::is_git_url("https://github.com/user/repo"));
assert!(RepoSource::is_git_url("git@github.com:user/repo.git"));
assert!(RepoSource::is_git_url("ssh://git@github.com/user/repo.git"));
assert!(RepoSource::is_git_url("git://github.com/user/repo.git"));
assert!(RepoSource::is_git_url("file:///tmp/repo.git"));
assert!(!RepoSource::is_git_url("/path/to/repo"));
assert!(!RepoSource::is_git_url("./relative/path"));
assert!(!RepoSource::is_git_url("C:\\Windows\\path"));
}
#[test]
fn test_parse_local() {
let source = RepoSource::parse("/path/to/repo");
assert!(matches!(source, RepoSource::Local(_)));
assert!(!source.is_remote());
}
#[test]
fn test_parse_remote() {
let source = RepoSource::parse("https://github.com/user/repo.git");
assert!(source.is_remote());
assert_eq!(source.remote_url(), Some("https://github.com/user/repo.git"));
}
#[test]
fn test_extract_repo_name() {
assert_eq!(
RepoSource::extract_repo_name("https://github.com/user/myrepo.git"),
"myrepo"
);
assert_eq!(
RepoSource::extract_repo_name("git@github.com:user/another-repo.git"),
"another-repo"
);
}
#[test]
fn test_parse_preserves_url() {
let url = "https://github.com/user/repo.git";
let source = RepoSource::parse(url);
assert!(source.is_remote(), "URL should be recognized as remote");
assert_eq!(
source.remote_url(),
Some(url),
"parse() should preserve URL unchanged (with .git suffix)"
);
}
#[test]
fn test_parse_keeps_original_url() {
let url = "ssh://git@host/repo";
let source = RepoSource::parse(url);
assert!(source.is_remote(), "ssh:// URL should be remote");
assert_eq!(
source.remote_url(),
Some(url),
"remote_url() should return the original URL without modifications"
);
assert_eq!(source.to_string(), url, "Display should show the original URL");
}
}