Compare commits

...
13 changed files with 883 additions and 5 deletions
+39
View File
@@ -162,6 +162,15 @@ pub struct SpawnSection {
/// it resolves to just the `custom` tile.
#[serde(default)]
pub presets: Option<Vec<SpawnPreset>>,
/// 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<Vec<String>>,
/// 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<u32>,
}
impl SpawnSection {
@@ -387,6 +396,7 @@ mod tests {
label: "fish".into(),
command: "fish".into(),
}]),
..Default::default()
};
let ids: Vec<String> = section
.resolve()
@@ -403,6 +413,7 @@ mod tests {
// only the implicit custom tile.
let section = SpawnSection {
presets: Some(vec![]),
..Default::default()
};
let ids: Vec<String> = 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::<MaestroConfigFile>(
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).
+8
View File
@@ -72,11 +72,19 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
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?;
+98
View File
@@ -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<maestro_config::SpawnPreset>,
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
pub project_roots: Vec<PathBuf>,
/// 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<DaemonConfig, Confi
.spawn_history_days
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
// `~` in scan roots means the daemon's own home; an unresolvable home
// leaves the tilde as-is and the scanner treats the root as missing.
let project_roots: Vec<PathBuf> = 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<DaemonConfig, Confi
tmux_tmpdir,
spawn_history_days,
spawn_presets,
project_roots,
project_scan_depth,
})
}
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
fn expand_tilde(path: &str, home: &str) -> 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<PathBuf, ConfigError> {
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();
+1
View File
@@ -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;
+18 -1
View File
@@ -36,6 +36,8 @@ pub struct LocalApiState {
pub home_dir: Arc<String>,
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
}
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<LocalApiState>) -> 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<LocalApiState>) -> 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<String>,
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>,
) -> LocalApiState {
LocalApiState {
manager,
hostname: Arc::new(hostname.into()),
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
}
}
+530
View File
@@ -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<PathBuf>,
/// 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<DateTime<Utc>>,
}
/// 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<PathBuf> {
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<PathBuf>) {
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<DiscoveredRepo> {
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<Vec<PathBuf>> {
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<DateTime<Utc>> {
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::<Utc>::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<maestro_protocol::Project> {
let mut projects: Vec<maestro_protocol::Project> = 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<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
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<PathBuf>,
max_depth: u32,
home: PathBuf,
ttl: Duration,
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
}
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<PathBuf>, 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<Vec<DiscoveredRepo>> {
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<Self>) -> 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 `<root>/<name>`, 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<String> {
let mut v: Vec<String> = 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<Utc> {
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::<PathBuf>::new());
}
}
+10
View File
@@ -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<RecentDir> {
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<CatalogEvent> {
+38 -2
View File
@@ -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<RecentDir> {
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<RecentDir> {
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<Utc>) -> Vec<RecentDir> {
self.recent_dirs_capped(now, self.max_results)
}
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
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"));
}
}
+1
View File
@@ -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
+79
View File
@@ -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<maestro_daemon::projects::ProjectScanner>,
) -> 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<maestro_protocol::Project> = 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<maestro_protocol::Project> = 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
+6 -1
View File
@@ -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<Vec<Project>, ClientError> {
self.get(routes::PROJECTS, &[]).await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+52
View File
@@ -188,6 +188,30 @@ pub struct GitRoot {
pub root: Option<PathBuf>,
}
/// 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<PathBuf>,
/// 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<DateTime<Utc>>,
/// Command last spawned in this project, from spawn history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_command: Option<String>,
}
/// 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),
+3 -1
View File
@@ -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));