omnifuse_git/
repo_source.rs1use std::{
6 hash::{Hash, Hasher},
7 path::{Path, PathBuf}
8};
9
10use tracing::{debug, info};
11
12const GIT_URL_PREFIXES: &[&str] = &["https://", "http://", "git://", "ssh://", "git@", "file://"];
14
15#[derive(Debug, Clone)]
17pub enum RepoSource {
18 Local(PathBuf),
20 Remote {
22 url: String,
24 cache_path: PathBuf
26 }
27}
28
29impl RepoSource {
30 #[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 #[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 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 #[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 #[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 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 #[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 #[must_use]
110 pub const fn is_remote(&self) -> bool {
111 matches!(self, Self::Remote { .. })
112 }
113
114 #[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 #[must_use]
125 pub fn exists(&self) -> bool {
126 self.local_path().exists()
127 }
128
129 #[must_use]
131 pub fn is_git_repo(&self) -> bool {
132 self.local_path().join(".git").exists()
133 }
134
135 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 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 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 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 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
260fn 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 #[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 #[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 assert_eq!(source.to_string(), url, "Display should show the original URL");
354 }
355}