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