use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
use super::topology::WorktreeEntry;
use crate::watcher::{ChangeBatch, Debouncer};
pub type WtId = PathBuf;
#[derive(Debug, Clone, Default)]
pub struct WtRouter {
roots: Vec<PathBuf>,
}
impl WtRouter {
pub fn new<'a, I>(worktrees: I) -> Self
where
I: IntoIterator<Item = &'a WorktreeEntry>,
{
let mut roots: Vec<PathBuf> = worktrees.into_iter().map(|w| w.path.clone()).collect();
roots.sort_by(|a, b| {
b.components()
.count()
.cmp(&a.components().count())
.then(b.cmp(a))
});
Self { roots }
}
pub fn is_empty(&self) -> bool {
self.roots.is_empty()
}
pub fn route(&self, abs_path: &Path) -> Option<&Path> {
self.roots
.iter()
.find(|root| abs_path.starts_with(root))
.map(PathBuf::as_path)
}
pub fn route_for_monitoring(&self, abs_path: &Path) -> Option<&Path> {
let wt = self.route(abs_path)?;
let rel = abs_path.strip_prefix(wt).unwrap_or(abs_path);
let is_noise = rel.components().any(|c| {
matches!(
c,
std::path::Component::Normal(s)
if s == std::ffi::OsStr::new("target")
|| s == std::ffi::OsStr::new(".git")
)
});
if is_noise { None } else { Some(wt) }
}
}
#[derive(Debug)]
pub struct RepoWatchRouter {
router: WtRouter,
quiet: Duration,
debouncers: BTreeMap<WtId, Debouncer>,
}
impl RepoWatchRouter {
pub fn new(router: WtRouter, quiet: Duration) -> Self {
Self {
router,
quiet,
debouncers: BTreeMap::new(),
}
}
pub fn record(&mut self, abs_path: &Path, now: Instant) -> bool {
let wt: WtId = match self.router.route_for_monitoring(abs_path) {
Some(w) => w.to_path_buf(),
None => return false,
};
let quiet = self.quiet;
self.debouncers
.entry(wt)
.or_insert_with(|| Debouncer::new(quiet))
.record(abs_path.to_path_buf(), now);
true
}
pub fn poll(&mut self, now: Instant) -> Vec<(WtId, ChangeBatch)> {
let mut out = Vec::new();
for (wt, deb) in self.debouncers.iter_mut() {
if let Some(batch) = deb.poll(now) {
out.push((wt.clone(), batch));
}
}
out
}
pub fn time_until_ready(&self, now: Instant) -> Option<Duration> {
self.debouncers
.values()
.filter_map(|d| d.time_until_ready(now))
.min()
}
pub fn router(&self) -> &WtRouter {
&self.router
}
}
pub struct RawWatchHandle {
watcher: notify::RecommendedWatcher,
}
impl RawWatchHandle {
pub fn stop(self) {
let _ = self.watcher;
}
}
pub fn raw_repo_watch(root: &Path) -> notify::Result<(RawWatchHandle, Receiver<PathBuf>)> {
let (tx, rx) = channel::<PathBuf>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
for p in ev.paths {
let _ = tx.send(p);
}
}
})?;
{
use notify::Watcher as _;
watcher.watch(root, notify::RecursiveMode::Recursive)?;
}
Ok((RawWatchHandle { watcher }, rx))
}
#[cfg(test)]
mod tests {
use super::*;
const REPO: &str = "/Users/iggy/Documents/GitHub/tf-multiverse";
fn wt(path: &str) -> WorktreeEntry {
let mut v = crate::repo::topology::parse_worktree_porcelain(&format!(
"worktree {path}\nHEAD a\nbranch refs/heads/x\n"
));
v.pop().expect("one entry")
}
fn router() -> WtRouter {
WtRouter::new(
[
wt(REPO),
wt(&format!("{REPO}/.claude/worktrees/agent-x")),
wt("/Users/iggy/Documents/GitHub/tf-multiverse-flat"),
wt("/var/tmp/special-checkout"),
]
.iter(),
)
}
#[test]
fn nested_path_routes_to_nested_wt_not_repo_root() {
let r = router();
assert_eq!(
r.route(Path::new(&format!(
"{REPO}/.claude/worktrees/agent-x/src/orbit.rs"
))),
Some(Path::new(&format!("{REPO}/.claude/worktrees/agent-x")))
);
}
#[test]
fn repo_root_file_routes_to_main() {
let r = router();
assert_eq!(
r.route(Path::new(&format!("{REPO}/crates/physics/src/a.rs"))),
Some(Path::new(REPO))
);
}
#[test]
fn sibling_and_other_route_to_themselves() {
let r = router();
assert_eq!(
r.route(Path::new(
"/Users/iggy/Documents/GitHub/tf-multiverse-flat/src/x.rs"
)),
Some(Path::new("/Users/iggy/Documents/GitHub/tf-multiverse-flat"))
);
assert_eq!(
r.route(Path::new("/var/tmp/special-checkout/lib.rs")),
Some(Path::new("/var/tmp/special-checkout"))
);
}
#[test]
fn path_under_no_worktree_is_none() {
let r = router();
assert_eq!(r.route(Path::new("/etc/passwd")), None);
assert_eq!(
r.route(Path::new("/Users/iggy/Documents/GitHub/other-repo/x.rs")),
None
);
}
#[test]
fn longest_prefix_total_order_is_deterministic() {
let r = WtRouter::new([wt(&format!("{REPO}/.claude/worktrees/agent-x")), wt(REPO)].iter());
assert_eq!(
r.route(Path::new(&format!(
"{REPO}/.claude/worktrees/agent-x/deep/a.rs"
))),
Some(Path::new(&format!("{REPO}/.claude/worktrees/agent-x")))
);
}
#[test]
fn monitoring_floors_target_and_git_noise_within_a_wt() {
let r = router();
let base = format!("{REPO}/.claude/worktrees/agent-x");
assert_eq!(
r.route_for_monitoring(Path::new(&format!("{base}/src/lib.rs"))),
Some(Path::new(&base))
);
assert_eq!(
r.route_for_monitoring(Path::new(&format!("{base}/target/debug/build/x"))),
None
);
assert_eq!(
r.route_for_monitoring(Path::new(&format!("{base}/.git/index"))),
None
);
assert_eq!(
r.route_for_monitoring(Path::new("/tmp/elsewhere/x.rs")),
None
);
}
#[test]
fn empty_router_is_empty_and_routes_nothing() {
let r = WtRouter::new(std::iter::empty());
assert!(r.is_empty());
assert_eq!(r.route(Path::new("/anything")), None);
assert_eq!(r.route_for_monitoring(Path::new("/anything")), None);
}
const QUIET: Duration = Duration::from_millis(100);
fn two_wt_router() -> WtRouter {
WtRouter::new([wt(REPO), wt(&format!("{REPO}/.claude/worktrees/agent-x"))].iter())
}
fn main_a() -> String {
format!("{REPO}/crates/physics/src/a.rs")
}
fn main_b() -> String {
format!("{REPO}/crates/physics/src/b.rs")
}
fn nested() -> String {
format!("{REPO}/.claude/worktrees/agent-x/src/lib.rs")
}
#[test]
fn repo_watch_routes_and_debounces_per_wt() {
let mut rw = RepoWatchRouter::new(two_wt_router(), QUIET);
let t0 = Instant::now();
assert!(rw.record(Path::new(&main_a()), t0));
assert!(rw.poll(t0 + Duration::from_millis(99)).is_empty());
let out = rw.poll(t0 + QUIET);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, PathBuf::from(REPO));
assert_eq!(out[0].1, vec![PathBuf::from(main_a())]);
assert!(rw.poll(t0 + QUIET + QUIET).is_empty());
}
#[test]
fn repo_watch_per_wt_isolation() {
let mut rw = RepoWatchRouter::new(two_wt_router(), QUIET);
let t0 = Instant::now();
rw.record(Path::new(&main_a()), t0); rw.record(Path::new(&nested()), t0); rw.record(Path::new(&nested()), t0 + Duration::from_millis(50));
let out = rw.poll(t0 + QUIET);
assert_eq!(out.len(), 1, "only main settled; nested still churning");
assert_eq!(out[0].0, PathBuf::from(REPO));
let out2 = rw.poll(t0 + Duration::from_millis(150));
assert_eq!(out2.len(), 1);
assert_eq!(
out2[0].0,
PathBuf::from(format!("{REPO}/.claude/worktrees/agent-x"))
);
}
#[test]
fn repo_watch_unrouted_dropped_no_fabricated_wtid() {
let mut rw = RepoWatchRouter::new(two_wt_router(), QUIET);
let t0 = Instant::now();
assert!(!rw.record(Path::new("/etc/passwd"), t0));
assert!(!rw.record(Path::new(&format!("{REPO}/target/debug/x")), t0));
assert!(!rw.record(Path::new(&format!("{REPO}/.git/index")), t0));
assert!(rw.poll(t0 + QUIET + QUIET).is_empty());
assert_eq!(rw.time_until_ready(t0), None);
}
#[test]
fn repo_watch_deterministic_sorted_wtid_order() {
let mut rw = RepoWatchRouter::new(two_wt_router(), QUIET);
let t0 = Instant::now();
rw.record(Path::new(&nested()), t0);
rw.record(Path::new(&main_a()), t0);
let out = rw.poll(t0 + QUIET);
assert_eq!(out.len(), 2);
assert_eq!(out[0].0, PathBuf::from(REPO));
assert_eq!(
out[1].0,
PathBuf::from(format!("{REPO}/.claude/worktrees/agent-x"))
);
}
#[test]
fn repo_watch_batch_has_only_that_wts_paths() {
let mut rw = RepoWatchRouter::new(two_wt_router(), QUIET);
let t0 = Instant::now();
rw.record(Path::new(&main_a()), t0);
rw.record(Path::new(&main_b()), t0);
rw.record(Path::new(&nested()), t0);
let out = rw.poll(t0 + QUIET);
assert_eq!(out.len(), 2);
let agent = format!("{REPO}/.claude/worktrees/agent-x");
let main = out
.iter()
.find(|(w, _)| w.as_path() == Path::new(REPO))
.unwrap();
assert_eq!(
main.1,
vec![PathBuf::from(main_a()), PathBuf::from(main_b())]
);
let nest = out
.iter()
.find(|(w, _)| w.as_path() == Path::new(&agent))
.unwrap();
assert_eq!(nest.1, vec![PathBuf::from(nested())], "no cross-WT bleed");
}
#[test]
fn repo_watch_time_until_ready_is_min_across_wts() {
let mut rw = RepoWatchRouter::new(two_wt_router(), QUIET);
let t0 = Instant::now();
rw.record(Path::new(&main_a()), t0); rw.record(Path::new(&nested()), t0 + Duration::from_millis(50)); assert_eq!(
rw.time_until_ready(t0 + Duration::from_millis(60)),
Some(Duration::from_millis(40))
);
}
#[test]
fn repo_watch_empty_router_drops_all() {
let mut rw = RepoWatchRouter::new(WtRouter::new(std::iter::empty()), QUIET);
let t0 = Instant::now();
assert!(!rw.record(Path::new(&main_a()), t0));
assert!(rw.poll(t0 + QUIET + QUIET).is_empty());
assert_eq!(rw.time_until_ready(t0), None);
}
#[test]
fn raw_repo_watch_constructs_and_raii_drops_clean() {
let dir = std::env::temp_dir().join(format!("cl-rrw-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (handle, rx) = raw_repo_watch(&dir).expect("constructs on a real dir");
assert!(
rx.try_recv().is_err(),
"no events fabricated on a quiet dir"
);
handle.stop();
let (h2, _rx2) = raw_repo_watch(&dir).expect("re-constructs");
drop(h2);
let _ = std::fs::remove_dir_all(&dir);
}
}