From b6cf87244f8e5c0f215c2bc018b3a55f87360a98 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:30:38 +0100 Subject: [PATCH 1/4] feat(maestro): [spawn].project_roots and project_scan_depth config (#54) --- crates/maestro-config/src/lib.rs | 39 ++++++++++++ crates/maestro-daemon/src/config.rs | 98 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 4780720..e7d6520 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -162,6 +162,15 @@ pub struct SpawnSection { /// it resolves to just the `custom` tile. #[serde(default)] pub presets: Option>, + /// Root directories the daemon scans for git repositories (#54), `~` + /// expanded by the daemon against its own home. When absent the daemon + /// scans `["~"]`. + #[serde(default)] + pub project_roots: Option>, + /// How many directory levels below each [`Self::project_roots`] entry the + /// scanner descends (#54). When absent the daemon uses `3`. + #[serde(default)] + pub project_scan_depth: Option, } impl SpawnSection { @@ -387,6 +396,7 @@ mod tests { label: "fish".into(), command: "fish".into(), }]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -403,6 +413,7 @@ mod tests { // only the implicit custom tile. let section = SpawnSection { presets: Some(vec![]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -421,6 +432,7 @@ mod tests { label: "x".into(), command: "x".into(), }]), + ..Default::default() }; assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId); @@ -430,6 +442,7 @@ mod tests { label: "x".into(), command: "".into(), }]), + ..Default::default() }; assert_eq!( empty_cmd.resolve().unwrap_err(), @@ -452,6 +465,7 @@ mod tests { command: "a2".into(), }, ]), + ..Default::default() }; assert_eq!( dup.resolve().unwrap_err(), @@ -464,6 +478,7 @@ mod tests { label: "Custom".into(), command: "whatever".into(), }]), + ..Default::default() }; assert_eq!( reserved.resolve().unwrap_err(), @@ -497,6 +512,30 @@ presets = [ assert_eq!(ids, ["claude", "zsh", "custom"]); } + #[test] + fn spawn_project_scan_fields_default_to_none() { + // Absent [spawn].project_* → None; the daemon applies its own defaults. + assert_eq!(SpawnSection::default().project_roots, None); + assert_eq!(SpawnSection::default().project_scan_depth, None); + } + + #[test] + fn spawn_project_scan_fields_parse_from_toml() { + let cfg = toml::from_str::( + r#" +[spawn] +project_roots = ["~/code", "/srv/work"] +project_scan_depth = 5 +"#, + ) + .expect("parse"); + assert_eq!( + cfg.spawn.project_roots, + Some(vec!["~/code".to_string(), "/srv/work".to_string()]) + ); + assert_eq!(cfg.spawn.project_scan_depth, Some(5)); + } + #[test] fn partial_file_loads_with_section_defaults() { // Only [daemon] present (typical single-host install). diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index dccb4f5..4e8684d 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -23,6 +23,14 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070"; /// `[daemon].spawn_history_days` is unset. `0` in config disables history. pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30; +/// Default scan roots for project discovery (#54) when `[spawn].project_roots` +/// is unset: the whole home directory, with the scanner's own ignore list +/// keeping it cheap. +pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"]; +/// Default directory depth for project discovery (#54) when +/// `[spawn].project_scan_depth` is unset. +pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3; + /// Env var that overrides `[daemon].bind_addr`. pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR"; /// Env var that overrides `[daemon].tmux_tmpdir`. @@ -74,6 +82,12 @@ pub struct DaemonConfig { /// built-ins) with the implicit `custom` tile appended. Advertised to /// clients in `GET /api/info`. pub spawn_presets: Vec, + /// Project-discovery scan roots (#54): `[spawn].project_roots` (default + /// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home. + pub project_roots: Vec, + /// Project-discovery depth (#54): `[spawn].project_scan_depth`, default + /// [`DEFAULT_PROJECT_SCAN_DEPTH`]. + pub project_scan_depth: u32, } impl DaemonConfig { @@ -147,6 +161,25 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result = file + .spawn + .project_roots + .unwrap_or_else(|| { + DEFAULT_PROJECT_ROOTS + .iter() + .map(|s| s.to_string()) + .collect() + }) + .iter() + .map(|root| expand_tilde(root, &home_dir)) + .collect(); + let project_scan_depth = file + .spawn + .project_scan_depth + .unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH); + Ok(DaemonConfig { hostname, home_dir, @@ -157,9 +190,24 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result PathBuf { + match path.strip_prefix("~") { + Some(_) if home.is_empty() => PathBuf::from(path), + Some(rest) => { + let mut p = PathBuf::from(home); + p.push(rest.trim_start_matches('/')); + p + } + None => PathBuf::from(path), + } +} + pub fn default_state_dir() -> Result { Ok(maestro_config::default_state_dir("maestro")?) } @@ -280,6 +328,56 @@ spawn_history_days = 0 assert_eq!(cfg.spawn_history_days, 0); } + #[test] + fn project_scan_defaults_to_home_root_at_depth_3() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "").unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH); + assert_eq!( + cfg.project_roots, + vec![PathBuf::from(cfg.home_dir.clone())], + "bare ~ must expand to the daemon's home" + ); + } + + #[test] + fn project_scan_roots_expand_tilde_and_pass_through_absolutes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[spawn] +project_roots = ["~/code", "/srv/work"] +project_scan_depth = 5 +"#, + ) + .unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.project_scan_depth, 5); + assert_eq!( + cfg.project_roots, + vec![ + PathBuf::from(cfg.home_dir.clone()).join("code"), + PathBuf::from("/srv/work"), + ] + ); + } + + #[test] + fn tilde_expansion_handles_bare_and_nested_forms() { + assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u")); + assert_eq!( + expand_tilde("~/a/b", "/home/u"), + PathBuf::from("/home/u/a/b") + ); + assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs")); + // Unresolvable home leaves the tilde as-is rather than mangling it. + assert_eq!(expand_tilde("~", ""), PathBuf::from("~")); + } + #[test] fn system_hostname_is_nonempty() { let h = system_hostname(); From 33fb50be6e712736a5f46c12d4c7eb17c9e9b1a3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:32:29 +0100 Subject: [PATCH 2/4] feat(maestro): Project wire type and PROJECTS route (#54) --- crates/maestro-protocol/src/client.rs | 7 +++- crates/maestro-protocol/src/lib.rs | 52 +++++++++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 4 ++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 71ae127..dd4203f 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -15,7 +15,8 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ - ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session, + ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project, + RecentDirs, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -138,6 +139,10 @@ impl DaemonClient { .await } + pub async fn list_projects(&self) -> Result, ClientError> { + self.get(routes::PROJECTS, &[]).await + } + // -- plumbing ----------------------------------------------------------- async fn get( diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 82c73b7..b06e3b7 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -188,6 +188,30 @@ pub struct GitRoot { pub root: Option, } +/// One discovered git repository (or linked worktree) for `GET /api/projects` +/// (#54). The daemon reports the timestamp; clients decide what "stale" means +/// (the web UI dims entries older than 7 days). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Project { + /// Absolute path of the repository/worktree toplevel. + pub path: PathBuf, + /// Basename of `path`. + pub name: String, + #[serde(default)] + pub is_worktree: bool, + /// The main checkout this worktree belongs to. Set only when + /// [`Self::is_worktree`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_repo: Option, + /// Later of the repo's git HEAD mtime and the newest spawn-history use of + /// any directory inside it. `None` when neither is known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_activity: Option>, + /// Command last spawned in this project, from spawn history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_command: Option, +} + /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { @@ -329,6 +353,34 @@ mod tests { serde_json::from_str(&json).expect("deserialize") } + #[test] + fn project_round_trips_and_defaults_on_legacy_payload() { + let full = Project { + path: PathBuf::from("/home/u/tea/maestro"), + name: "maestro".into(), + is_worktree: true, + base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")), + last_activity: Some( + DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + last_command: Some("claude".into()), + }; + assert_eq!(full, round_trip(&full)); + + // A payload from a daemon predating the optional fields still decodes. + let legacy = serde_json::json!({ + "path": "/home/u/proj", + "name": "proj", + }); + let p: Project = serde_json::from_value(legacy).unwrap(); + assert!(!p.is_worktree); + assert_eq!(p.base_repo, None); + assert_eq!(p.last_activity, None); + assert_eq!(p.last_command, None); + } + fn sample_session() -> Session { Session { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 10c8131..777d086 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -17,6 +17,8 @@ pub const INFO: &str = "/api/info"; pub const DIRS: &str = "/api/dirs"; /// `GET` spawn history. pub const RECENT_DIRS: &str = "/api/recent-dirs"; +/// `GET` discovered git repositories and worktrees, newest activity first. +pub const PROJECTS: &str = "/api/projects"; /// `GET` the enclosing repository of a path. Takes a `path` query param. pub const GITROOT: &str = "/api/gitroot"; /// `WS` catalog snapshot + session deltas. @@ -172,7 +174,7 @@ mod tests { #[test] fn every_route_sits_under_the_api_prefix() { - for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] { + for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX)); From 1809f5d5812d96f594ec96f4edf20f1e3bf6df12 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:15 +0100 Subject: [PATCH 3/4] feat(maestro): uncapped spawn-history recents for project enrichment (#54) --- crates/maestro-daemon/src/session.rs | 10 ++++++ crates/maestro-daemon/src/spawn_history.rs | 40 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 7ae544c..ac54fb1 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -169,6 +169,16 @@ impl SessionManager { .unwrap_or_default() } + /// Every deduped recent dir, newest-first, without the picker's result + /// cap (#54): project enrichment folds the whole history. + pub fn all_recent_dirs(&self) -> Vec { + self.inner + .spawn_history + .as_ref() + .map(|h| h.all_recent_dirs()) + .unwrap_or_default() + } + /// Subscribe to session deltas. Each subscriber gets its own lag domain — /// a slow client makes only itself resync. pub fn events(&self) -> broadcast::Receiver { diff --git a/crates/maestro-daemon/src/spawn_history.rs b/crates/maestro-daemon/src/spawn_history.rs index 668bca4..b0aec59 100644 --- a/crates/maestro-daemon/src/spawn_history.rs +++ b/crates/maestro-daemon/src/spawn_history.rs @@ -143,11 +143,23 @@ impl SpawnHistory { /// Recent working dirs, newest use first, deduped by `cwd`, within the /// recency cap, capped at `max_results`. Empty when disabled or unreadable. pub fn recent_dirs(&self) -> Vec { - self.recent_dirs_as_of(Utc::now()) + self.recent_dirs_capped(Utc::now(), self.max_results) + } + + /// Every deduped recent dir, newest-first, without the picker's result + /// cap (#54): project enrichment folds the whole history, not its first + /// page, so a project spawned in long ago still reads as fresh. + pub fn all_recent_dirs(&self) -> Vec { + self.recent_dirs_capped(Utc::now(), usize::MAX) } /// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing. + #[cfg(test)] fn recent_dirs_as_of(&self, now: DateTime) -> Vec { + self.recent_dirs_capped(now, self.max_results) + } + + fn recent_dirs_capped(&self, now: DateTime, max_results: usize) -> Vec { if !self.is_enabled() { return Vec::new(); } @@ -168,7 +180,7 @@ impl SpawnHistory { last_command: rec.command, last_used: rec.at, }); - if out.len() >= self.max_results { + if out.len() >= max_results { break; } } @@ -362,4 +374,28 @@ mod tests { assert_eq!(recents[0].cwd, PathBuf::from("/d4")); assert_eq!(recents[1].cwd, PathBuf::from("/d3")); } + + /// Project enrichment (#54) folds the whole log, not just the picker's + /// first page. + #[test] + fn all_recent_dirs_skips_the_result_cap() { + let tmp = tempfile::tempdir().unwrap(); + let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30); + h.max_results = 2; + // Recent enough to survive the 30-day cap against the real clock. + let base = Utc::now() - Duration::hours(1); + for i in 0..5 { + h.append(&rec( + &format!("/d{i}"), + "claude", + base + Duration::minutes(i), + )) + .unwrap(); + } + assert_eq!(h.recent_dirs().len(), 2); + let all = h.all_recent_dirs(); + assert_eq!(all.len(), 5); + assert_eq!(all[0].cwd, PathBuf::from("/d4")); + assert_eq!(all[4].cwd, PathBuf::from("/d0")); + } } From 4a814d5b17d3eebcc8f871283db6a15cd20a2e6c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:15 +0100 Subject: [PATCH 4/4] 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