Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/with-watch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ with-watch exec --input 'src/**/*.rs' -- cargo test -p with-watch
- Passthrough and shell modes use built-in command adapters before falling back to conservative path heuristics.
- Known outputs, inline scripts, patterns, and shell output redirects are filtered out of the watch set.
- Pathless defaults are intentionally narrow: only `ls`, `dir`, `vdir`, `du`, and `find` implicitly watch the current directory.
- `ls`-style commands watch directory listings via metadata snapshots: plain `ls` watches immediate children, `ls -R` stays recursive, and `ls -d` watches only the named path.
- `exec --input` remains the explicit escape hatch when a delegated command has no meaningful filesystem inputs or when fallback inference would be ambiguous.

## Recognized command inventory
Expand Down Expand Up @@ -104,6 +105,7 @@ with-watch exec --input 'src/**/*.rs' -- cargo test -p with-watch

- `with-watch` always performs one initial run after it has inferred inputs and armed the watcher, even before any external filesystem change occurs.
- The default rerun filter compares content hashes, which avoids reruns from metadata churn alone.
- `ls`, `dir`, and `vdir` use metadata-based listing snapshots instead of hashing every file under the watched directory before the first run.
- `--no-hash` switches the filter to metadata-only comparison.
- Commands that write excluded outputs such as `cp src.txt dest.txt` should rerun when the source input changes, not when the output file changes.
- Commands that mutate watched inputs directly, such as `sed -i.bak -e 's/old/new/' config.txt`, refresh their baseline after each run so they do not loop on their own writes.
Expand Down
252 changes: 230 additions & 22 deletions crates/with-watch/src/analysis.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use std::{ffi::OsString, path::Path};
use std::{ffi::OsString, fs, path::Path};

