1use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use anyhow::{Context, Result};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27
28use crate::config::VcsKind;
29use crate::repo::normalize_url;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct DiscoveredRepo {
33 pub path: PathBuf,
34 pub vcs: VcsKind,
35 pub name: String,
37 pub remotes: Vec<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct WorkdirCache {
43 pub workdir: PathBuf,
44 pub scanned_at: DateTime<Utc>,
45 pub repos: Vec<DiscoveredRepo>,
46}
47
48impl WorkdirCache {
49 pub fn load(path: &Path) -> Result<Option<Self>> {
50 if !path.exists() {
51 return Ok(None);
52 }
53 let text =
54 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
55 if text.trim().is_empty() {
56 return Ok(None);
57 }
58 Ok(Some(
59 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
60 ))
61 }
62
63 pub fn save(&self, path: &Path) -> Result<()> {
65 let dir = path
66 .parent()
67 .context("workdir cache path has no parent directory")?;
68 std::fs::create_dir_all(dir)?;
69 let tmp = dir.join(format!(
70 ".{}.tmp",
71 path.file_name().unwrap().to_string_lossy()
72 ));
73 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)
74 .with_context(|| format!("writing {}", tmp.display()))?;
75 std::fs::rename(&tmp, path)
76 .with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?;
77 Ok(())
78 }
79
80 pub fn lookup(&self, normalized_urls: &[String]) -> Option<&DiscoveredRepo> {
83 self.repos
84 .iter()
85 .find(|r| r.path.exists() && r.remotes.iter().any(|u| normalized_urls.contains(u)))
86 }
87}
88
89pub fn scan(root: &Path, skip: &Path) -> Vec<DiscoveredRepo> {
94 let mut out = Vec::new();
95 scan_dir(root, skip, &mut out);
96 out
97}
98
99fn scan_dir(dir: &Path, skip: &Path, out: &mut Vec<DiscoveredRepo>) {
100 if dir == skip {
101 return;
102 }
103
104 let jj_repo = dir.join(".jj").join("repo");
105 if jj_repo.is_dir() {
106 if let Some(repo) = discovered_jj_repo(dir) {
107 out.push(repo);
108 }
109 return;
110 }
111 if jj_repo.is_file() {
112 return; }
114
115 let git_dir = dir.join(".git");
116 if git_dir.is_dir() {
117 if let Some(repo) = discovered_git_repo(dir) {
118 out.push(repo);
119 }
120 return;
121 }
122 if git_dir.is_file() {
123 return; }
125
126 let Ok(entries) = std::fs::read_dir(dir) else {
127 return;
128 };
129 for entry in entries.flatten() {
130 let Ok(file_type) = entry.file_type() else {
131 continue;
132 };
133 if file_type.is_symlink() || !file_type.is_dir() {
134 continue;
135 }
136 let path = entry.path();
137 if path
138 .file_name()
139 .and_then(|n| n.to_str())
140 .is_some_and(|n| n.starts_with('.'))
141 {
142 continue;
143 }
144 scan_dir(&path, skip, out);
145 }
146}
147
148fn discovered_git_repo(dir: &Path) -> Option<DiscoveredRepo> {
149 let output = Command::new("git")
150 .current_dir(dir)
151 .args(["config", "--get-regexp", r"^remote\..*\.url$"])
152 .output()
153 .ok()?;
154 if !output.status.success() {
156 return None;
157 }
158 let text = String::from_utf8_lossy(&output.stdout);
159 let mut remotes = Vec::new();
160 for line in text.lines() {
161 let Some((key, url)) = line.split_once(' ') else {
162 continue;
163 };
164 let Some(name) = key
165 .strip_prefix("remote.")
166 .and_then(|s| s.strip_suffix(".url"))
167 else {
168 continue;
169 };
170 remotes.push((name.to_string(), url.to_string()));
171 }
172 build_discovered(dir.to_path_buf(), VcsKind::Git, remotes)
173}
174
175fn discovered_jj_repo(dir: &Path) -> Option<DiscoveredRepo> {
176 let output = Command::new("jj")
177 .current_dir(dir)
178 .args(["git", "remote", "list"])
179 .output()
180 .ok()?;
181 if !output.status.success() {
182 return None;
183 }
184 let text = String::from_utf8_lossy(&output.stdout);
185 let mut remotes = Vec::new();
186 for line in text.lines() {
187 let mut parts = line.split_whitespace();
188 let (Some(name), Some(url)) = (parts.next(), parts.next()) else {
189 continue;
190 };
191 remotes.push((name.to_string(), url.to_string()));
192 }
193 build_discovered(dir.to_path_buf(), VcsKind::Jj, remotes)
194}
195
196fn build_discovered(
197 path: PathBuf,
198 vcs: VcsKind,
199 remotes: Vec<(String, String)>,
200) -> Option<DiscoveredRepo> {
201 if remotes.is_empty() {
202 return None;
203 }
204 let normalized: Vec<String> = remotes.iter().map(|(_, u)| normalize_url(u)).collect();
205 let name = remotes
206 .iter()
207 .position(|(name, _)| name == "origin")
208 .unwrap_or(0);
209 Some(DiscoveredRepo {
210 path,
211 vcs,
212 name: normalized[name].clone(),
213 remotes: normalized,
214 })
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use std::process::Command;
221 use tempfile::TempDir;
222
223 fn git(dir: &Path, args: &[&str]) {
224 let out = Command::new("git")
225 .current_dir(dir)
226 .args(args)
227 .output()
228 .unwrap();
229 assert!(
230 out.status.success(),
231 "git {args:?} failed: {}",
232 String::from_utf8_lossy(&out.stderr)
233 );
234 }
235
236 fn git_repo_with_remote(dir: &Path, remote_url: &str) {
237 std::fs::create_dir_all(dir).unwrap();
238 git(dir, &["init", "-q"]);
239 git(dir, &["remote", "add", "origin", remote_url]);
240 }
241
242 fn jj_available() -> bool {
243 Command::new("jj")
244 .arg("--version")
245 .output()
246 .map(|o| o.status.success())
247 .unwrap_or(false)
248 }
249
250 macro_rules! require_jj {
251 () => {
252 if !jj_available() {
253 eprintln!("skipping: `jj` not found on PATH");
254 return;
255 }
256 };
257 }
258
259 #[test]
260 fn finds_a_nested_repo_and_normalizes_its_remote() {
261 let tmp = TempDir::new().unwrap();
262 let repo = tmp.path().join("a/b/repo");
263 git_repo_with_remote(&repo, "git@github.com:o/r.git");
264
265 let found = scan(tmp.path(), Path::new("/nonexistent"));
266 assert_eq!(found.len(), 1);
267 assert_eq!(found[0].path, repo);
268 assert_eq!(found[0].vcs, VcsKind::Git);
269 assert_eq!(found[0].name, "github.com/o/r");
270 assert_eq!(found[0].remotes, vec!["github.com/o/r".to_string()]);
271 }
272
273 #[test]
274 fn does_not_descend_into_a_found_repo() {
275 let tmp = TempDir::new().unwrap();
276 let outer = tmp.path().join("outer");
277 git_repo_with_remote(&outer, "https://example.com/o/outer");
278 git_repo_with_remote(&outer.join("vendor/nested"), "https://example.com/o/nested");
280
281 let found = scan(tmp.path(), Path::new("/nonexistent"));
282 assert_eq!(found.len(), 1);
283 assert_eq!(found[0].path, outer);
284 }
285
286 #[test]
287 fn skips_dot_directories_and_the_data_dir() {
288 let tmp = TempDir::new().unwrap();
289 std::fs::create_dir_all(tmp.path().join(".cache/repo")).unwrap();
290 git_repo_with_remote(
291 &tmp.path().join(".cache/repo"),
292 "https://example.com/o/hidden",
293 );
294 let skip_repo = tmp.path().join("data/repos/foo/source");
295 git_repo_with_remote(&skip_repo, "https://example.com/o/skip");
296
297 let found = scan(tmp.path(), &tmp.path().join("data/repos"));
298 assert!(found.is_empty());
299 }
300
301 #[test]
302 fn drops_a_repo_with_no_remotes() {
303 let tmp = TempDir::new().unwrap();
304 let repo = tmp.path().join("repo");
305 std::fs::create_dir_all(&repo).unwrap();
306 git(&repo, &["init", "-q"]);
307
308 assert!(scan(tmp.path(), Path::new("/nonexistent")).is_empty());
309 }
310
311 #[test]
312 fn skips_a_git_worktree() {
313 let tmp = TempDir::new().unwrap();
314 let repo = tmp.path().join("repo");
315 git_repo_with_remote(&repo, "https://example.com/o/r");
316 git(&repo, &["config", "user.name", "test"]);
317 git(&repo, &["config", "user.email", "test@example.com"]);
318 git(&repo, &["commit", "-q", "--allow-empty", "-m", "base"]);
319 let worktree = tmp.path().join("worktree");
320 git(
321 &repo,
322 &["worktree", "add", "--detach", worktree.to_str().unwrap()],
323 );
324
325 let found = scan(tmp.path(), Path::new("/nonexistent"));
326 assert_eq!(found.len(), 1);
327 assert_eq!(found[0].path, repo);
328 }
329
330 #[test]
331 fn prefers_origin_as_the_name_over_other_remotes() {
332 let tmp = TempDir::new().unwrap();
333 let repo = tmp.path().join("repo");
334 git_repo_with_remote(&repo, "https://example.com/fork/r");
335 git(
336 &repo,
337 &["remote", "add", "upstream", "https://example.com/o/r"],
338 );
339
340 let found = scan(tmp.path(), Path::new("/nonexistent"));
341 assert_eq!(found.len(), 1);
342 assert_eq!(found[0].name, "example.com/fork/r");
343 assert_eq!(
344 found[0]
345 .remotes
346 .iter()
347 .collect::<std::collections::HashSet<_>>(),
348 std::collections::HashSet::from([
349 &"example.com/fork/r".to_string(),
350 &"example.com/o/r".to_string(),
351 ])
352 );
353 }
354
355 #[test]
356 fn lookup_ignores_a_cached_entry_whose_path_no_longer_exists() {
357 let cache = WorkdirCache {
358 workdir: PathBuf::from("/dev"),
359 scanned_at: Utc::now(),
360 repos: vec![DiscoveredRepo {
361 path: PathBuf::from("/nonexistent/gone"),
362 vcs: VcsKind::Git,
363 name: "example.com/o/r".into(),
364 remotes: vec!["example.com/o/r".into()],
365 }],
366 };
367 assert!(cache.lookup(&["example.com/o/r".to_string()]).is_none());
368 }
369
370 #[test]
371 fn colocated_jj_repo_is_detected_as_jj_not_git() {
372 require_jj!();
373 let tmp = TempDir::new().unwrap();
374 let upstream = tmp.path().join("upstream");
375 std::fs::create_dir(&upstream).unwrap();
376 git(&upstream, &["init", "-q", "-b", "main"]);
377 git(&upstream, &["config", "user.name", "test"]);
378 git(&upstream, &["config", "user.email", "test@example.com"]);
379 git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
380
381 let canon = tmp.path().join("canon");
382 let out = Command::new("jj")
383 .current_dir(tmp.path())
384 .args([
385 "git",
386 "clone",
387 upstream.to_str().unwrap(),
388 canon.to_str().unwrap(),
389 ])
390 .output()
391 .unwrap();
392 assert!(
393 out.status.success(),
394 "jj git clone failed: {}",
395 String::from_utf8_lossy(&out.stderr)
396 );
397
398 let found = scan(tmp.path(), Path::new("/nonexistent"));
399 assert_eq!(found.len(), 1);
400 assert_eq!(found[0].path, canon);
401 assert_eq!(found[0].vcs, VcsKind::Jj, "colocated repo must count as jj");
402 assert_eq!(found[0].name, normalize_url(upstream.to_str().unwrap()));
403 }
404
405 #[test]
406 fn skips_a_secondary_jj_workspace() {
407 require_jj!();
408 let tmp = TempDir::new().unwrap();
409 let upstream = tmp.path().join("upstream");
410 std::fs::create_dir(&upstream).unwrap();
411 git(&upstream, &["init", "-q", "-b", "main"]);
412 git(&upstream, &["config", "user.name", "test"]);
413 git(&upstream, &["config", "user.email", "test@example.com"]);
414 git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
415
416 let canon = tmp.path().join("canon");
417 let clone = Command::new("jj")
418 .current_dir(tmp.path())
419 .args([
420 "git",
421 "clone",
422 upstream.to_str().unwrap(),
423 canon.to_str().unwrap(),
424 ])
425 .output()
426 .unwrap();
427 assert!(clone.status.success());
428
429 let secondary = tmp.path().join("secondary");
430 let add = Command::new("jj")
431 .current_dir(&canon)
432 .args([
433 "workspace",
434 "add",
435 "--name",
436 "secondary",
437 secondary.to_str().unwrap(),
438 ])
439 .output()
440 .unwrap();
441 assert!(
442 add.status.success(),
443 "jj workspace add failed: {}",
444 String::from_utf8_lossy(&add.stderr)
445 );
446
447 let found = scan(tmp.path(), Path::new("/nonexistent"));
448 assert_eq!(found.len(), 1, "only the canonical repo, not the workspace");
449 assert_eq!(found[0].path, canon);
450 }
451}