Skip to main content

omnifuse_git/
repo_source.rs

1//! Repository source management (local path / remote URL).
2//!
3//! Ported from `SimpleGitFS` `core/src/git/repo_source.rs`.
4
5use std::{
6  hash::{Hash, Hasher},
7  path::{Path, PathBuf}
8};
9
10use tracing::{debug, info};
11
12/// Git URL prefixes for remote repositories.
13const GIT_URL_PREFIXES: &[&str] = &["https://", "http://", "git://", "ssh://", "git@", "file://"];
14
15/// Repository source — local path or remote URL.
16#[derive(Debug, Clone)]
17pub enum RepoSource {
18  /// Local filesystem.
19  Local(PathBuf),
20  /// Remote git URL.
21  Remote {
22    /// Original URL.
23    url: String,
24    /// Local path for the clone cache.
25    cache_path: PathBuf
26  }
27}
28
29impl RepoSource {
30  /// Parse a source from a string.
31  ///
32  /// Automatically determines URL vs local path.
33  #[must_use]
34  pub fn parse(input: &str) -> Self {
35    if Self::is_git_url(input) {
36      let url = input.to_string();
37      let cache_path = Self::compute_cache_path(&url);
38      Self::Remote { url, cache_path }
39    } else {
40      Self::Local(PathBuf::from(input))
41    }
42  }
43
44  /// Check whether a string looks like a git URL.
45  #[must_use]
46  pub fn is_git_url(input: &str) -> bool {
47    for prefix in GIT_URL_PREFIXES {
48      if input.starts_with(prefix) {
49        return true;
50      }
51    }
52
53    // scp-like: user@host:path
54    if input.contains('@')
55      && input.contains(':')
56      && !input.contains("://")
57      && let Some(pos) = input.find(':')
58      && pos > 1
59    {
60      return true;
61    }
62
63    false
64  }
65
66  /// Compute the cache path for a remote URL.
67  #[must_use]
68  pub fn compute_cache_path(url: &str) -> PathBuf {
69    let hash = Self::hash_url(url);
70    let name = Self::extract_repo_name(url);
71    let cache_base = dirs_cache_dir().join("omnifuse");
72    cache_base.join(format!("{name}-{hash}"))
73  }
74
75  /// Extract the repository name from a URL.
76  #[must_use]
77  fn extract_repo_name(url: &str) -> String {
78    let url = url.trim_end_matches(".git");
79    let name = url
80      .rsplit('/')
81      .next()
82      .or_else(|| url.rsplit(':').next())
83      .unwrap_or("repo");
84
85    name
86      .chars()
87      .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
88      .take(32)
89      .collect()
90  }
91
92  /// Hash a URL for a unique identifier.
93  fn hash_url(url: &str) -> String {
94    let mut hasher = std::hash::DefaultHasher::new();
95    url.hash(&mut hasher);
96    format!("{:016x}", hasher.finish())
97  }
98
99  /// Local working path.
100  #[must_use]
101  pub fn local_path(&self) -> &Path {
102    match self {
103      Self::Local(path) => path,
104      Self::Remote { cache_path, .. } => cache_path
105    }
106  }
107
108  /// Is this a remote source?
109  #[must_use]
110  pub const fn is_remote(&self) -> bool {
111    matches!(self, Self::Remote { .. })
112  }
113
114  /// Remote URL (if available).
115  #[must_use]
116  pub fn remote_url(&self) -> Option<&str> {
117    match self {
118      Self::Remote { url, .. } => Some(url),
119      Self::Local(_) => None
120    }
121  }
122
123  /// Does the local path exist?
124  #[must_use]
125  pub fn exists(&self) -> bool {
126    self.local_path().exists()
127  }
128
129  /// Is it a valid git repository?
130  #[must_use]
131  pub fn is_git_repo(&self) -> bool {
132    self.local_path().join(".git").exists()
133  }
134
135  /// Ensure the repository is available locally.
136  ///
137  /// For local — checks existence.
138  /// For remote — clones if needed.
139  ///
140  /// # Errors
141  ///
142  /// Returns an error if clone fails or the local path is missing.
143  pub async fn ensure_available(&self, branch: &str) -> anyhow::Result<PathBuf> {
144    match self {
145      Self::Local(path) => Self::ensure_local_repo(path),
146      Self::Remote { cache_path, .. } => self.ensure_available_at(branch, cache_path).await
147    }
148  }
149
150  /// Ensure the repository is available at an explicit target path.
151  ///
152  /// For local sources the target is ignored and the repository path is validated.
153  /// For remote sources the target is the clone/cache directory.
154  ///
155  /// # Errors
156  ///
157  /// Returns an error if clone/fetch fails or the local path is missing.
158  pub async fn ensure_available_at(&self, branch: &str, target: &Path) -> anyhow::Result<PathBuf> {
159    match self {
160      Self::Local(path) => Self::ensure_local_repo(path),
161      Self::Remote { url, .. } => Self::ensure_remote_repo(url, target, branch).await
162    }
163  }
164
165  fn ensure_local_repo(path: &Path) -> anyhow::Result<PathBuf> {
166    if !path.exists() {
167      anyhow::bail!("path not found: {}", path.display());
168    }
169    if !path.join(".git").exists() {
170      anyhow::bail!("not a git repository: {}", path.display());
171    }
172    Ok(path.to_path_buf())
173  }
174
175  async fn ensure_remote_repo(url: &str, target: &Path, branch: &str) -> anyhow::Result<PathBuf> {
176    if target.join(".git").exists() {
177      info!(url, path = %target.display(), "using cached repository");
178      Self::fetch_updates(target).await?;
179    } else {
180      info!(url, path = %target.display(), "cloning");
181      Self::clone_repo(url, target, branch).await?;
182    }
183    Ok(target.to_path_buf())
184  }
185
186  /// Clone a remote repository.
187  async fn clone_repo(url: &str, target: &Path, branch: &str) -> anyhow::Result<()> {
188    if let Some(parent) = target.parent() {
189      std::fs::create_dir_all(parent)?;
190    }
191
192    debug!(url, target = %target.display(), branch, "cloning");
193
194    let output = tokio::process::Command::new("git")
195      .args(["clone", "--branch", branch, "--single-branch", "--depth", "1", url])
196      .arg(target)
197      .output()
198      .await?;
199
200    if !output.status.success() {
201      let stderr = String::from_utf8_lossy(&output.stderr);
202
203      if stderr.contains("not found") || stderr.contains("Could not find remote branch") {
204        debug!("branch {branch} not found, trying default");
205
206        let output = tokio::process::Command::new("git")
207          .args(["clone", "--single-branch", "--depth", "1", url])
208          .arg(target)
209          .output()
210          .await?;
211
212        if !output.status.success() {
213          let stderr = String::from_utf8_lossy(&output.stderr);
214          anyhow::bail!("git clone failed: {stderr}");
215        }
216      } else {
217        anyhow::bail!("git clone failed: {stderr}");
218      }
219    }
220
221    // Unshallow for full history
222    let _ = tokio::process::Command::new("git")
223      .args(["fetch", "--unshallow"])
224      .current_dir(target)
225      .output()
226      .await;
227
228    info!(url, "clone completed");
229    Ok(())
230  }
231
232  /// Fetch updates for an existing repository.
233  async fn fetch_updates(repo_path: &Path) -> anyhow::Result<()> {
234    debug!(path = %repo_path.display(), "fetching updates");
235
236    let output = tokio::process::Command::new("git")
237      .args(["fetch", "--all"])
238      .current_dir(repo_path)
239      .output()
240      .await?;
241
242    if !output.status.success() {
243      let stderr = String::from_utf8_lossy(&output.stderr);
244      debug!("fetch failed (continuing): {stderr}");
245    }
246
247    Ok(())
248  }
249}
250
251impl std::fmt::Display for RepoSource {
252  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253    match self {
254      Self::Local(path) => write!(f, "{}", path.display()),
255      Self::Remote { url, .. } => write!(f, "{url}")
256    }
257  }
258}
259
260/// Get the cache directory (`XDG_CACHE_HOME` or fallback).
261fn dirs_cache_dir() -> PathBuf {
262  if let Ok(cache) = std::env::var("XDG_CACHE_HOME") {
263    return PathBuf::from(cache);
264  }
265
266  #[cfg(unix)]
267  {
268    if let Ok(home) = std::env::var("HOME") {
269      return PathBuf::from(home).join(".cache");
270    }
271  }
272
273  #[cfg(windows)]
274  {
275    if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
276      return PathBuf::from(local_app_data);
277    }
278  }
279
280  PathBuf::from("/tmp")
281}
282
283#[cfg(test)]
284#[allow(clippy::expect_used)]
285mod tests {
286  use super::*;
287
288  #[test]
289  fn test_is_git_url() {
290    assert!(RepoSource::is_git_url("https://github.com/user/repo.git"));
291    assert!(RepoSource::is_git_url("https://github.com/user/repo"));
292    assert!(RepoSource::is_git_url("git@github.com:user/repo.git"));
293    assert!(RepoSource::is_git_url("ssh://git@github.com/user/repo.git"));
294    assert!(RepoSource::is_git_url("git://github.com/user/repo.git"));
295    assert!(RepoSource::is_git_url("file:///tmp/repo.git"));
296
297    assert!(!RepoSource::is_git_url("/path/to/repo"));
298    assert!(!RepoSource::is_git_url("./relative/path"));
299    assert!(!RepoSource::is_git_url("C:\\Windows\\path"));
300  }
301
302  #[test]
303  fn test_parse_local() {
304    let source = RepoSource::parse("/path/to/repo");
305    assert!(matches!(source, RepoSource::Local(_)));
306    assert!(!source.is_remote());
307  }
308
309  #[test]
310  fn test_parse_remote() {
311    let source = RepoSource::parse("https://github.com/user/repo.git");
312    assert!(source.is_remote());
313    assert_eq!(source.remote_url(), Some("https://github.com/user/repo.git"));
314  }
315
316  #[test]
317  fn test_extract_repo_name() {
318    assert_eq!(
319      RepoSource::extract_repo_name("https://github.com/user/myrepo.git"),
320      "myrepo"
321    );
322    assert_eq!(
323      RepoSource::extract_repo_name("git@github.com:user/another-repo.git"),
324      "another-repo"
325    );
326  }
327
328  /// parse() preserves the URL unchanged (no normalization applied).
329  #[test]
330  fn test_parse_preserves_url() {
331    let url = "https://github.com/user/repo.git";
332    let source = RepoSource::parse(url);
333    assert!(source.is_remote(), "URL should be recognized as remote");
334    assert_eq!(
335      source.remote_url(),
336      Some(url),
337      "parse() should preserve URL unchanged (with .git suffix)"
338    );
339  }
340
341  /// parse() for ssh:// URL — original URL is returned unchanged.
342  #[test]
343  fn test_parse_keeps_original_url() {
344    let url = "ssh://git@host/repo";
345    let source = RepoSource::parse(url);
346    assert!(source.is_remote(), "ssh:// URL should be remote");
347    assert_eq!(
348      source.remote_url(),
349      Some(url),
350      "remote_url() should return the original URL without modifications"
351    );
352    // Additionally: Display also shows the original URL
353    assert_eq!(source.to_string(), url, "Display should show the original URL");
354  }
355}