use crate::{
error::Result,
parser::{ParsedShellExpression, ShellRedirect, ShellRedirectOperator},
snapshot::{WatchInput, WatchInputKind},
snapshot::{absolutize, PathSnapshotMode, WatchInput, WatchInputKind},
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -27,6 +27,7 @@ enum ExplicitCommandHandler {
Sed,
Awk,
Find,
LsLike,
Xargs,
Tar,
Touch,
Expand Down Expand Up @@ -110,6 +111,10 @@ const EXPLICIT_COMMAND_SPECS: &[ExplicitCommandSpec] = &[
&["find"],
ExplicitCommandHandler::Find,
),
ExplicitCommandSpec::dedicated_with_safe_current_dir_default(
&["ls", "dir", "vdir"],
ExplicitCommandHandler::LsLike,
),
ExplicitCommandSpec::dedicated(&["xargs"], ExplicitCommandHandler::Xargs),
ExplicitCommandSpec::dedicated(&["tar"], ExplicitCommandHandler::Tar),
ExplicitCommandSpec::dedicated(&["touch"], ExplicitCommandHandler::Touch),
Expand Down Expand Up @@ -402,7 +407,7 @@ fn analyze_command_tokens(
Ok(analysis.finalize())
}

const DEFAULT_CURRENT_DIR_COMMANDS: &[&str] = &["ls", "dir", "vdir", "du"];
const DEFAULT_CURRENT_DIR_COMMANDS: &[&str] = &["du"];
const NONWATCHABLE_COMMANDS: &[&str] = &[
"echo", "printf", "seq", "yes", "sleep", "date", "uname", "pwd", "true", "false", "basename",
"dirname", "nproc", "printenv", "whoami", "logname", "users", "hostid", "numfmt", "mktemp",
Expand Down Expand Up @@ -555,6 +560,7 @@ fn analyze_explicit_command(
ExplicitCommandHandler::Sed => analyze_sed(argv, redirects, cwd),
ExplicitCommandHandler::Awk => analyze_awk(argv, redirects, cwd),
ExplicitCommandHandler::Find => analyze_find(argv, redirects, cwd),
ExplicitCommandHandler::LsLike => analyze_ls_like(argv, redirects, cwd),
ExplicitCommandHandler::Xargs => analyze_xargs(argv, redirects, cwd),
ExplicitCommandHandler::Tar => analyze_tar(argv, redirects, cwd),
ExplicitCommandHandler::Touch => {
Expand Down Expand Up @@ -2072,6 +2078,122 @@ fn analyze_default_current_dir_reader(
Ok(analysis)
}

fn analyze_ls_like(
argv: &[String],
redirects: &[ShellRedirect],
cwd: &Path,
) -> Result<SingleCommandAnalysis> {
let mut inputs = Vec::new();
let mut positional_only = false;
let mut recursive = false;
let mut directory_mode = false;
let mut index = 1usize;

while index < argv.len() {
let token = argv[index].as_str();
if !positional_only && token == "--" {
positional_only = true;
index += 1;
continue;
}

if !positional_only {
if token == "-R" || token == "--recursive" {
recursive = true;
index += 1;
continue;
}
if token == "-d" || token == "--directory" {
directory_mode = true;
index += 1;
continue;
}
if token.starts_with("--") {
index += 1;
continue;
}
if token.starts_with('-') && token != "-" {
recursive |= token.contains('R');
directory_mode |= token.contains('d');
index += 1;
continue;
}
}

push_inferred_path_with_mode(
&mut inputs,
token,
cwd,
ls_like_snapshot_mode(token, cwd, recursive, directory_mode),
Comment on lines +1916 to +1920
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse ls flags before mapping operand snapshot modes

analyze_ls_like assigns each operand’s PathSnapshotMode immediately using the current recursive/directory_mode booleans, so options that appear later never update earlier operands. This under-watches valid invocations like ls subdir -R (GNU ls treats it as recursive): subdir is recorded as MetadataChildren, and deep descendant updates will not trigger reruns even though delegated command output changes. Parse all effective flags first (or retroactively update collected operands) before choosing snapshot modes.

Useful? React with 👍 / 👎.

)?;
index += 1;
}

if inputs.is_empty() {
push_inferred_path_with_mode(
&mut inputs,
".",
cwd,
default_ls_snapshot_mode(recursive, directory_mode),
)?;
}

let mut analysis = SingleCommandAnalysis {
inputs,
adapter_ids: vec![CommandAdapterId::DefaultCurrentDir],
fallback_used: false,
default_watch_root_used: false,
filtered_output_count: 0,
side_effect_profile: SideEffectProfile::ReadOnly,
status: CommandAnalysisStatus::NoInputs,
};

if argv.len() == 1
|| argv
.iter()
.skip(1)
.all(|token| token == "--" || (token.starts_with('-') && token != "-"))
{
analysis.default_watch_root_used = true;
}

apply_redirects(&mut analysis, redirects, cwd)?;
Ok(analysis)
}

fn default_ls_snapshot_mode(recursive: bool, directory_mode: bool) -> PathSnapshotMode {
if directory_mode {
PathSnapshotMode::MetadataPath
} else if recursive {
PathSnapshotMode::MetadataTree
} else {
PathSnapshotMode::MetadataChildren
}
}

fn ls_like_snapshot_mode(
raw: &str,
cwd: &Path,
recursive: bool,
directory_mode: bool,
) -> PathSnapshotMode {
if directory_mode {
return PathSnapshotMode::MetadataPath;
}

let absolute_path = absolutize(raw, cwd);
match fs::metadata(&absolute_path) {
Ok(metadata) if metadata.is_dir() => {
if recursive {
PathSnapshotMode::MetadataTree
} else {
PathSnapshotMode::MetadataChildren
}
}
Ok(_) | Err(_) => PathSnapshotMode::MetadataPath,
}
}

fn analyze_non_watchable(
_argv: &[String],
redirects: &[ShellRedirect],
Expand Down Expand Up @@ -2282,28 +2404,33 @@ fn push_inferred_input(inputs: &mut Vec<WatchInput>, raw: &str, cwd: &Path) -> R
Ok(())
}

fn push_inferred_path_with_mode(
inputs: &mut Vec<WatchInput>,
raw: &str,
cwd: &Path,
snapshot_mode: PathSnapshotMode,
) -> Result<()> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed == "-" {
return Ok(());
}

let input =
WatchInput::path_with_snapshot_mode(trimmed, cwd, WatchInputKind::Inferred, snapshot_mode)?;

if !inputs.contains(&input) {
inputs.push(input);
}

Ok(())
}

fn has_glob_magic(raw: &str) -> bool {
raw.contains('*') || raw.contains('?') || raw.contains('[')
}

fn path_exists(raw: &str, cwd: &Path) -> bool {
let expanded = expand_tilde(raw);
let path = Path::new(expanded.as_str());
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
absolute.exists()
}

fn expand_tilde(raw: &str) -> String {
if let Some(suffix) = raw.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}/{suffix}");
}
}
raw.to_string()
absolutize(raw, cwd).exists()
}

fn is_path_shaped(token: &str) -> bool {
Expand Down Expand Up @@ -2361,13 +2488,16 @@ fn is_dynamic_shell_token(token: &str) -> bool {

#[cfg(test)]
mod tests {
use std::{collections::BTreeSet, ffi::OsString};
use std::{collections::BTreeSet, ffi::OsString, fs};

use super::{
analyze_argv, analyze_shell_expression, help_inventory, render_after_long_help,
CommandAdapterId, CommandAnalysisStatus, SideEffectProfile,
};
use crate::parser::parse_shell_expression;
use crate::{
parser::parse_shell_expression,
snapshot::{PathSnapshotMode, WatchInput},
};

#[test]
fn cp_watches_only_sources() {
Expand Down Expand Up @@ -2485,6 +2615,11 @@ mod tests {

assert_eq!(analysis.inputs.len(), 1);
assert!(analysis.default_watch_root_used);
assert_path_snapshot_mode(
&analysis.inputs[0],
cwd.path(),
PathSnapshotMode::MetadataChildren,
);

let analysis = analyze_argv(&[OsString::from("find")], cwd.path()).expect("analyze");
assert_eq!(analysis.inputs.len(), 1);
Expand Down Expand Up @@ -2519,6 +2654,61 @@ mod tests {
assert!(analysis.default_watch_root_used);
}

#[test]
fn ls_like_inputs_use_listing_snapshot_modes() {
let cwd = tempfile::tempdir().expect("create tempdir");
fs::create_dir_all(cwd.path().join("subdir").join("nested")).expect("create nested dir");
fs::write(cwd.path().join("file.txt"), "alpha\n").expect("write file");

let default_ls = analyze_argv(&[OsString::from("ls")], cwd.path()).expect("analyze");
assert_path_snapshot_mode(
&default_ls.inputs[0],
cwd.path(),
PathSnapshotMode::MetadataChildren,
);

let directory_ls = analyze_argv(
&[OsString::from("ls"), OsString::from("subdir")],
cwd.path(),
)
.expect("analyze");
assert_path_snapshot_mode(
&directory_ls.inputs[0],
&cwd.path().join("subdir"),
PathSnapshotMode::MetadataChildren,
);

let recursive_ls = analyze_argv(
&[
OsString::from("ls"),
OsString::from("-R"),
OsString::from("subdir"),
],
cwd.path(),
)
.expect("analyze");
assert_path_snapshot_mode(
&recursive_ls.inputs[0],
&cwd.path().join("subdir"),
PathSnapshotMode::MetadataTree,
);

let directory_flag_ls = analyze_argv(
&[
OsString::from("ls"),
OsString::from("-d"),
OsString::from("subdir"),
],
cwd.path(),
)
.expect("analyze");
assert_path_snapshot_mode(
&directory_flag_ls.inputs[0],
&cwd.path().join("subdir"),
PathSnapshotMode::MetadataPath,
);
}

#[test]
fn tar_excludes_archive_outputs_for_create_and_reads_archives_for_extract() {
let cwd = tempfile::tempdir().expect("create tempdir");
Expand Down Expand Up @@ -2661,4 +2851,22 @@ mod tests {
assert_eq!(analysis.inputs.len(), 1);
assert_eq!(analysis.filtered_output_count, 1);
}

fn assert_path_snapshot_mode(
input: &WatchInput,
expected_path: &std::path::Path,
expected_snapshot_mode: PathSnapshotMode,
) {
match input {
WatchInput::Path {
path,
snapshot_mode,
..
} => {
assert_eq!(path, expected_path);
assert_eq!(*snapshot_mode, expected_snapshot_mode);
}
other => panic!("unexpected watch input: {other:?}"),
}
}
}
Loading
Loading