From 774da579ce4895cb2079ef9a4d66e6bf6ab541f2 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 22 May 2026 09:41:40 +0100 Subject: [PATCH 01/11] fix: account for dependency directories in watch mode When I changed watch mode to also watch dependencies' sources, I didn't take into account the fact that the `sources` will be used _as-is_ (i.e. not resolved to anything). This meant the following would happen: - `build:a` has `["src/*.ts"]` - `build:b` has `["lib/*.js"]` and depends on `build:a` - We pass `["src/*.ts", "lib/*.js"]` to watchexec _in the directory of `build:b`_ This made `lib/*.js` basically no-op, or worse, watch the wrong files. This change resolves the paths to their owning directory so we end up with `["wherever-builda-lives/src/*.ts", "wherever-buildb-lives/lib/*.js"]`. **Notable Changes:** - Instead of passing relative globs to watchexec, we now pass resolved ones (relative to the root) - We now pass `--project-origin` to watchexec which comes with some perf gains but also means globs are now relative to it - We pass `--watch {cwd}` for the cwd of each dependency - This new `resolve_source` function is basically turning a source glob into a relative-to-the-root glob while retaining negations --- src/cli/watch.rs | 99 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index f66a665b77..ced7844e7b 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -3,9 +3,11 @@ use crate::cli::Cli; use crate::cli::args::BackendArg; use crate::cmd; use crate::config::Config; +use crate::dirs; use crate::env; use crate::exit::exit; use crate::task::Deps; +use crate::task::task_source_checker::task_cwd; use crate::toolset::ToolsetBuilder; use clap::{CommandFactory, ValueEnum, ValueHint}; use console::style; @@ -13,7 +15,7 @@ use eyre::bail; use itertools::Itertools; use std::cmp::PartialEq; use std::iter::once; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Run task(s) and watch for changes to rerun it /// @@ -180,8 +182,16 @@ impl Watch { args.push("--watch-file".to_string()); args.push(watch_file.to_string_lossy().to_string()); } - let (globs, ignores) = if !self.glob.is_empty() { - (self.glob.clone(), Vec::new()) + // The anchor is the common ancestor of all watch paths, either + // the monorepo root, the project root, or cwd. This is used by + // watchexec as an explicit --project-origin + let anchor_root = config.monorepo_root().or_else(|| config.project_root.clone()); + let has_anchor = anchor_root.is_some(); + let filter_anchor: PathBuf = anchor_root + .or_else(|| dirs::CWD.clone()) + .unwrap_or_default(); + let (globs, ignores, extra_watch_dirs) = if !self.glob.is_empty() { + (self.glob.clone(), Vec::new(), Vec::new()) } else { let collected: Vec<_> = if self.skip_deps { tasks.to_vec() @@ -189,8 +199,37 @@ impl Watch { let deps = Deps::new(&config, tasks.clone()).await?; deps.all().cloned().collect() }; - merge_watch_patterns(collected.iter().map(|t| t.sources.as_slice())) + // Resolve each task's sources relative to the filter_anchor. + // Otherwise, watchexec is given a bunch of relative paths out of + // context. + let mut resolved: Vec> = Vec::with_capacity(collected.len()); + let mut watch_dirs: Vec = Vec::with_capacity(collected.len()); + for t in &collected { + let cwd = task_cwd(t, &config).await?; + resolved.push( + t.sources + .iter() + .map(|s| resolve_source(s, &cwd, &filter_anchor)) + .collect(), + ); + watch_dirs.push(cwd); + } + let watch_dirs: Vec = watch_dirs.into_iter().unique().collect(); + let (i, e) = merge_watch_patterns(resolved.iter().map(|v| v.as_slice())); + (i, e, watch_dirs) }; + if has_anchor { + args.push("--project-origin".to_string()); + args.push(filter_anchor.to_string_lossy().to_string()); + } + // Always include each task's cwd as a watch path + for path in &extra_watch_dirs { + if self.watchexec.recursive_paths.contains(path) { + continue; + } + args.push("--watch".to_string()); + args.push(path.to_string_lossy().to_string()); + } if !globs.is_empty() { args.push("-f".to_string()); args.extend(itertools::intersperse(globs, "-f".to_string()).collect::>()); @@ -258,6 +297,39 @@ impl Watch { } } +/// Resolve a source pattern relative to an anchor, taking +/// into account negations and escaped negations. +fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { + enum Kind { + Negation, + LiteralBang, + Plain, + } + let (kind, rest) = if let Some(r) = s.strip_prefix('!') { + (Kind::Negation, r) + } else if s.starts_with("\\!") { + (Kind::LiteralBang, &s[1..]) + } else { + (Kind::Plain, s) + }; + let p = Path::new(rest); + let absolute = if p.is_absolute() { + p.to_path_buf() + } else { + cwd.join(rest) + }; + let relative = absolute + .strip_prefix(anchor) + .map(|p| p.to_path_buf()) + .unwrap_or(absolute); + let relative = relative.to_string_lossy(); + match kind { + Kind::Negation => format!("!{relative}"), + Kind::LiteralBang if relative.starts_with('!') => format!("\\{relative}"), + Kind::LiteralBang | Kind::Plain => relative.into_owned(), + } +} + /// Merge each task's `sources` into the (filter, ignore) pair watchexec /// expects. /// @@ -1259,7 +1331,8 @@ pub enum ColourMode { #[cfg(test)] mod tests { - use super::merge_watch_patterns; + use super::{merge_watch_patterns, resolve_source}; + use std::path::Path; fn s(v: &[&str]) -> Vec { v.iter().map(|x| x.to_string()).collect() @@ -1308,4 +1381,20 @@ mod tests { "exc should not contain a pattern that any task positively includes; got {exc:?}", ); } + + #[test] + fn resolve_preserves_literal_bang_escape_at_anchor() { + let anchor = Path::new("/repo"); + assert_eq!(resolve_source("\\!keep.txt", anchor, anchor), "\\!keep.txt"); + } + + #[test] + fn resolve_drops_literal_bang_escape_when_no_longer_ambiguous() { + let anchor = Path::new("/repo"); + let cwd = Path::new("/repo/packages/foo"); + assert_eq!( + resolve_source("\\!keep.txt", cwd, anchor), + "packages/foo/!keep.txt", + ); + } } From 3c324835a2d683045ba5cc5d3863b505ab6ac330 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 22 May 2026 19:20:04 +0100 Subject: [PATCH 02/11] fix: account for common ancestors to avoid abs paths --- src/cli/watch.rs | 117 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 27 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index ced7844e7b..1aa4db7e6b 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -182,16 +182,12 @@ impl Watch { args.push("--watch-file".to_string()); args.push(watch_file.to_string_lossy().to_string()); } - // The anchor is the common ancestor of all watch paths, either - // the monorepo root, the project root, or cwd. This is used by - // watchexec as an explicit --project-origin - let anchor_root = config.monorepo_root().or_else(|| config.project_root.clone()); - let has_anchor = anchor_root.is_some(); - let filter_anchor: PathBuf = anchor_root - .or_else(|| dirs::CWD.clone()) - .unwrap_or_default(); - let (globs, ignores, extra_watch_dirs) = if !self.glob.is_empty() { - (self.glob.clone(), Vec::new(), Vec::new()) + // Filter anchor: the path that --project-origin is set to and that + // every glob filter is made relative to. watchexec interprets -f + // patterns relative to the origin and silently rejects absolute + // paths, so the anchor must be an ancestor of every task cwd. + let (globs, ignores, extra_watch_dirs, filter_anchor) = if !self.glob.is_empty() { + (self.glob.clone(), Vec::new(), Vec::new(), None) } else { let collected: Vec<_> = if self.skip_deps { tasks.to_vec() @@ -199,28 +195,46 @@ impl Watch { let deps = Deps::new(&config, tasks.clone()).await?; deps.all().cloned().collect() }; - // Resolve each task's sources relative to the filter_anchor. - // Otherwise, watchexec is given a bunch of relative paths out of - // context. - let mut resolved: Vec> = Vec::with_capacity(collected.len()); - let mut watch_dirs: Vec = Vec::with_capacity(collected.len()); + let mut task_cwds: Vec<(&_, PathBuf)> = Vec::with_capacity(collected.len()); for t in &collected { let cwd = task_cwd(t, &config).await?; - resolved.push( + task_cwds.push((t, cwd)); + } + let configured = config.monorepo_root().or_else(|| config.project_root.clone()); + let common = common_ancestor(task_cwds.iter().map(|(_, c)| c.clone())); + let anchor: PathBuf = match (configured, common) { + (Some(mut cfg), Some(common)) => { + while !common.starts_with(&cfg) { + if !cfg.pop() { + break; + } + } + cfg + } + (Some(cfg), None) => cfg, + (None, Some(common)) => common, + (None, None) => dirs::CWD.clone().unwrap_or_default(), + }; + let resolved: Vec> = task_cwds + .iter() + .map(|(t, cwd)| { t.sources .iter() - .map(|s| resolve_source(s, &cwd, &filter_anchor)) - .collect(), - ); - watch_dirs.push(cwd); - } - let watch_dirs: Vec = watch_dirs.into_iter().unique().collect(); + .map(|s| resolve_source(s, cwd, &anchor)) + .collect() + }) + .collect(); + let watch_dirs: Vec = task_cwds + .into_iter() + .map(|(_, c)| c) + .unique() + .collect(); let (i, e) = merge_watch_patterns(resolved.iter().map(|v| v.as_slice())); - (i, e, watch_dirs) + (i, e, watch_dirs, Some(anchor)) }; - if has_anchor { + if let Some(anchor) = &filter_anchor { args.push("--project-origin".to_string()); - args.push(filter_anchor.to_string_lossy().to_string()); + args.push(anchor.to_string_lossy().to_string()); } // Always include each task's cwd as a watch path for path in &extra_watch_dirs { @@ -297,6 +311,27 @@ impl Watch { } } +/// Longest path that is a prefix of every input path. Returns `None` for +/// an empty iterator. +fn common_ancestor>(paths: I) -> Option { + let mut iter = paths.into_iter(); + let first = iter.next()?; + let mut acc: Vec = first + .components() + .map(|c| c.as_os_str().to_os_string()) + .collect(); + for p in iter { + let comps: Vec<_> = p.components().map(|c| c.as_os_str()).collect(); + let n = acc + .iter() + .zip(comps.iter()) + .take_while(|(a, b)| a.as_os_str() == **b) + .count(); + acc.truncate(n); + } + Some(acc.iter().collect()) +} + /// Resolve a source pattern relative to an anchor, taking /// into account negations and escaped negations. fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { @@ -1331,8 +1366,8 @@ pub enum ColourMode { #[cfg(test)] mod tests { - use super::{merge_watch_patterns, resolve_source}; - use std::path::Path; + use super::{common_ancestor, merge_watch_patterns, resolve_source}; + use std::path::{Path, PathBuf}; fn s(v: &[&str]) -> Vec { v.iter().map(|x| x.to_string()).collect() @@ -1397,4 +1432,32 @@ mod tests { "packages/foo/!keep.txt", ); } + + fn pb(s: &str) -> PathBuf { + PathBuf::from(s) + } + + #[test] + fn common_ancestor_of_siblings_is_parent() { + let got = common_ancestor([pb("/repo/packages/foo"), pb("/repo/packages/bar")]); + assert_eq!(got, Some(pb("/repo/packages"))); + } + + #[test] + fn common_ancestor_of_nested_is_shorter() { + let got = common_ancestor([pb("/repo/a"), pb("/repo/a/b/c")]); + assert_eq!(got, Some(pb("/repo/a"))); + } + + #[test] + fn common_ancestor_of_disjoint_is_root() { + let got = common_ancestor([pb("/x/a"), pb("/y/b")]); + assert_eq!(got, Some(pb("/"))); + } + + #[test] + fn common_ancestor_empty_is_none() { + let got = common_ancestor(std::iter::empty()); + assert_eq!(got, None); + } } From 10d5991bd57af8cdeefd323fcb6f782fca10642b Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 22 May 2026 20:29:20 +0100 Subject: [PATCH 03/11] chore: add debug log when source is absolute --- src/cli/watch.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 1aa4db7e6b..a5ad237f10 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -209,7 +209,11 @@ impl Watch { break; } } - cfg + if cfg.as_os_str().is_empty() { + common + } else { + cfg + } } (Some(cfg), None) => cfg, (None, Some(common)) => common, @@ -353,10 +357,17 @@ fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { } else { cwd.join(rest) }; - let relative = absolute - .strip_prefix(anchor) - .map(|p| p.to_path_buf()) - .unwrap_or(absolute); + let relative = match absolute.strip_prefix(anchor) { + Ok(p) => p.to_path_buf(), + Err(_) => { + debug!( + "watch source {} is outside filter anchor {}; watchexec will silently drop it", + absolute.display(), + anchor.display() + ); + absolute + } + }; let relative = relative.to_string_lossy(); match kind { Kind::Negation => format!("!{relative}"), From a45e5936fdaf0c5eaf1c0b1f1c35e77c2b2bf9e4 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 24 May 2026 15:36:02 +0100 Subject: [PATCH 04/11] chore: pr feedback --- src/cli/watch.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index a5ad237f10..efba31f4dd 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -201,7 +201,7 @@ impl Watch { task_cwds.push((t, cwd)); } let configured = config.monorepo_root().or_else(|| config.project_root.clone()); - let common = common_ancestor(task_cwds.iter().map(|(_, c)| c.clone())); + let common = common_ancestor(task_cwds.iter().map(|(_, c)| c)); let anchor: PathBuf = match (configured, common) { (Some(mut cfg), Some(common)) => { while !common.starts_with(&cfg) { @@ -317,19 +317,23 @@ impl Watch { /// Longest path that is a prefix of every input path. Returns `None` for /// an empty iterator. -fn common_ancestor>(paths: I) -> Option { +fn common_ancestor(paths: I) -> Option +where + I: IntoIterator, + P: AsRef, +{ let mut iter = paths.into_iter(); let first = iter.next()?; let mut acc: Vec = first + .as_ref() .components() .map(|c| c.as_os_str().to_os_string()) .collect(); for p in iter { - let comps: Vec<_> = p.components().map(|c| c.as_os_str()).collect(); let n = acc .iter() - .zip(comps.iter()) - .take_while(|(a, b)| a.as_os_str() == **b) + .zip(p.as_ref().components()) + .take_while(|(a, b)| a.as_os_str() == b.as_os_str()) .count(); acc.truncate(n); } @@ -1468,7 +1472,7 @@ mod tests { #[test] fn common_ancestor_empty_is_none() { - let got = common_ancestor(std::iter::empty()); + let got = common_ancestor(std::iter::empty::()); assert_eq!(got, None); } } From cc12d554b0703a368aa7a88dbb9d7b513a5b8524 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 24 May 2026 15:40:50 +0100 Subject: [PATCH 05/11] chore: switch to warning for abs paths --- src/cli/watch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index efba31f4dd..7e51c5e31b 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -364,7 +364,7 @@ fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { let relative = match absolute.strip_prefix(anchor) { Ok(p) => p.to_path_buf(), Err(_) => { - debug!( + warn!( "watch source {} is outside filter anchor {}; watchexec will silently drop it", absolute.display(), anchor.display() From f004d3829dd95f2378547349742a9b2ddc739886 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 24 May 2026 15:49:12 +0100 Subject: [PATCH 06/11] feat: rework to account for cwd-escaping globs --- src/cli/watch.rs | 115 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 21 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 7e51c5e31b..2904633571 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -200,8 +200,22 @@ impl Watch { let cwd = task_cwd(t, &config).await?; task_cwds.push((t, cwd)); } + // Pre-resolve sources to absolute paths so the anchor can be + // widened to cover any source that escapes its task's cwd via + // `..` or an absolute path. + let parsed: Vec> = task_cwds + .iter() + .map(|(t, cwd)| { + t.sources.iter().map(|s| parse_source(s, cwd)).collect() + }) + .collect(); let configured = config.monorepo_root().or_else(|| config.project_root.clone()); - let common = common_ancestor(task_cwds.iter().map(|(_, c)| c)); + let common = common_ancestor( + task_cwds + .iter() + .map(|(_, c)| c.as_path()) + .chain(parsed.iter().flatten().map(|(_, p)| p.as_path())), + ); let anchor: PathBuf = match (configured, common) { (Some(mut cfg), Some(common)) => { while !common.starts_with(&cfg) { @@ -219,12 +233,12 @@ impl Watch { (None, Some(common)) => common, (None, None) => dirs::CWD.clone().unwrap_or_default(), }; - let resolved: Vec> = task_cwds + let resolved: Vec> = parsed .iter() - .map(|(t, cwd)| { - t.sources + .map(|sources| { + sources .iter() - .map(|s| resolve_source(s, cwd, &anchor)) + .map(|(k, abs)| relativize_source(*k, abs, &anchor)) .collect() }) .collect(); @@ -340,20 +354,21 @@ where Some(acc.iter().collect()) } -/// Resolve a source pattern relative to an anchor, taking -/// into account negations and escaped negations. -fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { - enum Kind { - Negation, - LiteralBang, - Plain, - } +#[derive(Clone, Copy, Debug)] +enum SourceKind { + Negation, + LiteralBang, + Plain, +} + +/// Parse a source pattern into its kind and an absolute path +fn parse_source(s: &str, cwd: &Path) -> (SourceKind, PathBuf) { let (kind, rest) = if let Some(r) = s.strip_prefix('!') { - (Kind::Negation, r) + (SourceKind::Negation, r) } else if s.starts_with("\\!") { - (Kind::LiteralBang, &s[1..]) + (SourceKind::LiteralBang, &s[1..]) } else { - (Kind::Plain, s) + (SourceKind::Plain, s) }; let p = Path::new(rest); let absolute = if p.is_absolute() { @@ -361,6 +376,32 @@ fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { } else { cwd.join(rest) }; + (kind, normalize_path(&absolute)) +} + +/// Resolve `.` and `..` components without touching the FS. +/// Used so a source like `../shared/src/*.ts` produces a path +/// we can contain in the anchor. +fn normalize_path(p: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for c in p.components() { + match c { + Component::ParentDir => { + if !out.pop() { + out.push(".."); + } + } + Component::CurDir => {} + _ => out.push(c.as_os_str()), + } + } + out +} + +/// Express an already-absolute source path relative to the filter anchor, +/// re-applying the original negation/literal-bang prefix. +fn relativize_source(kind: SourceKind, absolute: &Path, anchor: &Path) -> String { let relative = match absolute.strip_prefix(anchor) { Ok(p) => p.to_path_buf(), Err(_) => { @@ -369,14 +410,14 @@ fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { absolute.display(), anchor.display() ); - absolute + absolute.to_path_buf() } }; let relative = relative.to_string_lossy(); match kind { - Kind::Negation => format!("!{relative}"), - Kind::LiteralBang if relative.starts_with('!') => format!("\\{relative}"), - Kind::LiteralBang | Kind::Plain => relative.into_owned(), + SourceKind::Negation => format!("!{relative}"), + SourceKind::LiteralBang if relative.starts_with('!') => format!("\\{relative}"), + SourceKind::LiteralBang | SourceKind::Plain => relative.into_owned(), } } @@ -1381,13 +1422,20 @@ pub enum ColourMode { #[cfg(test)] mod tests { - use super::{common_ancestor, merge_watch_patterns, resolve_source}; + use super::{ + common_ancestor, merge_watch_patterns, normalize_path, parse_source, relativize_source, + }; use std::path::{Path, PathBuf}; fn s(v: &[&str]) -> Vec { v.iter().map(|x| x.to_string()).collect() } + fn resolve_source(s: &str, cwd: &Path, anchor: &Path) -> String { + let (k, abs) = parse_source(s, cwd); + relativize_source(k, &abs, anchor) + } + #[test] fn merge_single_task_splits_pos_and_neg() { let task = s(&["src/**/*.ts", "!src/**/*.test.ts"]); @@ -1470,6 +1518,31 @@ mod tests { assert_eq!(got, Some(pb("/"))); } + #[test] + fn normalize_resolves_parent_components() { + assert_eq!( + normalize_path(Path::new("/repo/pkg/foo/../../shared/src")), + pb("/repo/shared/src"), + ); + } + + #[test] + fn parse_source_absolutizes_relative_with_parent_escape() { + let cwd = Path::new("/repo/packages/foo"); + let (_, abs) = parse_source("../../shared/src/*.ts", cwd); + assert_eq!(abs, pb("/repo/shared/src/*.ts")); + } + + #[test] + fn anchor_widens_to_cover_source_escaping_cwd() { + let cwd = pb("/repo/packages/foo"); + let (_, abs) = parse_source("../../shared/src/*.ts", &cwd); + let common = common_ancestor([cwd.as_path(), abs.as_path()]); + assert_eq!(common, Some(pb("/repo"))); + let rel = relativize_source(super::SourceKind::Plain, &abs, &common.unwrap()); + assert_eq!(rel, "shared/src/*.ts"); + } + #[test] fn common_ancestor_empty_is_none() { let got = common_ancestor(std::iter::empty::()); From b3e7b2fd31eec5a485334584abfa9e6d1ec888c8 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 24 May 2026 16:10:07 +0100 Subject: [PATCH 07/11] feat: handle empy sets of sources --- src/cli/watch.rs | 85 +++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 2904633571..689da0024a 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -205,50 +205,53 @@ impl Watch { // `..` or an absolute path. let parsed: Vec> = task_cwds .iter() - .map(|(t, cwd)| { - t.sources.iter().map(|s| parse_source(s, cwd)).collect() - }) + .map(|(t, cwd)| t.sources.iter().map(|s| parse_source(s, cwd)).collect()) .collect(); - let configured = config.monorepo_root().or_else(|| config.project_root.clone()); - let common = common_ancestor( - task_cwds - .iter() - .map(|(_, c)| c.as_path()) - .chain(parsed.iter().flatten().map(|(_, p)| p.as_path())), - ); - let anchor: PathBuf = match (configured, common) { - (Some(mut cfg), Some(common)) => { - while !common.starts_with(&cfg) { - if !cfg.pop() { - break; + // If no task declared any sources, opt out of source-based + // watching entirely and let watchexec apply its defaults. + if parsed.iter().all(|v| v.is_empty()) { + (Vec::new(), Vec::new(), Vec::new(), None) + } else { + let configured = config + .monorepo_root() + .or_else(|| config.project_root.clone()); + let common = common_ancestor( + task_cwds + .iter() + .map(|(_, c)| c.as_path()) + .chain(parsed.iter().flatten().map(|(_, p)| p.as_path())), + ); + let anchor: PathBuf = match (configured, common) { + (Some(mut cfg), Some(common)) => { + while !common.starts_with(&cfg) { + if !cfg.pop() { + break; + } + } + if cfg.as_os_str().is_empty() { + common + } else { + cfg } } - if cfg.as_os_str().is_empty() { - common - } else { - cfg - } - } - (Some(cfg), None) => cfg, - (None, Some(common)) => common, - (None, None) => dirs::CWD.clone().unwrap_or_default(), - }; - let resolved: Vec> = parsed - .iter() - .map(|sources| { - sources - .iter() - .map(|(k, abs)| relativize_source(*k, abs, &anchor)) - .collect() - }) - .collect(); - let watch_dirs: Vec = task_cwds - .into_iter() - .map(|(_, c)| c) - .unique() - .collect(); - let (i, e) = merge_watch_patterns(resolved.iter().map(|v| v.as_slice())); - (i, e, watch_dirs, Some(anchor)) + (Some(cfg), None) => cfg, + (None, Some(common)) => common, + (None, None) => dirs::CWD.clone().unwrap_or_default(), + }; + let resolved: Vec> = parsed + .iter() + .map(|sources| { + sources + .iter() + .map(|(k, abs)| relativize_source(*k, abs, &anchor)) + .collect() + }) + .collect(); + let watch_dirs: Vec = + task_cwds.into_iter().map(|(_, c)| c).unique().collect(); + let (i, e) = merge_watch_patterns(resolved.iter().map(|v| v.as_slice())); + (i, e, watch_dirs, Some(anchor)) + } }; if let Some(anchor) = &filter_anchor { args.push("--project-origin".to_string()); From 787ea2d9b3ac2b1599c37e153b77c5e064c5a530 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 29 May 2026 13:55:03 +0100 Subject: [PATCH 08/11] fix: handle windows escapes --- src/cli/watch.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 689da0024a..1cb8704b95 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -417,10 +417,17 @@ fn relativize_source(kind: SourceKind, absolute: &Path, anchor: &Path) -> String } }; let relative = relative.to_string_lossy(); + let relative = if std::path::MAIN_SEPARATOR == '/' { + relative.into_owned() + } else { + relative.replace(std::path::MAIN_SEPARATOR, "/") + }; match kind { SourceKind::Negation => format!("!{relative}"), - SourceKind::LiteralBang if relative.starts_with('!') => format!("\\{relative}"), - SourceKind::LiteralBang | SourceKind::Plain => relative.into_owned(), + SourceKind::LiteralBang | SourceKind::Plain if relative.starts_with('!') => { + format!("\\{relative}") + } + SourceKind::LiteralBang | SourceKind::Plain => relative, } } @@ -1499,6 +1506,13 @@ mod tests { ); } + #[test] + fn resolve_escapes_plain_source_relativized_to_leading_bang() { + let cwd = Path::new("/repo"); + let anchor = Path::new("/repo/sub"); + assert_eq!(resolve_source("sub/!gen/*.ts", cwd, anchor), "\\!gen/*.ts"); + } + fn pb(s: &str) -> PathBuf { PathBuf::from(s) } From a9f4248ff2239d73b6c113263451941bbdec4d21 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:41:25 +0100 Subject: [PATCH 09/11] fix: handle watching external directories --- e2e/tasks/test_task_watch_cwd_escape | 45 +++++++++++++++++++ src/cli/watch.rs | 65 +++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 e2e/tasks/test_task_watch_cwd_escape diff --git a/e2e/tasks/test_task_watch_cwd_escape b/e2e/tasks/test_task_watch_cwd_escape new file mode 100644 index 0000000000..991a40f65c --- /dev/null +++ b/e2e/tasks/test_task_watch_cwd_escape @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# Regression: a task whose cwd is a subdirectory (`dir`) but whose `sources` +# escape that cwd via `..` must still be watched. The anchor/project-origin +# has to widen up to the common ancestor so watchexec's filter actually +# matches the out-of-cwd source file when it changes. + +mise use -g watchexec + +mkdir -p packages/foo shared/src + +cat <mise.toml +[tasks.build] +dir = "packages/foo" +sources = ["../../shared/src/*.ts"] +run = "echo built" +EOF + +touch shared/src/index.ts + +LOG_FILE=$(mktemp) +mise watch build >"$LOG_FILE" 2>&1 & +WATCH_PID=$! + +trap 'kill $WATCH_PID' EXIT + +# Wait for the initial run before asserting the re-run. +for _ in $(seq 1 40); do + grep -q "built" "$LOG_FILE" && break + sleep 0.5 +done +if ! grep -q "built" "$LOG_FILE"; then + fail "Initial run never produced output; got: $(cat "$LOG_FILE")" +fi + +echo "" >"$LOG_FILE" # clear so we only capture the re-run + +# Touch the source that lives *outside* the task's cwd. +touch shared/src/index.ts +sleep 2 + +if ! grep -q "built" "$LOG_FILE"; then + fail "Expected re-run after changing ../../shared/src source, got: $(tr -d '\0' <"$LOG_FILE")" +fi +ok "mise watch retriggers on cwd-escaping (..) source" diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 1cb8704b95..dd56960244 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -247,8 +247,21 @@ impl Watch { .collect() }) .collect(); - let watch_dirs: Vec = - task_cwds.into_iter().map(|(_, c)| c).unique().collect(); + let cwds: Vec = + task_cwds.iter().map(|(_, c)| c.clone()).unique().collect(); + let mut watch_dirs = cwds.clone(); + for (kind, abs) in parsed.iter().flatten() { + if matches!(kind, SourceKind::Negation) { + continue; + } + let dir = source_watch_dir(abs); + // Already covered by a recursively-watched cwd. + if cwds.iter().any(|c| dir.starts_with(c)) { + continue; + } + watch_dirs.push(dir); + } + let watch_dirs: Vec = watch_dirs.into_iter().unique().collect(); let (i, e) = merge_watch_patterns(resolved.iter().map(|v| v.as_slice())); (i, e, watch_dirs, Some(anchor)) } @@ -402,6 +415,29 @@ fn normalize_path(p: &Path) -> PathBuf { out } +/// Extracts the directory that should be watched for a source pattern. +/// Basically slices up to the first glob-like character. +fn source_watch_dir(absolute: &Path) -> PathBuf { + use std::path::Component; + let is_glob = |s: &std::ffi::OsStr| s.to_string_lossy().contains(['*', '?', '[', '{']); + let mut dir = PathBuf::new(); + let mut found_glob = false; + for c in absolute.components() { + if let Component::Normal(part) = c { + if is_glob(part) { + found_glob = true; + break; + } + } + dir.push(c.as_os_str()); + } + if found_glob { + dir + } else { + dir.parent().map(|p| p.to_path_buf()).unwrap_or(dir) + } +} + /// Express an already-absolute source path relative to the filter anchor, /// re-applying the original negation/literal-bang prefix. fn relativize_source(kind: SourceKind, absolute: &Path, anchor: &Path) -> String { @@ -1434,6 +1470,7 @@ pub enum ColourMode { mod tests { use super::{ common_ancestor, merge_watch_patterns, normalize_path, parse_source, relativize_source, + source_watch_dir, }; use std::path::{Path, PathBuf}; @@ -1560,6 +1597,30 @@ mod tests { assert_eq!(rel, "shared/src/*.ts"); } + #[test] + fn source_watch_dir_stops_at_first_glob() { + assert_eq!( + source_watch_dir(Path::new("/root/shared/src/*.ts")), + pb("/root/shared/src"), + ); + } + + #[test] + fn source_watch_dir_stops_at_double_star() { + assert_eq!( + source_watch_dir(Path::new("/root/shared/**/*.ts")), + pb("/root/shared"), + ); + } + + #[test] + fn source_watch_dir_of_literal_file_is_parent() { + assert_eq!( + source_watch_dir(Path::new("/root/shared/src/index.ts")), + pb("/root/shared/src"), + ); + } + #[test] fn common_ancestor_empty_is_none() { let got = common_ancestor(std::iter::empty::()); From c1e81714cb4615e21aa758b78f552447cee7a07f Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:53:57 +0100 Subject: [PATCH 10/11] chore: add a sleep loop instead of a constant sleep Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- e2e/tasks/test_task_watch_cwd_escape | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/tasks/test_task_watch_cwd_escape b/e2e/tasks/test_task_watch_cwd_escape index 991a40f65c..bf146860b6 100644 --- a/e2e/tasks/test_task_watch_cwd_escape +++ b/e2e/tasks/test_task_watch_cwd_escape @@ -37,7 +37,10 @@ echo "" >"$LOG_FILE" # clear so we only capture the re-run # Touch the source that lives *outside* the task's cwd. touch shared/src/index.ts -sleep 2 +for _ in $(seq 1 40); do + grep -q "built" "$LOG_FILE" && break + sleep 0.5 +done if ! grep -q "built" "$LOG_FILE"; then fail "Expected re-run after changing ../../shared/src source, got: $(tr -d '\0' <"$LOG_FILE")" From eeabdf50a767c8b4d6ea526fa5eb756a0b2deeaf Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:22:28 +0100 Subject: [PATCH 11/11] chore: formatting --- src/cli/watch.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index dd56960244..198b061ed7 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -423,11 +423,11 @@ fn source_watch_dir(absolute: &Path) -> PathBuf { let mut dir = PathBuf::new(); let mut found_glob = false; for c in absolute.components() { - if let Component::Normal(part) = c { - if is_glob(part) { - found_glob = true; - break; - } + if let Component::Normal(part) = c + && is_glob(part) + { + found_glob = true; + break; } dir.push(c.as_os_str()); }