1use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, Mutex, PoisonError};
30use std::time::{Duration, Instant};
31
32use serde::Serialize;
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct Repo {
37 pub name: String,
40 pub path: PathBuf,
42}
43
44pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
57 let mut seen = HashSet::new();
58 let mut out = Vec::new();
59 for root in roots {
60 for host in subdirs(root) {
61 for owner in subdirs(&host) {
62 for dir in subdirs(&owner) {
63 if !dir.join(".git").exists() {
64 continue;
65 }
66 let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
67 if !seen.insert(path.clone()) {
68 continue;
69 }
70 let name = format!("{}/{}", file_name(&owner), file_name(&dir));
71 out.push(Repo { name, path });
72 }
73 }
74 }
75 }
76 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
77 out
78}
79
80fn subdirs(dir: &Path) -> Vec<PathBuf> {
82 std::fs::read_dir(dir)
83 .into_iter()
84 .flatten()
85 .flatten()
86 .map(|entry| entry.path())
87 .filter(|p| p.is_dir())
88 .collect()
89}
90
91fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
92 path.file_name()
93 .map(|n| n.to_string_lossy())
94 .unwrap_or_default()
95}
96
97const DISCOVER_MAX_DEPTH: u32 = 4;
105
106fn well_known_roots(home: &Path) -> Vec<PathBuf> {
112 ["src/github.com", "ghq", "dev", "repos", "projects", "wt"]
113 .into_iter()
114 .map(|rel| home.join(rel))
115 .collect()
116}
117
118struct Candidate {
120 path: PathBuf,
123 name: String,
126 owner_name: Option<String>,
131}
132
133fn collect(dir: &Path, depth: u32, seen: &mut HashSet<PathBuf>, out: &mut Vec<Candidate>) {
142 if depth == 0 {
143 return;
144 }
145 for child in subdirs(dir) {
146 match main_checkout(&child) {
147 Some(main) => {
148 let path = main.canonicalize().unwrap_or(main);
149 if seen.insert(path.clone()) {
150 let name = file_name(&path).into_owned();
151 let owner_name = path
152 .parent()
153 .and_then(|p| p.file_name())
154 .map(|owner| format!("{}/{name}", owner.to_string_lossy()));
155 out.push(Candidate {
156 path,
157 name,
158 owner_name,
159 });
160 }
161 }
162 None => collect(&child, depth - 1, seen, out),
163 }
164 }
165}
166
167fn main_checkout(dir: &Path) -> Option<PathBuf> {
179 let dot_git = dir.join(".git");
180 if dot_git.is_dir() {
181 return Some(dir.to_path_buf());
182 }
183 let contents = std::fs::read_to_string(&dot_git).ok()?;
184 let gitdir = contents.strip_prefix("gitdir:")?.trim();
185 let git_dir = PathBuf::from(gitdir).parent()?.parent()?.to_path_buf();
187 if git_dir.file_name()?.to_str()? != ".git" {
188 return None;
189 }
190 Some(git_dir.parent()?.to_path_buf())
191}
192
193fn mentions(hint_lower: &str, token: &str) -> bool {
198 let token_lower = token.to_lowercase();
199 hint_lower
200 .split(|c: char| !(c.is_alphanumeric() || matches!(c, '/' | '-' | '_')))
201 .any(|word| word == token_lower)
202}
203
204enum Tier {
210 Settled(PathBuf),
211 Ambiguous,
212 Miss,
213}
214
215fn tier<'a>(mut it: impl Iterator<Item = &'a Candidate>) -> Tier {
216 match (it.next(), it.next()) {
217 (None, _) => Tier::Miss,
218 (Some(only), None) => Tier::Settled(only.path.clone()),
219 (Some(_), Some(_)) => Tier::Ambiguous,
220 }
221}
222
223pub struct Found {
228 pub path: PathBuf,
230 pub reason: &'static str,
233}
234
235pub fn discover(
253 home: &Path,
254 extra_roots: &[PathBuf],
255 hint: Option<&str>,
256 self_name: &str,
257) -> Option<Found> {
258 let mut roots = well_known_roots(home);
259 roots.extend(extra_roots.iter().cloned());
260
261 let mut seen = HashSet::new();
262 let mut candidates = Vec::new();
263 for root in &roots {
264 collect(root, DISCOVER_MAX_DEPTH, &mut seen, &mut candidates);
265 }
266
267 if let Some(hint) = hint {
268 let hint_lower = hint.to_lowercase();
269 match tier(candidates.iter().filter(|c| {
270 c.owner_name
271 .as_deref()
272 .is_some_and(|on| mentions(&hint_lower, on))
273 })) {
274 Tier::Settled(path) => {
275 return Some(Found {
276 path,
277 reason: "its owner/repo name is mentioned in the hint",
278 });
279 }
280 Tier::Ambiguous => return None,
281 Tier::Miss => {}
282 }
283 match tier(candidates.iter().filter(|c| mentions(&hint_lower, &c.name))) {
284 Tier::Settled(path) => {
285 return Some(Found {
286 path,
287 reason: "its name is mentioned in the hint",
288 });
289 }
290 Tier::Ambiguous => return None,
291 Tier::Miss => {}
292 }
293 }
294
295 match tier(candidates.iter().filter(|c| c.name == self_name)) {
296 Tier::Settled(path) => Some(Found {
297 path,
298 reason: "it is this binary's own repository, the only checkout found under the \
299 usual project directories",
300 }),
301 Tier::Ambiguous | Tier::Miss => None,
302 }
303}
304
305pub async fn discover_verified(
322 home: &Path,
323 extra_roots: &[PathBuf],
324 hint: Option<&str>,
325 self_name: &str,
326) -> Option<Found> {
327 let found = discover(home, extra_roots, hint, self_name)?;
328 crate::git::toplevel(&found.path).await.ok()?;
329 Some(found)
330}
331
332#[derive(Debug, Clone, Default)]
338pub struct Cache {
339 state: Arc<Mutex<State>>,
340}
341
342#[derive(Debug, Default)]
343struct State {
344 repos: Vec<Repo>,
345 scanned_at: Option<Instant>,
346}
347
348impl Cache {
349 pub fn new() -> Self {
351 Self::default()
352 }
353
354 pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
358 let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
359 let stale =
360 refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
361 if stale {
362 state.repos = scan(roots);
363 state.scanned_at = Some(Instant::now());
364 }
365 state.repos.clone()
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
376 let dir = root.join(host).join(owner).join(repo);
377 std::fs::create_dir_all(&dir).expect("create repo dir");
378 if git {
379 std::fs::create_dir_all(dir.join(".git")).expect("create .git");
380 }
381 dir
382 }
383
384 #[test]
385 fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
386 let tmp = tempfile::tempdir().expect("tempdir");
387 let root = tmp.path().to_owned();
388 make(&root, "github.com", "yukimemi", "rvpm", true);
389 make(&root, "github.com", "yukimemi", "magi", true);
390 make(&root, "github.com", "yukimemi", "not-a-checkout", false);
393
394 let repos = scan(&[root]);
395 let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
396 assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
397 assert!(repos.iter().all(|r| r.path.is_absolute()));
398 }
399
400 #[test]
401 fn a_missing_root_does_not_empty_the_results_of_the_others() {
402 let tmp = tempfile::tempdir().expect("tempdir");
403 let good = tmp.path().join("good");
404 std::fs::create_dir_all(&good).expect("good root");
405 make(&good, "github.com", "yukimemi", "magi", true);
406 let missing = tmp.path().join("does-not-exist");
407
408 let repos = scan(&[missing, good]);
409 assert_eq!(repos.len(), 1);
410 assert_eq!(repos[0].name, "yukimemi/magi");
411 }
412
413 #[test]
414 fn duplicate_paths_across_roots_are_counted_once() {
415 let tmp = tempfile::tempdir().expect("tempdir");
416 let root = tmp.path().to_owned();
417 make(&root, "github.com", "yukimemi", "magi", true);
418
419 let repos = scan(&[root.clone(), root]);
423 assert_eq!(repos.len(), 1);
424 }
425
426 #[test]
427 fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
428 let tmp = tempfile::tempdir().expect("tempdir");
429 let root = tmp.path().to_owned();
430 make(&root, "github.com", "yukimemi", "magi", true);
431 let roots = [root.clone()];
432 let cache = Cache::new();
433
434 let first = cache.list(&roots, Duration::from_secs(3600), false);
435 assert_eq!(first.len(), 1);
436
437 make(&root, "github.com", "yukimemi", "rvpm", true);
440 let second = cache.list(&roots, Duration::from_secs(3600), false);
441 assert_eq!(second.len(), 1, "a fresh cache must not rescan");
442
443 let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
444 assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
445
446 make(&root, "github.com", "yukimemi", "third", true);
448 let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
449 assert_eq!(still_cached.len(), 2);
450 }
451
452 #[test]
453 fn a_zero_ttl_always_rescans() {
454 let tmp = tempfile::tempdir().expect("tempdir");
455 let root = tmp.path().to_owned();
456 make(&root, "github.com", "yukimemi", "magi", true);
457 let roots = [root.clone()];
458 let cache = Cache::new();
459
460 assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
461 make(&root, "github.com", "yukimemi", "rvpm", true);
462 assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
463 }
464
465 #[test]
466 fn main_checkout_resolves_a_linked_worktree_to_its_main_checkout() {
467 let tmp = tempfile::tempdir().expect("tempdir");
468 let main = tmp.path().join("main-repo");
469 std::fs::create_dir_all(main.join(".git").join("worktrees").join("seat"))
470 .expect("create main .git/worktrees/seat");
471 let worktree = tmp.path().join("wt-repo");
472 std::fs::create_dir_all(&worktree).expect("create worktree dir");
473 std::fs::write(
474 worktree.join(".git"),
475 format!(
476 "gitdir: {}\n",
477 main.join(".git").join("worktrees").join("seat").display()
478 ),
479 )
480 .expect("write .git file");
481
482 assert_eq!(main_checkout(&worktree), Some(main));
483 }
484
485 #[test]
486 fn main_checkout_is_none_without_a_git_dir_or_file() {
487 let tmp = tempfile::tempdir().expect("tempdir");
488 assert_eq!(main_checkout(tmp.path()), None);
489 }
490
491 #[test]
492 fn discover_finds_this_binarys_own_repository_under_a_well_known_root_with_no_hint() {
493 let tmp = tempfile::tempdir().expect("tempdir");
494 let home = tmp.path();
495 let repo = home
496 .join("src")
497 .join("github.com")
498 .join("yukimemi")
499 .join("magi");
500 std::fs::create_dir_all(repo.join(".git")).expect("create repo .git");
501
502 let found = discover(home, &[], None, "magi").expect("self-name match");
503 assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
504 assert!(
505 found.reason.contains("own repository"),
506 "got: {}",
507 found.reason
508 );
509 }
510
511 #[test]
512 fn discover_finds_nothing_when_no_checkout_matches_self_name() {
513 let tmp = tempfile::tempdir().expect("tempdir");
514 let home = tmp.path();
515 std::fs::create_dir_all(home.join("dev").join("yukimemi").join("other").join(".git"))
516 .expect("create unrelated repo");
517
518 assert!(discover(home, &[], None, "magi").is_none());
519 }
520
521 #[test]
522 fn discover_prefers_an_owner_repo_hint_over_a_bare_name_collision() {
523 let tmp = tempfile::tempdir().expect("tempdir");
524 let home = tmp.path();
525 let mine = home.join("dev").join("yukimemi").join("widget");
526 let theirs = home.join("dev").join("someoneelse").join("widget");
527 std::fs::create_dir_all(mine.join(".git")).expect("create mine");
528 std::fs::create_dir_all(theirs.join(".git")).expect("create theirs");
529
530 let found = discover(
531 home,
532 &[],
533 Some("please fix a bug in yukimemi/widget"),
534 "magi",
535 )
536 .expect("owner/repo hint resolves the tie");
537 assert_eq!(found.path, mine.canonicalize().expect("canonicalize mine"));
538 assert!(found.reason.contains("owner/repo"), "got: {}", found.reason);
539 }
540
541 #[test]
542 fn discover_matches_a_unique_bare_name_mentioned_in_the_hint() {
543 let tmp = tempfile::tempdir().expect("tempdir");
544 let home = tmp.path();
545 let repo = home.join("repos").join("gizmo");
546 std::fs::create_dir_all(repo.join(".git")).expect("create repo");
547
548 let found = discover(home, &[], Some("look at gizmo please"), "magi")
549 .expect("bare name hint resolves");
550 assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
551 assert!(
552 found.reason.contains("name is mentioned"),
553 "got: {}",
554 found.reason
555 );
556 }
557
558 #[test]
559 fn discover_refuses_a_bare_name_hint_shared_by_two_checkouts_rather_than_guessing() {
560 let tmp = tempfile::tempdir().expect("tempdir");
561 let home = tmp.path();
562 std::fs::create_dir_all(
563 home.join("dev")
564 .join("yukimemi")
565 .join("widget")
566 .join(".git"),
567 )
568 .expect("create first widget");
569 std::fs::create_dir_all(
570 home.join("dev")
571 .join("someoneelse")
572 .join("widget")
573 .join(".git"),
574 )
575 .expect("create second widget");
576
577 assert!(discover(home, &[], Some("please fix widget"), "magi").is_none());
580 }
581
582 #[test]
583 fn discover_resolves_a_worktree_under_wt_to_its_main_checkout() {
584 let tmp = tempfile::tempdir().expect("tempdir");
585 let home = tmp.path();
586 let main = tmp.path().join("elsewhere").join("magi");
590 std::fs::create_dir_all(main.join(".git").join("worktrees").join("cand-A"))
591 .expect("create main .git/worktrees/cand-A");
592
593 let seat = home.join("wt").join("magi").join("b21f").join("cand-A");
594 std::fs::create_dir_all(&seat).expect("create seat dir");
595 std::fs::write(
596 seat.join(".git"),
597 format!(
598 "gitdir: {}\n",
599 main.join(".git").join("worktrees").join("cand-A").display()
600 ),
601 )
602 .expect("write worktree .git file");
603
604 let found = discover(home, &[], None, "magi").expect("self-name match via worktree");
605 assert_eq!(found.path, main.canonicalize().expect("canonicalize main"));
606 }
607
608 #[tokio::test]
609 async fn discover_verified_refuses_a_directory_whose_git_dir_is_not_a_real_checkout() {
610 let tmp = tempfile::tempdir().expect("tempdir");
611 let home = tmp.path();
612 std::fs::create_dir_all(home.join("repos").join("widget").join(".git"))
618 .expect("create a .git directory with nothing real inside it");
619
620 assert!(
621 discover_verified(home, &[], None, "widget").await.is_none(),
622 "a `.git` directory that is not an actual checkout must not be returned"
623 );
624 }
625
626 #[tokio::test]
627 async fn discover_verified_accepts_a_real_checkout() {
628 let tmp = tempfile::tempdir().expect("tempdir");
629 let home = tmp.path();
630 let repo = home.join("repos").join("widget");
631 tokio::fs::create_dir_all(&repo)
632 .await
633 .expect("create repo dir");
634 crate::git::git(&repo, &["init", "-b", "main"])
635 .await
636 .expect("git init");
637
638 let found = discover_verified(home, &[], None, "widget")
639 .await
640 .expect("a real checkout resolves");
641 assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
642 }
643}