Skip to content
173 changes: 168 additions & 5 deletions src/cli/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ 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;
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
///
Expand Down Expand Up @@ -180,17 +182,72 @@ 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())
// 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()
} else {
let deps = Deps::new(&config, tasks.clone()).await?;
deps.all().cloned().collect()
};
merge_watch_patterns(collected.iter().map(|t| t.sources.as_slice()))
let mut task_cwds: Vec<(&_, PathBuf)> = Vec::with_capacity(collected.len());
for t in &collected {
let cwd = task_cwd(t, &config).await?;
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()));
Comment thread
43081j marked this conversation as resolved.
Outdated
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
}
}
(Some(cfg), None) => cfg,
(None, Some(common)) => common,
(None, None) => dirs::CWD.clone().unwrap_or_default(),
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
let resolved: Vec<Vec<String>> = task_cwds
.iter()
.map(|(t, cwd)| {
t.sources
.iter()
.map(|s| resolve_source(s, cwd, &anchor))
.collect()
})
.collect();
let watch_dirs: Vec<PathBuf> = 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());
args.push(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());
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if !globs.is_empty() {
args.push("-f".to_string());
args.extend(itertools::intersperse(globs, "-f".to_string()).collect::<Vec<_>>());
Expand Down Expand Up @@ -258,6 +315,67 @@ impl Watch {
}
}

/// Longest path that is a prefix of every input path. Returns `None` for
/// an empty iterator.
fn common_ancestor<I: IntoIterator<Item = PathBuf>>(paths: I) -> Option<PathBuf> {
let mut iter = paths.into_iter();
let first = iter.next()?;
let mut acc: Vec<std::ffi::OsString> = 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())
}
Comment thread
43081j marked this conversation as resolved.
Outdated

/// 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 = 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
}
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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.
///
Expand Down Expand Up @@ -1259,7 +1377,8 @@ pub enum ColourMode {

#[cfg(test)]
mod tests {
use super::merge_watch_patterns;
use super::{common_ancestor, merge_watch_patterns, resolve_source};
use std::path::{Path, PathBuf};

fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
Expand Down Expand Up @@ -1308,4 +1427,48 @@ 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",
);
}

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);
}
}
Loading