//! Project discovery for `GET /api/projects` (#54). //! //! Scans `[spawn].project_roots` (default the whole home) for `.git` entries — //! directory or file, so linked worktrees count too — up to //! `[spawn].project_scan_depth` levels. Descent stops at the first repo found: //! nested independent repos are not discovered, but worktrees of every hit //! still are, via `git worktree list`, which also catches linked worktrees //! living outside the roots. //! //! Hidden directories are skipped everywhere; heavy well-known directories //! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the //! home directory, so scanning all of home stays cheap. //! //! Results are cached behind a short TTL and refreshed on a slow background //! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear //! rarely, and `/api/projects` may be polled. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use crate::spawn_history::RecentDir; /// Directory names never descended when they sit directly in the home dir, so /// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere /// regardless. pub const DEFAULT_HOME_IGNORE: &[&str] = &["node_modules", "Downloads", "Library", "go", "snap", "opt"]; /// How long a discovery snapshot is served as-is before the next request /// triggers a rescan. /// /// A scan walks the roots and runs one `git worktree list` per hit, holding /// the scanner lock throughout, and clients re-request whenever a spawn form /// re-mounts — which `multiview.render()` does on unrelated events. Keep this /// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is /// what refreshes, and a request only rescans when that timer has failed to. pub const CACHE_TTL: Duration = Duration::from_secs(180); /// Background refresh cadence. Repositories appear rarely; this keeps the /// served list eventually fresh without anyone asking. pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60); /// One repository or linked worktree found by [`discover`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiscoveredRepo { pub path: PathBuf, /// Basename of `path`. pub name: String, pub is_worktree: bool, /// The main checkout this worktree belongs to; set only when /// [`Self::is_worktree`]. pub base_repo: Option, /// Mtime of the checkout's git HEAD — "someone committed / checked out /// here". The later-of merge with spawn history happens in /// [`to_projects`], which also sorts. pub head_mtime: Option>, } /// Repo toplevels under each root of `roots`, at most `max_depth` levels deep. /// A dir holding `.git` (dir *or* file) is a hit and is not descended into; /// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are /// skipped directly under `home`. pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec { let mut found = Vec::new(); for root in roots { walk(root, 0, max_depth, home, &mut found); } found.sort(); found.dedup(); found } fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec) { let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => return, }; let mut subdirs = Vec::new(); let mut has_git = false; for entry in entries.flatten() { match entry.file_name().to_string_lossy().into_owned() { name if name == ".git" => has_git = true, name if name.starts_with('.') => {} name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {} _ => { if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { subdirs.push(entry.path()); } } } } if has_git { // Match `git worktree list` output, which prints real paths. found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())); return; } if depth >= max_depth { return; } for sub in subdirs { walk(&sub, depth + 1, max_depth, home, found); } } /// Every discovered project: the scanned repos plus all worktrees their /// `git worktree list` reports (linked worktrees can live outside the roots). /// A hit whose git invocation fails still lists, un-worktreed. pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec { let scanned = scan_roots(roots, max_depth, home); let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main) for repo in scanned { if map.iter().any(|(c, _)| c == &repo) { continue; } let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]); let main = worktrees[0].clone(); for wt in worktrees { if !map.iter().any(|(c, _)| c == &wt) { map.push((wt, main.clone())); } } } map.into_iter() .map(|(path, main)| { let is_worktree = path != main; DiscoveredRepo { name: path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), is_worktree, base_repo: is_worktree.then_some(main), head_mtime: head_mtime(&path), path, } }) .collect() } /// `git worktree list --porcelain` from `repo`: absolute paths, main first. /// `None` when git fails (broken/bare checkout). fn list_worktrees(repo: &Path) -> Option> { let out = std::process::Command::new("git") .arg("-C") .arg(repo) .args(["worktree", "list", "--porcelain"]) .output() .ok()?; if !out.status.success() { return None; } let mut paths = Vec::new(); for line in String::from_utf8_lossy(&out.stdout).lines() { if let Some(p) = line.strip_prefix("worktree ") { paths.push(PathBuf::from(p)); } } (!paths.is_empty()).then_some(paths) } /// Mtime of the checkout's reflog, falling back to `HEAD` itself. /// /// `HEAD` is only rewritten on checkout — committing updates /// `refs/heads/` and appends to `logs/HEAD`, leaving `HEAD` alone. A /// repo worked on daily from one long-lived branch would otherwise report its /// last *branch switch* as its activity and get dimmed as stale (#54 review). /// /// For a linked worktree `.git` is a file holding a `gitdir:` pointer; /// resolve it so worktrees report their own reflog rather than nothing. fn head_mtime(repo: &Path) -> Option> { let dot_git = repo.join(".git"); let head = if dot_git.is_dir() { dot_git.join("HEAD") } else { let ptr = std::fs::read_to_string(&dot_git).ok()?; let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim(); // Relative pointers are relative to the worktree. let p = Path::new(gitdir); let p = if p.is_absolute() { p.to_path_buf() } else { repo.join(p) }; p.join("HEAD") }; // Prefer the reflog next to whichever HEAD we resolved. let reflog = head.with_file_name("logs").join("HEAD"); let head = if reflog.exists() { reflog } else { head }; let mtime = std::fs::metadata(head).ok()?.modified().ok()?; Some(DateTime::::from(mtime)) } /// Fold spawn history into the discovery snapshot and produce the wire list, /// sorted newest-activity-first. `history` must be newest-first (what /// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory) /// returns): the first entry whose cwd sits inside a project is its most /// recent use. pub fn to_projects( repos: &[DiscoveredRepo], history: &[RecentDir], ) -> Vec { let mut projects: Vec = repos .iter() .map(|repo| { let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd)); maestro_protocol::Project { path: repo.path.clone(), name: repo.name.clone(), is_worktree: repo.is_worktree, base_repo: repo.base_repo.clone(), last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)), last_command: last_use.map(|u| u.last_command.clone()), } }) .collect(); projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity)); projects } /// Whether `dir` is `project` itself or anywhere inside it. fn contains(project: &Path, dir: &Path) -> bool { dir == project || dir.starts_with(project) } fn max_opt(a: Option>, b: Option>) -> Option> { match (a, b) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), } } /// Cached discovery behind a short TTL plus an optional slow background /// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`]. #[derive(Debug)] pub struct ProjectScanner { roots: Vec, max_depth: u32, home: PathBuf, ttl: Duration, cache: tokio::sync::Mutex>)>>, } impl ProjectScanner { /// Scanner over the resolved config roots. An empty `roots` list scans /// nothing and always serves an empty list. pub fn new(roots: Vec, max_depth: u32, home: PathBuf) -> Self { Self { roots, max_depth, home, ttl: CACHE_TTL, cache: tokio::sync::Mutex::new(None), } } /// Scanner that never discovers anything — tests and embedders that don't /// want the endpoint populated. pub fn disabled() -> Self { Self::new(Vec::new(), 0, PathBuf::new()) } /// Cached snapshot, rescanning (off-thread) when the TTL has lapsed. /// Also the background timer's entry point: a tick inside the TTL is a /// no-op (#75). pub async fn repos(&self) -> std::sync::Arc> { let mut cache = self.cache.lock().await; if let Some((at, repos)) = cache.as_ref() { if at.elapsed() < self.ttl { return repos.clone(); } } let roots = self.roots.clone(); let max_depth = self.max_depth; let home = self.home.clone(); // Blocking fs walk + git subprocesses must not stall the reactor. let repos = std::sync::Arc::new( tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home)) .await .unwrap_or_default(), ); *cache = Some((Instant::now(), repos.clone())); repos } /// One background-refresh tick: rescan only if the cached snapshot has /// passed its TTL (#75) — the timer used to bypass it and re-walk the /// roots every [`REFRESH_INTERVAL`]. async fn refresh(&self) { self.repos().await; } /// Spawn the slow background refresh task. Drop the returned handle to /// detach it; it stops when the runtime shuts down. pub fn start_background_refresh(self: &std::sync::Arc) -> tokio::task::JoinHandle<()> { let scanner = std::sync::Arc::clone(self); tokio::spawn(async move { let mut ticker = tokio::time::interval(REFRESH_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); ticker.tick().await; // the immediate first tick — wait a full interval loop { scanner.refresh().await; ticker.tick().await; } }) } } #[cfg(test)] mod tests { use super::*; use crate::spawn_history::RecentDir; /// Real git repo at `/`, one initial commit so HEAD exists. fn make_repo(root: &Path, name: &str) -> PathBuf { let path = root.join(name); std::fs::create_dir_all(&path).unwrap(); run_git(&path, &["init", "-q"]); std::fs::write(path.join("f.txt"), "x\n").unwrap(); run_git(&path, &["add", "-A"]); run_git( &path, &[ "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init", ], ); path } fn run_git(dir: &Path, args: &[&str]) { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .unwrap(); assert!( out.status.success(), "git {args:?} failed: {} | {}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout) ); } fn names(repos: &[DiscoveredRepo]) -> Vec { let mut v: Vec = repos.iter().map(|r| r.name.clone()).collect(); v.sort(); v } #[test] fn scan_finds_repos_and_stops_at_the_first_hit() { let tmp = tempfile::tempdir().unwrap(); let outer = make_repo(tmp.path(), "outer"); // A nested independent repo inside `outer` is NOT discovered // separately: descent stops once a repo is found. make_repo(&outer, "vendored"); std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap(); let found = scan_roots( &[tmp.path().to_path_buf()], 3, Path::new("/nonexistent-home"), ); assert_eq!(found, vec![outer]); let discovered = discover( &[tmp.path().to_path_buf()], 3, Path::new("/nonexistent-home"), ); assert_eq!(names(&discovered), ["outer"]); assert!(!discovered[0].is_worktree); assert_eq!(discovered[0].base_repo, None); assert!(discovered[0].head_mtime.is_some()); } #[test] fn scan_respects_depth_hidden_dirs_and_home_ignore() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().join("home"); // Hidden dirs are never entered, at any depth. std::fs::create_dir_all(home.join(".secret/.git")).unwrap(); // node_modules directly under home is not descended… let _ignored = make_repo(&home.join("node_modules"), "dep"); // …but the same name deeper down is fair game. let kept = make_repo(&home.join("code/node_modules"), "dep2"); // Depth cap: a repo three levels below the root is the last tier — // found at depth 3, but a fourth-level repo isn't. let at_three = home.join("a/b/c"); std::fs::create_dir_all(at_three.join(".git")).unwrap(); std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap(); let found = scan_roots(std::slice::from_ref(&home), 3, &home); assert!(found.iter().all(|p| !p.starts_with(home.join(".secret")))); assert!(found .iter() .all(|p| !p.starts_with(home.join("node_modules")))); assert!(found.contains(&kept.canonicalize().unwrap())); assert!(found.contains(&at_three.canonicalize().unwrap())); assert!(!found.iter().any(|p| p.ends_with("d"))); } #[test] fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("scan-root"); std::fs::create_dir_all(&root).unwrap(); let main = make_repo(&root, "main-repo"); // Linked worktree deliberately outside the scan root. let wt = tmp.path().join("elsewhere/wt-of-main"); std::fs::create_dir_all(wt.parent().unwrap()).unwrap(); run_git( &main, &[ "worktree", "add", "-q", "-b", "feature", &wt.to_string_lossy(), ], ); let discovered = discover(&[root], 3, Path::new("/nonexistent-home")); assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]); let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap(); assert!(wt_entry.is_worktree); assert_eq!( wt_entry.base_repo.as_deref(), Some(main.canonicalize().unwrap().as_path()) ); let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap(); assert!(!main_entry.is_worktree); } #[test] fn head_mtime_resolves_a_worktrees_git_file_pointer() { let tmp = tempfile::tempdir().unwrap(); let main = make_repo(tmp.path(), "m"); let wt = tmp.path().join("wt"); run_git( &main, &["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()], ); assert!(head_mtime(&wt).is_some()); } fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir { RecentDir { cwd: PathBuf::from(cwd), last_command: cmd.into(), last_used: DateTime::parse_from_rfc3339(at) .unwrap() .with_timezone(&Utc), } } #[test] fn to_projects_merges_history_and_sorts_newest_first() { let repo_old = DiscoveredRepo { path: PathBuf::from("/w/old"), name: "old".into(), is_worktree: false, base_repo: None, // Committed recently but never spawned in. head_mtime: Some(t("2026-08-23T10:00:00Z")), }; let repo_hist = DiscoveredRepo { path: PathBuf::from("/w/hist"), name: "hist".into(), is_worktree: false, base_repo: None, // Old commit… head_mtime: Some(t("2026-08-01T10:00:00Z")), }; let repo_stale = DiscoveredRepo { path: PathBuf::from("/w/stale"), name: "stale".into(), is_worktree: false, base_repo: None, head_mtime: None, }; let history = vec![ // Newest overall: a subdir of /w/hist (first match wins for it). recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"), recent("/w/hist", "claude", "2026-08-20T09:00:00Z"), ]; let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history); let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect(); assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]); // old: git activity wins (never spawned). assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z"))); assert_eq!(projects[0].last_command, None); // hist: spawn history beats the old commit; command from the newest // matching entry (the crates/ one, not the repo root one). assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z"))); assert_eq!(projects[1].last_command.as_deref(), Some("opencode")); // stale: neither source knows anything. assert_eq!(projects[2].last_activity, None); assert_eq!(projects[2].last_command, None); } fn t(s: &str) -> DateTime { DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) } #[tokio::test] async fn scanner_caches_within_ttl_and_rescans_after() { let tmp = tempfile::tempdir().unwrap(); let mut scanner = ProjectScanner::new( vec![tmp.path().to_path_buf()], 3, PathBuf::from("/nonexistent"), ); let empty = scanner.repos().await; assert!(empty.is_empty()); make_repo(tmp.path(), "late"); // Within the TTL the cached (empty) snapshot is still served. let still_empty = scanner.repos().await; assert!(still_empty.is_empty()); // After the TTL lapses the rescan sees the new repo. scanner.ttl = Duration::ZERO; let fresh = scanner.repos().await; assert_eq!(fresh.len(), 1); assert_eq!(fresh[0].name, "late"); } #[tokio::test] async fn background_refresh_honours_the_ttl() { let tmp = tempfile::tempdir().unwrap(); let mut scanner = ProjectScanner::new( vec![tmp.path().to_path_buf()], 3, PathBuf::from("/nonexistent"), ); let empty = scanner.repos().await; assert!(empty.is_empty()); make_repo(tmp.path(), "late"); // A background tick inside the TTL must not re-walk the filesystem: // the new repo stays invisible until the cache expires. scanner.refresh().await; assert!(scanner.repos().await.is_empty()); // Once the TTL lapses, the next tick picks it up. scanner.ttl = Duration::ZERO; scanner.refresh().await; let fresh = scanner.repos().await; assert_eq!(fresh.len(), 1); assert_eq!(fresh[0].name, "late"); } #[test] fn disabled_scanner_always_serves_empty() { let scanner = ProjectScanner::disabled(); assert_eq!(scanner.roots, Vec::::new()); } }