use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Repo {
pub name: String,
pub path: PathBuf,
}
pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for root in roots {
for host in subdirs(root) {
for owner in subdirs(&host) {
for dir in subdirs(&owner) {
if !dir.join(".git").exists() {
continue;
}
let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
if !seen.insert(path.clone()) {
continue;
}
let name = format!("{}/{}", file_name(&owner), file_name(&dir));
out.push(Repo { name, path });
}
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
out
}
fn subdirs(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|p| p.is_dir())
.collect()
}
fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default()
}
const DISCOVER_MAX_DEPTH: u32 = 4;
fn well_known_roots(home: &Path) -> Vec<PathBuf> {
["src/github.com", "ghq", "dev", "repos", "projects", "wt"]
.into_iter()
.map(|rel| home.join(rel))
.collect()
}
struct Candidate {
path: PathBuf,
name: String,
owner_name: Option<String>,
}
fn collect(dir: &Path, depth: u32, seen: &mut HashSet<PathBuf>, out: &mut Vec<Candidate>) {
if depth == 0 {
return;
}
for child in subdirs(dir) {
match main_checkout(&child) {
Some(main) => {
let path = main.canonicalize().unwrap_or(main);
if seen.insert(path.clone()) {
let name = file_name(&path).into_owned();
let owner_name = path
.parent()
.and_then(|p| p.file_name())
.map(|owner| format!("{}/{name}", owner.to_string_lossy()));
out.push(Candidate {
path,
name,
owner_name,
});
}
}
None => collect(&child, depth - 1, seen, out),
}
}
}
fn main_checkout(dir: &Path) -> Option<PathBuf> {
let dot_git = dir.join(".git");
if dot_git.is_dir() {
return Some(dir.to_path_buf());
}
let contents = std::fs::read_to_string(&dot_git).ok()?;
let gitdir = contents.strip_prefix("gitdir:")?.trim();
let git_dir = PathBuf::from(gitdir).parent()?.parent()?.to_path_buf();
if git_dir.file_name()?.to_str()? != ".git" {
return None;
}
Some(git_dir.parent()?.to_path_buf())
}
fn mentions(hint_lower: &str, token: &str) -> bool {
let token_lower = token.to_lowercase();
hint_lower
.split(|c: char| !(c.is_alphanumeric() || matches!(c, '/' | '-' | '_')))
.any(|word| word == token_lower)
}
enum Tier {
Settled(PathBuf),
Ambiguous,
Miss,
}
fn tier<'a>(mut it: impl Iterator<Item = &'a Candidate>) -> Tier {
match (it.next(), it.next()) {
(None, _) => Tier::Miss,
(Some(only), None) => Tier::Settled(only.path.clone()),
(Some(_), Some(_)) => Tier::Ambiguous,
}
}
pub struct Found {
pub path: PathBuf,
pub reason: &'static str,
}
pub fn discover(
home: &Path,
extra_roots: &[PathBuf],
hint: Option<&str>,
self_name: &str,
) -> Option<Found> {
let mut roots = well_known_roots(home);
roots.extend(extra_roots.iter().cloned());
let mut seen = HashSet::new();
let mut candidates = Vec::new();
for root in &roots {
collect(root, DISCOVER_MAX_DEPTH, &mut seen, &mut candidates);
}
if let Some(hint) = hint {
let hint_lower = hint.to_lowercase();
match tier(candidates.iter().filter(|c| {
c.owner_name
.as_deref()
.is_some_and(|on| mentions(&hint_lower, on))
})) {
Tier::Settled(path) => {
return Some(Found {
path,
reason: "its owner/repo name is mentioned in the hint",
});
}
Tier::Ambiguous => return None,
Tier::Miss => {}
}
match tier(candidates.iter().filter(|c| mentions(&hint_lower, &c.name))) {
Tier::Settled(path) => {
return Some(Found {
path,
reason: "its name is mentioned in the hint",
});
}
Tier::Ambiguous => return None,
Tier::Miss => {}
}
}
match tier(candidates.iter().filter(|c| c.name == self_name)) {
Tier::Settled(path) => Some(Found {
path,
reason: "it is this binary's own repository, the only checkout found under the \
usual project directories",
}),
Tier::Ambiguous | Tier::Miss => None,
}
}
pub async fn discover_verified(
home: &Path,
extra_roots: &[PathBuf],
hint: Option<&str>,
self_name: &str,
) -> Option<Found> {
let found = discover(home, extra_roots, hint, self_name)?;
crate::git::toplevel(&found.path).await.ok()?;
Some(found)
}
#[derive(Debug, Clone, Default)]
pub struct Cache {
state: Arc<Mutex<State>>,
}
#[derive(Debug, Default)]
struct State {
repos: Vec<Repo>,
scanned_at: Option<Instant>,
}
impl Cache {
pub fn new() -> Self {
Self::default()
}
pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let stale =
refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
if stale {
state.repos = scan(roots);
state.scanned_at = Some(Instant::now());
}
state.repos.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
let dir = root.join(host).join(owner).join(repo);
std::fs::create_dir_all(&dir).expect("create repo dir");
if git {
std::fs::create_dir_all(dir.join(".git")).expect("create .git");
}
dir
}
#[test]
fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "rvpm", true);
make(&root, "github.com", "yukimemi", "magi", true);
make(&root, "github.com", "yukimemi", "not-a-checkout", false);
let repos = scan(&[root]);
let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
assert!(repos.iter().all(|r| r.path.is_absolute()));
}
#[test]
fn a_missing_root_does_not_empty_the_results_of_the_others() {
let tmp = tempfile::tempdir().expect("tempdir");
let good = tmp.path().join("good");
std::fs::create_dir_all(&good).expect("good root");
make(&good, "github.com", "yukimemi", "magi", true);
let missing = tmp.path().join("does-not-exist");
let repos = scan(&[missing, good]);
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "yukimemi/magi");
}
#[test]
fn duplicate_paths_across_roots_are_counted_once() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "magi", true);
let repos = scan(&[root.clone(), root]);
assert_eq!(repos.len(), 1);
}
#[test]
fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "magi", true);
let roots = [root.clone()];
let cache = Cache::new();
let first = cache.list(&roots, Duration::from_secs(3600), false);
assert_eq!(first.len(), 1);
make(&root, "github.com", "yukimemi", "rvpm", true);
let second = cache.list(&roots, Duration::from_secs(3600), false);
assert_eq!(second.len(), 1, "a fresh cache must not rescan");
let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
make(&root, "github.com", "yukimemi", "third", true);
let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
assert_eq!(still_cached.len(), 2);
}
#[test]
fn a_zero_ttl_always_rescans() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_owned();
make(&root, "github.com", "yukimemi", "magi", true);
let roots = [root.clone()];
let cache = Cache::new();
assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
make(&root, "github.com", "yukimemi", "rvpm", true);
assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
}
#[test]
fn main_checkout_resolves_a_linked_worktree_to_its_main_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let main = tmp.path().join("main-repo");
std::fs::create_dir_all(main.join(".git").join("worktrees").join("seat"))
.expect("create main .git/worktrees/seat");
let worktree = tmp.path().join("wt-repo");
std::fs::create_dir_all(&worktree).expect("create worktree dir");
std::fs::write(
worktree.join(".git"),
format!(
"gitdir: {}\n",
main.join(".git").join("worktrees").join("seat").display()
),
)
.expect("write .git file");
assert_eq!(main_checkout(&worktree), Some(main));
}
#[test]
fn main_checkout_is_none_without_a_git_dir_or_file() {
let tmp = tempfile::tempdir().expect("tempdir");
assert_eq!(main_checkout(tmp.path()), None);
}
#[test]
fn discover_finds_this_binarys_own_repository_under_a_well_known_root_with_no_hint() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let repo = home
.join("src")
.join("github.com")
.join("yukimemi")
.join("magi");
std::fs::create_dir_all(repo.join(".git")).expect("create repo .git");
let found = discover(home, &[], None, "magi").expect("self-name match");
assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
assert!(
found.reason.contains("own repository"),
"got: {}",
found.reason
);
}
#[test]
fn discover_finds_nothing_when_no_checkout_matches_self_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
std::fs::create_dir_all(home.join("dev").join("yukimemi").join("other").join(".git"))
.expect("create unrelated repo");
assert!(discover(home, &[], None, "magi").is_none());
}
#[test]
fn discover_prefers_an_owner_repo_hint_over_a_bare_name_collision() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let mine = home.join("dev").join("yukimemi").join("widget");
let theirs = home.join("dev").join("someoneelse").join("widget");
std::fs::create_dir_all(mine.join(".git")).expect("create mine");
std::fs::create_dir_all(theirs.join(".git")).expect("create theirs");
let found = discover(
home,
&[],
Some("please fix a bug in yukimemi/widget"),
"magi",
)
.expect("owner/repo hint resolves the tie");
assert_eq!(found.path, mine.canonicalize().expect("canonicalize mine"));
assert!(found.reason.contains("owner/repo"), "got: {}", found.reason);
}
#[test]
fn discover_matches_a_unique_bare_name_mentioned_in_the_hint() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let repo = home.join("repos").join("gizmo");
std::fs::create_dir_all(repo.join(".git")).expect("create repo");
let found = discover(home, &[], Some("look at gizmo please"), "magi")
.expect("bare name hint resolves");
assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
assert!(
found.reason.contains("name is mentioned"),
"got: {}",
found.reason
);
}
#[test]
fn discover_refuses_a_bare_name_hint_shared_by_two_checkouts_rather_than_guessing() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
std::fs::create_dir_all(
home.join("dev")
.join("yukimemi")
.join("widget")
.join(".git"),
)
.expect("create first widget");
std::fs::create_dir_all(
home.join("dev")
.join("someoneelse")
.join("widget")
.join(".git"),
)
.expect("create second widget");
assert!(discover(home, &[], Some("please fix widget"), "magi").is_none());
}
#[test]
fn discover_resolves_a_worktree_under_wt_to_its_main_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let main = tmp.path().join("elsewhere").join("magi");
std::fs::create_dir_all(main.join(".git").join("worktrees").join("cand-A"))
.expect("create main .git/worktrees/cand-A");
let seat = home.join("wt").join("magi").join("b21f").join("cand-A");
std::fs::create_dir_all(&seat).expect("create seat dir");
std::fs::write(
seat.join(".git"),
format!(
"gitdir: {}\n",
main.join(".git").join("worktrees").join("cand-A").display()
),
)
.expect("write worktree .git file");
let found = discover(home, &[], None, "magi").expect("self-name match via worktree");
assert_eq!(found.path, main.canonicalize().expect("canonicalize main"));
}
#[tokio::test]
async fn discover_verified_refuses_a_directory_whose_git_dir_is_not_a_real_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
std::fs::create_dir_all(home.join("repos").join("widget").join(".git"))
.expect("create a .git directory with nothing real inside it");
assert!(
discover_verified(home, &[], None, "widget").await.is_none(),
"a `.git` directory that is not an actual checkout must not be returned"
);
}
#[tokio::test]
async fn discover_verified_accepts_a_real_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let repo = home.join("repos").join("widget");
tokio::fs::create_dir_all(&repo)
.await
.expect("create repo dir");
crate::git::git(&repo, &["init", "-b", "main"])
.await
.expect("git init");
let found = discover_verified(home, &[], None, "widget")
.await
.expect("a real checkout resolves");
assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
}
}