From 4a814d5b17d3eebcc8f871283db6a15cd20a2e6c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:15 +0100 Subject: [PATCH] feat(maestro): project discovery scanner and GET /api/projects (#54) --- crates/maestro-daemon/src/cli.rs | 8 + crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/local_api.rs | 19 +- crates/maestro-daemon/src/projects.rs | 530 +++++++++++++++++++++++ crates/maestro-daemon/tests/attach.rs | 1 + crates/maestro-daemon/tests/local_api.rs | 79 ++++ 6 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 crates/maestro-daemon/src/projects.rs diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index a836059..9aafcf6 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -72,11 +72,19 @@ async fn run(config_override: Option) -> Result<(), Box> { tracing::info!(recovered, "adopted existing tmux sessions"); manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL); + let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new( + config.project_roots.clone(), + config.project_scan_depth, + PathBuf::from(&config.home_dir), + )); + projects.start_background_refresh(); + let router = local_api::router(local_api::state_for( manager, config.hostname.clone(), config.home_dir.clone(), config.spawn_presets.clone(), + projects, )); let listener = TcpListener::bind(config.bind_addr).await?; diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index f40ca02..993536c 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -16,6 +16,7 @@ pub mod gitroot; pub mod local_api; pub mod panes; pub mod proc; +pub mod projects; pub mod pty; pub mod session; pub mod sidecar; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index d7bb438..515324b 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -36,6 +36,8 @@ pub struct LocalApiState { pub home_dir: Arc, /// Resolved spawn presets (#22/#26) advertised in `GET /api/info`. pub presets: Arc>, + /// Project discovery (#54) backing `GET /api/projects`. + pub projects: Arc, } pub fn router(state: LocalApiState) -> Router { @@ -44,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::patterns::SESSION, delete(kill_session)) .route(routes::DIRS, get(list_dir)) .route(routes::RECENT_DIRS, get(recent_dirs)) + .route(routes::PROJECTS, get(projects)) .route(routes::GITROOT, get(git_root)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) @@ -98,6 +101,15 @@ async fn recent_dirs(State(s): State) -> Response { Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response() } +/// Discovered git repositories and worktrees, newest activity first (#54). +/// `last_activity` is the later of the repo's git HEAD mtime and the newest +/// spawn-history use of any directory inside it. +async fn projects(State(s): State) -> Response { + let repos = s.projects.repos().await; + let history = s.manager.all_recent_dirs(); + Json(crate::projects::to_projects(&repos, &history)).into_response() +} + /// Probe the enclosing git repo of `path`, to infer the worktree `base_repo` /// for the spawn form (#26). `path` is resolved against the daemon's home dir /// (empty/`~`/relative), like [`list_dir`]. @@ -157,18 +169,23 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response { } /// Convenience constructor — pulls the bits `LocalApiState` needs from -/// `DaemonConfig` so callers don't have to assemble it by hand. +/// `DaemonConfig` so callers don't have to assemble it by hand. The project +/// scanner starts with its cache cold; wire +/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh) +/// on the returned handle's scanner to keep it eventually fresh. pub fn state_for( manager: SessionManager, hostname: impl Into, home_dir: impl Into, presets: Vec, + projects: Arc, ) -> LocalApiState { LocalApiState { manager, hostname: Arc::new(hostname.into()), home_dir: Arc::new(home_dir.into()), presets: Arc::new(presets), + projects, } } diff --git a/crates/maestro-daemon/src/projects.rs b/crates/maestro-daemon/src/projects.rs new file mode 100644 index 0000000..fb31467 --- /dev/null +++ b/crates/maestro-daemon/src/projects.rs @@ -0,0 +1,530 @@ +//! 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. Short: a scan is a bounded filesystem walk plus one +/// `git worktree list` per hit. +pub const CACHE_TTL: Duration = Duration::from_secs(5); +/// 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 HEAD file. For a linked worktree `.git` is a file +/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD +/// 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") + }; + 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. + 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 + } + + /// Rescan now, ignoring the TTL. Called by the background timer. + 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"); + } + + #[test] + fn disabled_scanner_always_serves_empty() { + let scanner = ProjectScanner::disabled(); + assert_eq!(scanner.roots, Vec::::new()); + } +} diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index d8c6b1f..21be5ab 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -37,6 +37,7 @@ async fn spawn_daemon_ws() -> Harness { "test-host", "/home/test", Vec::new(), + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index c1be910..194df70 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -26,6 +26,17 @@ struct Harness { } async fn spawn(hostname: &str) -> Harness { + spawn_with( + hostname, + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + ) + .await +} + +async fn spawn_with( + hostname: &str, + projects: std::sync::Arc, +) -> Harness { let fx = DaemonFixture::new(); let manager = fx.manager.clone(); // Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises @@ -36,6 +47,7 @@ async fn spawn(hostname: &str) -> Harness { hostname, "/home/test", presets, + projects, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await @@ -153,6 +165,73 @@ async fn recent_dirs_returns_empty_without_history() { assert_eq!(resp["truncated"], false); } +/// #54: `GET /api/projects` lists every git repo under the scan roots, with +/// real git checkouts — not mocks. +#[tokio::test] +async fn projects_lists_repos_under_the_scan_roots() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["alpha", "beta"] { + let repo = tmp.path().join(name); + std::fs::create_dir_all(&repo).unwrap(); + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "init", + "--allow-empty", + ], + ] { + let out = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(&args) + .output() + .unwrap(); + assert!(out.status.success()); + } + } + + let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new( + vec![tmp.path().to_path_buf()], + 3, + tmp.path().to_path_buf(), + )); + let h = spawn_with("naps-laptop", scanner).await; + + let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) + .await + .unwrap() + .json() + .await + .unwrap(); + + let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); + names.sort(); + assert_eq!(names, ["alpha", "beta"]); + for p in &projects { + assert!(!p.is_worktree); + assert_eq!(p.base_repo, None); + assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known"); + } +} + +#[tokio::test] +async fn projects_returns_empty_for_a_disabled_scanner() { + let h = spawn("naps-laptop").await; + let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(projects.is_empty()); +} + #[tokio::test] async fn gitroot_probes_path() { // #26: resolves the query against the daemon home dir and walks up for a