From 6c23afe8397558a1baf2258cd23d0fc0e643c7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 28 Apr 2024 18:33:07 +1200 Subject: [PATCH] feat: make it possible to watch non-recursively (#827) Fixes #227 Fixes #174 docs(cli): be more precise in print-events advice to use `-v` docs(cli): improve jaq error help feat(cli): add `-W` for non-recursive watches feat(cli): use non-blocking logging feat(globset): hide `fmt::Debug` spew from ignore crate feat(ignore-files): hide `fmt::Debug` spew from ignore crate feat(lib): make it possible to watch non-recursively fix(lib): inserting `WatchedPath`s directly should be possible refactor(lib): move `WatchedPath` out of `fs` mod --- Cargo.lock | 13 ++ completions/bash | 18 ++- completions/elvish | 6 +- completions/fish | 5 +- completions/nu | 5 +- completions/powershell | 6 +- completions/zsh | 6 +- crates/cli/Cargo.toml | 1 + crates/cli/release.toml | 2 + crates/cli/src/args.rs | 164 +++++++++++++++++--------- crates/cli/src/args/logging.rs | 132 +++++++++++++++++++++ crates/cli/src/config.rs | 17 +-- crates/cli/src/{filterer => }/dirs.rs | 89 +++++--------- crates/cli/src/filterer.rs | 8 +- crates/cli/src/filterer/parse.rs | 2 +- crates/cli/src/lib.rs | 86 +------------- crates/filterer/globset/CHANGELOG.md | 2 + crates/filterer/globset/Cargo.toml | 6 + crates/filterer/globset/src/lib.rs | 16 ++- crates/ignore-files/CHANGELOG.md | 2 + crates/ignore-files/Cargo.toml | 6 + crates/ignore-files/src/filter.rs | 14 ++- crates/lib/CHANGELOG.md | 4 + crates/lib/src/config.rs | 6 +- crates/lib/src/lib.rs | 2 + crates/lib/src/sources/fs.rs | 58 +++------ crates/lib/src/watched_path.rs | 82 +++++++++++++ doc/watchexec.1 | 37 +++--- doc/watchexec.1.md | 61 ++++++---- 29 files changed, 538 insertions(+), 318 deletions(-) create mode 100644 crates/cli/src/args/logging.rs rename crates/cli/src/{filterer => }/dirs.rs (72%) create mode 100644 crates/lib/src/watched_path.rs diff --git a/Cargo.lock b/Cargo.lock index c42cb4b..d0523be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3685,6 +3685,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.27" @@ -4060,6 +4072,7 @@ dependencies = [ "termcolor", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "tracing-test", "uuid", diff --git a/completions/bash b/completions/bash index 2f8e7ad..31f6230 100644 --- a/completions/bash +++ b/completions/bash @@ -19,7 +19,7 @@ _watchexec() { case "${cmd}" in watchexec) - opts="-w -c -o -r -s -d -p -n -E -1 -N -q -e -f -j -i -v -h -V --watch --clear --on-busy-update --restart --signal --stop-signal --stop-timeout --map-signal --debounce --stdin-quit --no-vcs-ignore --no-project-ignore --no-global-ignore --no-default-ignore --no-discover-ignore --ignore-nothing --postpone --delay-run --poll --shell --no-environment --emit-events-to --only-emit-events --env --no-process-group --wrap-process --notify --color --timings --quiet --bell --project-origin --workdir --exts --filter --filter-file --filter-prog --ignore --ignore-file --fs-events --no-meta --print-events --verbose --log-file --manual --completions --help --version [COMMAND]..." + opts="-w -W -c -o -r -s -d -p -n -E -1 -N -q -e -f -j -i -v -h -V --watch --watch-non-recursive --clear --on-busy-update --restart --signal --stop-signal --stop-timeout --map-signal --debounce --stdin-quit --no-vcs-ignore --no-project-ignore --no-global-ignore --no-default-ignore --no-discover-ignore --ignore-nothing --postpone --delay-run --poll --shell --no-environment --emit-events-to --only-emit-events --env --no-process-group --wrap-process --notify --color --timings --quiet --bell --project-origin --workdir --exts --filter --filter-file --filter-prog --ignore --ignore-file --fs-events --no-meta --print-events --manual --completions --verbose --log-file --help --version [COMMAND]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -33,6 +33,14 @@ _watchexec() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --watch-non-recursive) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -W) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --clear) COMPREPLY=($(compgen -W "clear reset" -- "${cur}")) return 0 @@ -189,14 +197,14 @@ _watchexec() { COMPREPLY=($(compgen -W "access create remove rename modify metadata" -- "${cur}")) return 0 ;; - --log-file) - COMPREPLY=($(compgen -f "${cur}")) - return 0 - ;; --completions) COMPREPLY=($(compgen -W "bash elvish fish nu powershell zsh" -- "${cur}")) return 0 ;; + --log-file) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; diff --git a/completions/elvish b/completions/elvish index ce7ebc4..6af0bfd 100644 --- a/completions/elvish +++ b/completions/elvish @@ -20,6 +20,8 @@ set edit:completion:arg-completer[watchexec] = {|@words| &'watchexec'= { cand -w 'Watch a specific file or directory' cand --watch 'Watch a specific file or directory' + cand -W 'Watch a specific directory, non-recursively' + cand --watch-non-recursive 'Watch a specific directory, non-recursively' cand -c 'Clear screen before running command' cand --clear 'Clear screen before running command' cand -o 'What to do when receiving events while the command is running' @@ -52,8 +54,8 @@ set edit:completion:arg-completer[watchexec] = {|@words| cand --ignore 'Filename patterns to filter out' cand --ignore-file 'Files to load ignores from' cand --fs-events 'Filesystem events to filter to' - cand --log-file 'Write diagnostic logs to a file' cand --completions 'Generate a shell completions script' + cand --log-file 'Write diagnostic logs to a file' cand -r 'Restart the process if it''s still running' cand --restart 'Restart the process if it''s still running' cand --stdin-quit 'Exit when stdin closes' @@ -78,9 +80,9 @@ set edit:completion:arg-completer[watchexec] = {|@words| cand --bell 'Ring the terminal bell on command completion' cand --no-meta 'Don''t emit fs events for metadata changes' cand --print-events 'Print events that trigger actions' + cand --manual 'Show the manual page' cand -v 'Set diagnostic log level' cand --verbose 'Set diagnostic log level' - cand --manual 'Show the manual page' cand -h 'Print help (see more with ''--help'')' cand --help 'Print help (see more with ''--help'')' cand -V 'Print version' diff --git a/completions/fish b/completions/fish index c29c8ca..e35afa1 100644 --- a/completions/fish +++ b/completions/fish @@ -1,4 +1,5 @@ complete -c watchexec -s w -l watch -d 'Watch a specific file or directory' -r -F +complete -c watchexec -s W -l watch-non-recursive -d 'Watch a specific directory, non-recursively' -r -F complete -c watchexec -s c -l clear -d 'Clear screen before running command' -r -f -a "{clear '',reset ''}" complete -c watchexec -s o -l on-busy-update -d 'What to do when receiving events while the command is running' -r -f -a "{queue '',do-nothing '',restart '',signal ''}" complete -c watchexec -s s -l signal -d 'Send a signal to the process when it\'s still running' -r @@ -22,8 +23,8 @@ complete -c watchexec -s j -l filter-prog -d '[experimental] Filter programs' -r complete -c watchexec -s i -l ignore -d 'Filename patterns to filter out' -r complete -c watchexec -l ignore-file -d 'Files to load ignores from' -r -F complete -c watchexec -l fs-events -d 'Filesystem events to filter to' -r -f -a "{access '',create '',remove '',rename '',modify '',metadata ''}" -complete -c watchexec -l log-file -d 'Write diagnostic logs to a file' -r -F complete -c watchexec -l completions -d 'Generate a shell completions script' -r -f -a "{bash '',elvish '',fish '',nu '',powershell '',zsh ''}" +complete -c watchexec -l log-file -d 'Write diagnostic logs to a file' -r -F complete -c watchexec -s r -l restart -d 'Restart the process if it\'s still running' complete -c watchexec -l stdin-quit -d 'Exit when stdin closes' complete -c watchexec -l no-vcs-ignore -d 'Don\'t load gitignores' @@ -44,7 +45,7 @@ complete -c watchexec -s q -l quiet -d 'Don\'t print starting and stopping messa complete -c watchexec -l bell -d 'Ring the terminal bell on command completion' complete -c watchexec -l no-meta -d 'Don\'t emit fs events for metadata changes' complete -c watchexec -l print-events -d 'Print events that trigger actions' -complete -c watchexec -s v -l verbose -d 'Set diagnostic log level' complete -c watchexec -l manual -d 'Show the manual page' +complete -c watchexec -s v -l verbose -d 'Set diagnostic log level' complete -c watchexec -s h -l help -d 'Print help (see more with \'--help\')' complete -c watchexec -s V -l version -d 'Print version' diff --git a/completions/nu b/completions/nu index 45f76ab..bb86068 100644 --- a/completions/nu +++ b/completions/nu @@ -32,6 +32,7 @@ module completions { export extern watchexec [ ...command: string # Command to run on changes --watch(-w): string # Watch a specific file or directory + --watch-non-recursive(-W): string # Watch a specific directory, non-recursively --clear(-c): string@"nu-complete watchexec screen_clear" # Clear screen before running command --on-busy-update(-o): string@"nu-complete watchexec on_busy_update" # What to do when receiving events while the command is running --restart(-r) # Restart the process if it's still running @@ -75,10 +76,10 @@ module completions { --fs-events: string@"nu-complete watchexec filter_fs_events" # Filesystem events to filter to --no-meta # Don't emit fs events for metadata changes --print-events # Print events that trigger actions - --verbose(-v) # Set diagnostic log level - --log-file: string # Write diagnostic logs to a file --manual # Show the manual page --completions: string@"nu-complete watchexec completions" # Generate a shell completions script + --verbose(-v) # Set diagnostic log level + --log-file: string # Write diagnostic logs to a file --help(-h) # Print help (see more with '--help') --version(-V) # Print version ] diff --git a/completions/powershell b/completions/powershell index fb7f861..7513880 100644 --- a/completions/powershell +++ b/completions/powershell @@ -23,6 +23,8 @@ Register-ArgumentCompleter -Native -CommandName 'watchexec' -ScriptBlock { 'watchexec' { [CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Watch a specific file or directory') [CompletionResult]::new('--watch', 'watch', [CompletionResultType]::ParameterName, 'Watch a specific file or directory') + [CompletionResult]::new('-W', 'W ', [CompletionResultType]::ParameterName, 'Watch a specific directory, non-recursively') + [CompletionResult]::new('--watch-non-recursive', 'watch-non-recursive', [CompletionResultType]::ParameterName, 'Watch a specific directory, non-recursively') [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'Clear screen before running command') [CompletionResult]::new('--clear', 'clear', [CompletionResultType]::ParameterName, 'Clear screen before running command') [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'What to do when receiving events while the command is running') @@ -55,8 +57,8 @@ Register-ArgumentCompleter -Native -CommandName 'watchexec' -ScriptBlock { [CompletionResult]::new('--ignore', 'ignore', [CompletionResultType]::ParameterName, 'Filename patterns to filter out') [CompletionResult]::new('--ignore-file', 'ignore-file', [CompletionResultType]::ParameterName, 'Files to load ignores from') [CompletionResult]::new('--fs-events', 'fs-events', [CompletionResultType]::ParameterName, 'Filesystem events to filter to') - [CompletionResult]::new('--log-file', 'log-file', [CompletionResultType]::ParameterName, 'Write diagnostic logs to a file') [CompletionResult]::new('--completions', 'completions', [CompletionResultType]::ParameterName, 'Generate a shell completions script') + [CompletionResult]::new('--log-file', 'log-file', [CompletionResultType]::ParameterName, 'Write diagnostic logs to a file') [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Restart the process if it''s still running') [CompletionResult]::new('--restart', 'restart', [CompletionResultType]::ParameterName, 'Restart the process if it''s still running') [CompletionResult]::new('--stdin-quit', 'stdin-quit', [CompletionResultType]::ParameterName, 'Exit when stdin closes') @@ -81,9 +83,9 @@ Register-ArgumentCompleter -Native -CommandName 'watchexec' -ScriptBlock { [CompletionResult]::new('--bell', 'bell', [CompletionResultType]::ParameterName, 'Ring the terminal bell on command completion') [CompletionResult]::new('--no-meta', 'no-meta', [CompletionResultType]::ParameterName, 'Don''t emit fs events for metadata changes') [CompletionResult]::new('--print-events', 'print-events', [CompletionResultType]::ParameterName, 'Print events that trigger actions') + [CompletionResult]::new('--manual', 'manual', [CompletionResultType]::ParameterName, 'Show the manual page') [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Set diagnostic log level') [CompletionResult]::new('--verbose', 'verbose', [CompletionResultType]::ParameterName, 'Set diagnostic log level') - [CompletionResult]::new('--manual', 'manual', [CompletionResultType]::ParameterName, 'Show the manual page') [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') [CompletionResult]::new('-V', 'V ', [CompletionResultType]::ParameterName, 'Print version') diff --git a/completions/zsh b/completions/zsh index 1cf9db9..0706096 100644 --- a/completions/zsh +++ b/completions/zsh @@ -17,6 +17,8 @@ _watchexec() { _arguments "${_arguments_options[@]}" \ '*-w+[Watch a specific file or directory]:PATH:_files' \ '*--watch=[Watch a specific file or directory]:PATH:_files' \ +'*-W+[Watch a specific directory, non-recursively]:PATH:_files' \ +'*--watch-non-recursive=[Watch a specific directory, non-recursively]:PATH:_files' \ '-c+[Clear screen before running command]' \ '--clear=[Clear screen before running command]' \ '-o+[What to do when receiving events while the command is running]:MODE:(queue do-nothing restart signal)' \ @@ -49,8 +51,8 @@ _watchexec() { '*--ignore=[Filename patterns to filter out]:PATTERN: ' \ '*--ignore-file=[Files to load ignores from]:PATH:_files' \ '*--fs-events=[Filesystem events to filter to]:EVENTS:(access create remove rename modify metadata)' \ -'--log-file=[Write diagnostic logs to a file]' \ '(--manual)--completions=[Generate a shell completions script]:COMPLETIONS:(bash elvish fish nu powershell zsh)' \ +'--log-file=[Write diagnostic logs to a file]' \ '(-o --on-busy-update)-r[Restart the process if it'\''s still running]' \ '(-o --on-busy-update)--restart[Restart the process if it'\''s still running]' \ '--stdin-quit[Exit when stdin closes]' \ @@ -75,9 +77,9 @@ _watchexec() { '--bell[Ring the terminal bell on command completion]' \ '(--fs-events)--no-meta[Don'\''t emit fs events for metadata changes]' \ '--print-events[Print events that trigger actions]' \ +'(--completions)--manual[Show the manual page]' \ '*-v[Set diagnostic log level]' \ '*--verbose[Set diagnostic log level]' \ -'(--completions)--manual[Show the manual page]' \ '-h[Print help (see more with '\''--help'\'')]' \ '--help[Print help (see more with '\''--help'\'')]' \ '-V[Print version]' \ diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8ffc6ac..1c92ca8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -44,6 +44,7 @@ serde_json = "1.0.107" tempfile = "3.8.1" termcolor = "1.4.0" tracing = "0.1.40" +tracing-appender = "0.2.3" which = "6.0.1" [dependencies.blake3] diff --git a/crates/cli/release.toml b/crates/cli/release.toml index 6a4def2..fd1b038 100644 --- a/crates/cli/release.toml +++ b/crates/cli/release.toml @@ -2,6 +2,8 @@ pre-release-commit-message = "release: cli v{{version}}" tag-prefix = "" tag-message = "watchexec {{version}}" +pre-release-hook = ["sh", "-c", "cd ../.. && bin/completions && bin/manpage"] + [[pre-release-replacements]] file = "watchexec.exe.manifest" search = "^ version=\"[\\d.]+[.]0\"" diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 581139b..377138f 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -1,21 +1,28 @@ use std::{ + collections::BTreeSet, ffi::{OsStr, OsString}, - path::PathBuf, + fs::canonicalize, + mem::take, + path::{Path, PathBuf}, str::FromStr, time::Duration, }; use clap::{ - builder::TypedValueParser, error::ErrorKind, Arg, ArgAction, Command, CommandFactory, Parser, - ValueEnum, ValueHint, + builder::TypedValueParser, error::ErrorKind, Arg, Command, CommandFactory, Parser, ValueEnum, + ValueHint, }; use miette::{IntoDiagnostic, Result}; use tokio::{fs::File, io::AsyncReadExt}; -use watchexec::paths::PATH_SEPARATOR; +use tracing::{debug, info, trace, warn}; +use tracing_appender::non_blocking::WorkerGuard; +use watchexec::{paths::PATH_SEPARATOR, sources::fs::WatchedPath}; use watchexec_signals::Signal; use crate::filterer::parse::parse_filter_program; +mod logging; + const OPTSET_FILTERING: &str = "Filtering"; const OPTSET_COMMAND: &str = "Command"; const OPTSET_DEBUGGING: &str = "Debugging"; @@ -128,7 +135,25 @@ pub struct Args { value_hint = ValueHint::AnyPath, value_name = "PATH", )] - pub paths: Vec, + pub recursive_paths: Vec, + + /// Watch a specific directory, non-recursively + /// + /// Unlike '-w', folders watched with this option are not recursed into. + /// + /// This option can be specified multiple times to watch multiple directories non-recursively. + #[arg( + short = 'W', + long = "watch-non-recursive", + help_heading = OPTSET_FILTERING, + value_hint = ValueHint::AnyPath, + value_name = "PATH", + )] + pub non_recursive_paths: Vec, + + #[doc(hidden)] + #[arg(skip)] + pub paths: Vec, /// Clear screen before running command /// @@ -791,6 +816,7 @@ pub struct Args { /// /// Provide your own custom filter programs in jaq (similar to jq) syntax. Programs are given /// an event in the same format as described in '--emit-events-to' and must return a boolean. + /// Invalid programs will make watchexec fail to start; use '-v' to see program runtime errors. /// /// In addition to the jaq stdlib, watchexec adds some custom filter definitions: /// @@ -921,54 +947,13 @@ pub struct Args { /// This prints the events that triggered the action when handling it (after debouncing), in a /// human readable form. This is useful for debugging filters. /// - /// Use '-v' when you need more diagnostic information. + /// Use '-vvv' instead when you need more diagnostic information. #[arg( long, help_heading = OPTSET_DEBUGGING, )] pub print_events: bool, - /// Set diagnostic log level - /// - /// This enables diagnostic logging, which is useful for investigating bugs or gaining more - /// insight into faulty filters or "missing" events. Use multiple times to increase verbosity. - /// - /// Goes up to '-vvvv'. When submitting bug reports, default to a '-vvv' log level. - /// - /// You may want to use with '--log-file' to avoid polluting your terminal. - /// - /// Setting $RUST_LOG also works, and takes precedence, but is not recommended. However, using - /// $RUST_LOG is the only way to get logs from before these options are parsed. - #[arg( - long, - short, - help_heading = OPTSET_DEBUGGING, - action = ArgAction::Count, - num_args = 0, - )] - pub verbose: Option, - - /// Write diagnostic logs to a file - /// - /// This writes diagnostic logs to a file, instead of the terminal, in JSON format. If a log - /// level was not already specified, this will set it to '-vvv'. - /// - /// If a path is not provided, the default is the working directory. Note that with - /// '--ignore-nothing', the write events to the log will likely get picked up by Watchexec, - /// causing a loop; prefer setting a path outside of the watched directory. - /// - /// If the path provided is a directory, a file will be created in that directory. The file name - /// will be the current date and time, in the format 'watchexec.YYYY-MM-DDTHH-MM-SSZ.log'. - #[arg( - long, - help_heading = OPTSET_DEBUGGING, - num_args = 0..=1, - default_missing_value = ".", - value_hint = ValueHint::AnyPath, - value_name = "PATH", - )] - pub log_file: Option, - /// Show the manual page /// /// This shows the manual page for Watchexec, if the output is a terminal and the 'man' program @@ -993,6 +978,9 @@ pub struct Args { conflicts_with_all = ["command", "manual"], )] pub completions: Option, + + #[command(flatten)] + pub logging: logging::LoggingArgs, } #[derive(Clone, Copy, Debug, Default, ValueEnum)] @@ -1159,11 +1147,10 @@ fn expand_args_up_to_doubledash() -> Result, std::io::Error> { } #[inline] -pub async fn get_args() -> Result { - use tracing::{debug, trace, warn}; - - if std::env::var("RUST_LOG").is_ok() { - warn!("⚠ RUST_LOG environment variable set, logging options have no effect"); +pub async fn get_args() -> Result<(Args, Option)> { + let prearg_logs = logging::preargs(); + if prearg_logs { + warn!("⚠ RUST_LOG environment variable set or hardcoded, logging options have no effect"); } debug!("expanding @argfile arguments if any"); @@ -1172,6 +1159,12 @@ pub async fn get_args() -> Result { debug!("parsing arguments"); let mut args = Args::parse_from(args); + let log_guard = if !prearg_logs { + logging::postargs(&args.logging).await? + } else { + None + }; + // https://no-color.org/ if args.color == ColourMode::Auto && std::env::var("NO_COLOR").is_ok() { args.color = ColourMode::Never; @@ -1192,10 +1185,12 @@ pub async fn get_args() -> Result { } if args.no_environment { + warn!("--no-environment is deprecated"); args.emit_events_to = EmitEvents::None; } if args.no_process_group { + warn!("--no-process-group is deprecated"); args.wrap_process = WrapMode::None; } @@ -1221,6 +1216,63 @@ pub async fn get_args() -> Result { .exit(); } + let workdir = if let Some(w) = take(&mut args.workdir) { + w + } else { + let curdir = std::env::current_dir().into_diagnostic()?; + canonicalize(curdir).into_diagnostic()? + }; + info!(path=?workdir, "effective working directory"); + args.workdir = Some(workdir.clone()); + + let project_origin = if let Some(p) = take(&mut args.project_origin) { + p + } else { + crate::dirs::project_origin(&args).await? + }; + info!(path=?project_origin, "effective project origin"); + args.project_origin = Some(project_origin.clone()); + + args.paths = take(&mut args.recursive_paths) + .into_iter() + .map(|path| { + { + if path.is_absolute() { + Ok(path) + } else { + canonicalize(project_origin.join(path)).into_diagnostic() + } + } + .map(WatchedPath::non_recursive) + }) + .chain(take(&mut args.non_recursive_paths).into_iter().map(|path| { + { + if path.is_absolute() { + Ok(path) + } else { + canonicalize(project_origin.join(path)).into_diagnostic() + } + } + .map(WatchedPath::non_recursive) + })) + .collect::>>()? + .into_iter() + .collect(); + + if args.paths.len() == 1 + && args + .paths + .first() + .map_or(false, |p| p.as_ref() == Path::new("/dev/null")) + { + info!("only path is /dev/null, not watching anything"); + args.paths = Vec::new(); + } else if args.paths.is_empty() { + info!("no paths, using current directory"); + args.paths.push(args.workdir.clone().unwrap().into()); + } + info!(paths=?args.paths, "effective watched paths"); + for (n, prog) in args.filter_programs.iter_mut().enumerate() { if let Some(progpath) = prog.strip_prefix('@') { trace!(?n, path=?progpath, "reading filter program from file"); @@ -1233,12 +1285,14 @@ pub async fn get_args() -> Result { } } - args.filter_programs_parsed = std::mem::take(&mut args.filter_programs) + args.filter_programs_parsed = take(&mut args.filter_programs) .into_iter() .enumerate() .map(parse_filter_program) .collect::>()?; - debug!(?args, "got arguments"); - Ok(args) + debug_assert!(args.workdir.is_some()); + debug_assert!(args.project_origin.is_some()); + info!(?args, "got arguments"); + Ok((args, log_guard)) } diff --git a/crates/cli/src/args/logging.rs b/crates/cli/src/args/logging.rs new file mode 100644 index 0000000..2298b03 --- /dev/null +++ b/crates/cli/src/args/logging.rs @@ -0,0 +1,132 @@ +use std::{env::var, io::stderr, path::PathBuf}; + +use clap::{ArgAction, Parser, ValueHint}; +use miette::{bail, Result}; +use tokio::fs::metadata; +use tracing::{info, warn}; +use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling}; + +#[derive(Debug, Clone, Parser)] +pub struct LoggingArgs { + /// Set diagnostic log level + /// + /// This enables diagnostic logging, which is useful for investigating bugs or gaining more + /// insight into faulty filters or "missing" events. Use multiple times to increase verbosity. + /// + /// Goes up to '-vvvv'. When submitting bug reports, default to a '-vvv' log level. + /// + /// You may want to use with '--log-file' to avoid polluting your terminal. + /// + /// Setting $RUST_LOG also works, and takes precedence, but is not recommended. However, using + /// $RUST_LOG is the only way to get logs from before these options are parsed. + #[arg( + long, + short, + help_heading = super::OPTSET_DEBUGGING, + action = ArgAction::Count, + default_value = "0", + num_args = 0, + )] + pub verbose: u8, + + /// Write diagnostic logs to a file + /// + /// This writes diagnostic logs to a file, instead of the terminal, in JSON format. If a log + /// level was not already specified, this will set it to '-vvv'. + /// + /// If a path is not provided, the default is the working directory. Note that with + /// '--ignore-nothing', the write events to the log will likely get picked up by Watchexec, + /// causing a loop; prefer setting a path outside of the watched directory. + /// + /// If the path provided is a directory, a file will be created in that directory. The file name + /// will be the current date and time, in the format 'watchexec.YYYY-MM-DDTHH-MM-SSZ.log'. + #[arg( + long, + help_heading = super::OPTSET_DEBUGGING, + num_args = 0..=1, + default_missing_value = ".", + value_hint = ValueHint::AnyPath, + value_name = "PATH", + )] + pub log_file: Option, +} + +pub fn preargs() -> bool { + let mut log_on = false; + + #[cfg(feature = "dev-console")] + match console_subscriber::try_init() { + Ok(_) => { + warn!("dev-console enabled"); + log_on = true; + } + Err(e) => { + eprintln!("Failed to initialise tokio console, falling back to normal logging\n{e}") + } + } + + if !log_on && var("RUST_LOG").is_ok() { + match tracing_subscriber::fmt::try_init() { + Ok(()) => { + warn!(RUST_LOG=%var("RUST_LOG").unwrap(), "logging configured from RUST_LOG"); + log_on = true; + } + Err(e) => eprintln!("Failed to initialise logging with RUST_LOG, falling back\n{e}"), + } + } + + log_on +} + +pub async fn postargs(args: &LoggingArgs) -> Result> { + if args.verbose == 0 { + return Ok(None); + } + + let (log_writer, guard) = if let Some(file) = &args.log_file { + let is_dir = metadata(&file).await.map_or(false, |info| info.is_dir()); + let (dir, filename) = if is_dir { + ( + file.to_owned(), + PathBuf::from(format!( + "watchexec.{}.log", + chrono::Utc::now().format("%Y-%m-%dT%H-%M-%SZ") + )), + ) + } else if let (Some(parent), Some(file_name)) = (file.parent(), file.file_name()) { + (parent.into(), PathBuf::from(file_name)) + } else { + bail!("Failed to determine log file name"); + }; + + non_blocking(rolling::never(dir, filename)) + } else { + non_blocking(stderr()) + }; + + let mut builder = tracing_subscriber::fmt().with_env_filter(match args.verbose { + 0 => unreachable!("checked by if earlier"), + 1 => "warn", + 2 => "info", + 3 => "debug", + _ => "trace", + }); + + if args.verbose > 2 { + use tracing_subscriber::fmt::format::FmtSpan; + builder = builder.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE); + } + + match if args.log_file.is_some() { + builder.json().with_writer(log_writer).try_init() + } else if args.verbose > 3 { + builder.pretty().with_writer(log_writer).try_init() + } else { + builder.with_writer(log_writer).try_init() + } { + Ok(()) => info!("logging initialised"), + Err(e) => eprintln!("Failed to initialise logging, continuing with none\n{e}"), + } + + Ok(Some(guard)) +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 41a4620..55867a8 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,11 +1,10 @@ use std::{ borrow::Cow, collections::HashMap, - env::{current_dir, var}, + env::var, ffi::{OsStr, OsString}, fs::File, io::{IsTerminal, Write}, - path::Path, process::Stdio, sync::{ atomic::{AtomicBool, AtomicU8, Ordering}, @@ -68,19 +67,7 @@ pub fn make_config(args: &Args, state: &State) -> Result { eprintln!("[[Error (not fatal)]]\n{}", Report::new(err.error)); }); - config.pathset(if args.paths.is_empty() { - vec![current_dir().into_diagnostic()?] - } else if args.paths.len() == 1 - && args - .paths - .first() - .map_or(false, |p| p == Path::new("/dev/null")) - { - // special case: /dev/null means "don't start the fs event source" - Vec::new() - } else { - args.paths.clone() - }); + config.pathset(args.paths.clone()); config.throttle(args.debounce.0); config.keyboard_events(args.stdin_quit); diff --git a/crates/cli/src/filterer/dirs.rs b/crates/cli/src/dirs.rs similarity index 72% rename from crates/cli/src/filterer/dirs.rs rename to crates/cli/src/dirs.rs index 82a8b78..1eb1bbd 100644 --- a/crates/cli/src/filterer/dirs.rs +++ b/crates/cli/src/dirs.rs @@ -1,7 +1,5 @@ use std::{ - borrow::Cow, collections::HashSet, - env, path::{Path, PathBuf}, }; @@ -14,16 +12,7 @@ use watchexec::paths::common_prefix; use crate::args::Args; -type ProjectOriginPath = PathBuf; -type WorkDirPath = PathBuf; - -/// Extract relevant directories (in particular the project origin and work directory) -/// given the command line arguments that were provided -pub async fn dirs(args: &Args) -> Result<(ProjectOriginPath, WorkDirPath)> { - let curdir = env::current_dir().into_diagnostic()?; - let curdir = canonicalize(curdir).await.into_diagnostic()?; - debug!(?curdir, "current directory"); - +pub async fn project_origin(args: &Args) -> Result { let project_origin = if let Some(origin) = &args.project_origin { debug!(?origin, "project origin override"); canonicalize(origin).await.into_diagnostic()? @@ -34,27 +23,19 @@ pub async fn dirs(args: &Args) -> Result<(ProjectOriginPath, WorkDirPath)> { }; debug!(?homedir, "home directory"); - let mut paths = HashSet::new(); - for path in &args.paths { - paths.insert(canonicalize(path).await.into_diagnostic()?); - } - - let homedir_requested = homedir.as_ref().map_or(false, |home| paths.contains(home)); + let homedir_requested = homedir.as_ref().map_or(false, |home| { + args.paths + .binary_search_by_key(home, |w| PathBuf::from(w.clone())) + .is_ok() + }); debug!( ?homedir_requested, "resolved whether the homedir is explicitly requested" ); - if paths.is_empty() { - debug!("no paths, using current directory"); - paths.insert(curdir.clone()); - } - - debug!(?paths, "resolved all watched paths"); - let mut origins = HashSet::new(); - for path in paths { - origins.extend(project_origins::origins(&path).await); + for path in &args.paths { + origins.extend(project_origins::origins(path).await); } match (homedir, homedir_requested) { @@ -67,7 +48,7 @@ pub async fn dirs(args: &Args) -> Result<(ProjectOriginPath, WorkDirPath)> { if origins.is_empty() { debug!("no origins, using current directory"); - origins.insert(curdir.clone()); + origins.insert(args.workdir.clone().unwrap()); } debug!(?origins, "resolved all project origins"); @@ -80,12 +61,9 @@ pub async fn dirs(args: &Args) -> Result<(ProjectOriginPath, WorkDirPath)> { .await .into_diagnostic()? }; - info!(?project_origin, "resolved common/project origin"); + debug!(?project_origin, "resolved common/project origin"); - let workdir = curdir; - info!(?workdir, "resolved working directory"); - - Ok((project_origin, workdir)) + Ok(project_origin) } pub async fn vcs_types(origin: &Path) -> Vec { @@ -94,41 +72,34 @@ pub async fn vcs_types(origin: &Path) -> Vec { .into_iter() .filter(|pt| pt.is_vcs()) .collect::>(); - info!(?vcs_types, "resolved vcs types"); + info!(?vcs_types, "effective vcs types"); vcs_types } -pub async fn ignores( - args: &Args, - vcs_types: &[ProjectType], - origin: &Path, -) -> Result> { - fn higher_make_absolute_if_needed<'a>( - origin: &'a Path, - ) -> impl 'a + Fn(&'a PathBuf) -> Cow<'a, Path> { - |path| { - if path.is_absolute() { - Cow::Borrowed(path) - } else { - Cow::Owned(origin.join(path)) - } - } - } - +pub async fn ignores(args: &Args, vcs_types: &[ProjectType]) -> Result> { + let origin = args.project_origin.clone().unwrap(); let mut skip_git_global_excludes = false; let mut ignores = if args.no_project_ignore { Vec::new() } else { - let make_absolute_if_needed = higher_make_absolute_if_needed(origin); - let include_paths = args.paths.iter().map(&make_absolute_if_needed); - let ignore_files = args.ignore_files.iter().map(&make_absolute_if_needed); + let ignore_files = args.ignore_files.iter().map(|path| { + if path.is_absolute() { + path.into() + } else { + origin.join(path) + } + }); let (mut ignores, errors) = ignore_files::from_origin( - IgnoreFilesFromOriginArgs::new_unchecked(origin, include_paths, ignore_files) - .canonicalise() - .await - .into_diagnostic()?, + IgnoreFilesFromOriginArgs::new_unchecked( + &origin, + args.paths.iter().map(PathBuf::from), + ignore_files, + ) + .canonicalise() + .await + .into_diagnostic()?, ) .await; @@ -221,7 +192,7 @@ pub async fn ignores( .filter(|ig| { !ig.applies_in .as_ref() - .map_or(false, |p| p.starts_with(origin)) + .map_or(false, |p| p.starts_with(&origin)) }) .collect::>(); debug!( diff --git a/crates/cli/src/filterer.rs b/crates/cli/src/filterer.rs index 6d9ec5c..4461560 100644 --- a/crates/cli/src/filterer.rs +++ b/crates/cli/src/filterer.rs @@ -16,7 +16,6 @@ use watchexec_filterer_globset::GlobsetFilterer; use crate::args::{Args, FsEvent}; -mod dirs; pub(crate) mod parse; mod proglib; mod progs; @@ -71,13 +70,14 @@ impl Filterer for WatchexecFilterer { impl WatchexecFilterer { /// Create a new filterer from the given arguments pub async fn new(args: &Args) -> Result> { - let (project_origin, workdir) = dirs::dirs(args).await?; + let project_origin = args.project_origin.clone().unwrap(); + let workdir = args.workdir.clone().unwrap(); let ignore_files = if args.no_discover_ignore { Vec::new() } else { - let vcs_types = dirs::vcs_types(&project_origin).await; - dirs::ignores(args, &vcs_types, &project_origin).await? + let vcs_types = crate::dirs::vcs_types(&project_origin).await; + crate::dirs::ignores(args, &vcs_types).await? }; let mut ignores = Vec::new(); diff --git a/crates/cli/src/filterer/parse.rs b/crates/cli/src/filterer/parse.rs index 31c67c2..fb6e3aa 100644 --- a/crates/cli/src/filterer/parse.rs +++ b/crates/cli/src/filterer/parse.rs @@ -10,7 +10,7 @@ pub fn parse_filter_program((n, prog): (usize, String)) -> Result .map(|err| err.to_string()) .collect::>() .join("\n"); - return Err(miette!("failed to load filter program #{}: {:?}", n, errs)); + return Err(miette!("{}", errs).wrap_err(format!("failed to load filter program #{}", n))); } main.ok_or_else(|| miette!("failed to load filter program #{} (no reason given)", n)) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index bd50cea..4b77360 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,7 +1,7 @@ #![deny(rust_2018_idioms)] #![allow(clippy::missing_const_for_fn, clippy::future_not_send)] -use std::{env::var, fs::File, io::Write, process::Stdio, sync::Mutex}; +use std::{io::Write, process::Stdio}; use args::{Args, ShellCompletion}; use clap::CommandFactory; @@ -9,8 +9,8 @@ use clap_complete::{Generator, Shell}; use clap_mangen::Man; use is_terminal::IsTerminal; use miette::{IntoDiagnostic, Result}; -use tokio::{fs::metadata, io::AsyncWriteExt, process::Command}; -use tracing::{debug, info, warn}; +use tokio::{io::AsyncWriteExt, process::Command}; +use tracing::{debug, info}; use watchexec::Watchexec; use watchexec_events::{Event, Priority}; @@ -18,86 +18,11 @@ use crate::filterer::WatchexecFilterer; pub mod args; mod config; +mod dirs; mod emits; mod filterer; mod state; -async fn init() -> Result { - let mut log_on = false; - - #[cfg(feature = "dev-console")] - match console_subscriber::try_init() { - Ok(_) => { - warn!("dev-console enabled"); - log_on = true; - } - Err(e) => { - eprintln!("Failed to initialise tokio console, falling back to normal logging\n{e}") - } - } - - if !log_on && var("RUST_LOG").is_ok() { - match tracing_subscriber::fmt::try_init() { - Ok(()) => { - warn!(RUST_LOG=%var("RUST_LOG").unwrap(), "logging configured from RUST_LOG"); - log_on = true; - } - Err(e) => eprintln!("Failed to initialise logging with RUST_LOG, falling back\n{e}"), - } - } - - let args = args::get_args().await?; - let verbosity = args.verbose.unwrap_or(0); - - if log_on { - warn!("ignoring logging options from args"); - } else if verbosity > 0 { - let log_file = if let Some(file) = &args.log_file { - let is_dir = metadata(&file).await.map_or(false, |info| info.is_dir()); - let path = if is_dir { - let filename = format!( - "watchexec.{}.log", - chrono::Utc::now().format("%Y-%m-%dT%H-%M-%SZ") - ); - file.join(filename) - } else { - file.to_owned() - }; - - // TODO: use tracing-appender instead - Some(File::create(path).into_diagnostic()?) - } else { - None - }; - - let mut builder = tracing_subscriber::fmt().with_env_filter(match verbosity { - 0 => unreachable!("checked by if earlier"), - 1 => "warn", - 2 => "info", - 3 => "debug", - _ => "trace", - }); - - if verbosity > 2 { - use tracing_subscriber::fmt::format::FmtSpan; - builder = builder.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE); - } - - match if let Some(writer) = log_file { - builder.json().with_writer(Mutex::new(writer)).try_init() - } else if verbosity > 3 { - builder.pretty().try_init() - } else { - builder.try_init() - } { - Ok(()) => info!("logging initialised"), - Err(e) => eprintln!("Failed to initialise logging, continuing with none\n{e}"), - } - } - - Ok(args) -} - async fn run_watchexec(args: Args) -> Result<()> { info!(version=%env!("CARGO_PKG_VERSION"), "constructing Watchexec from CLI"); @@ -191,8 +116,7 @@ async fn run_completions(shell: ShellCompletion) -> Result<()> { } pub async fn run() -> Result<()> { - let args = init().await?; - debug!(?args, "arguments"); + let (args, _log_guard) = args::get_args().await?; if args.manual { run_manpage(args).await diff --git a/crates/filterer/globset/CHANGELOG.md b/crates/filterer/globset/CHANGELOG.md index 257f39f..8ad96f6 100644 --- a/crates/filterer/globset/CHANGELOG.md +++ b/crates/filterer/globset/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next (YYYY-MM-DD) +- Hide fmt::Debug spew from ignore crate, use `full_debug` feature to restore. + ## v4.0.0 (2024-04-20) - Deps: watchexec 4 diff --git a/crates/filterer/globset/Cargo.toml b/crates/filterer/globset/Cargo.toml index 41e9e40..efe256f 100644 --- a/crates/filterer/globset/Cargo.toml +++ b/crates/filterer/globset/Cargo.toml @@ -47,3 +47,9 @@ features = [ "rt-multi-thread", "macros", ] + +[features] +default = [] + +## Don't hide ignore::gitignore::Gitignore Debug impl +full_debug = [] diff --git a/crates/filterer/globset/src/lib.rs b/crates/filterer/globset/src/lib.rs index aa4327a..e65a114 100644 --- a/crates/filterer/globset/src/lib.rs +++ b/crates/filterer/globset/src/lib.rs @@ -10,6 +10,7 @@ use std::{ ffi::OsString, + fmt, path::{Path, PathBuf}, }; @@ -21,7 +22,7 @@ use watchexec_events::{Event, FileType, Priority}; use watchexec_filterer_ignore::IgnoreFilterer; /// A simple filterer in the style of the watchexec v1.17 filter. -#[derive(Debug)] +#[cfg_attr(feature = "full_debug", derive(Debug))] pub struct GlobsetFilterer { #[cfg_attr(not(unix), allow(dead_code))] origin: PathBuf, @@ -31,6 +32,19 @@ pub struct GlobsetFilterer { extensions: Vec, } +#[cfg(not(feature = "full_debug"))] +impl fmt::Debug for GlobsetFilterer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GlobsetFilterer") + .field("origin", &self.origin) + .field("filters", &"ignore::gitignore::Gitignore{...}") + .field("ignores", &"ignore::gitignore::Gitignore{...}") + .field("ignore_files", &self.ignore_files) + .field("extensions", &self.extensions) + .finish() + } +} + impl GlobsetFilterer { /// Create a new `GlobsetFilterer` from a project origin, allowed extensions, and lists of globs. /// diff --git a/crates/ignore-files/CHANGELOG.md b/crates/ignore-files/CHANGELOG.md index 8edf61f..6f692a4 100644 --- a/crates/ignore-files/CHANGELOG.md +++ b/crates/ignore-files/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next (YYYY-MM-DD) +- Hide fmt::Debug spew from ignore crate, use `full_debug` feature to restore. + ## v3.0.0 (2024-04-20) - Deps: gix-config 0.36 diff --git a/crates/ignore-files/Cargo.toml b/crates/ignore-files/Cargo.toml index d4dc276..370a988 100644 --- a/crates/ignore-files/Cargo.toml +++ b/crates/ignore-files/Cargo.toml @@ -40,3 +40,9 @@ path = "../project-origins" [dev-dependencies] tracing-subscriber = "0.3.6" + +[features] +default = [] + +## Don't hide ignore::gitignore::Gitignore Debug impl +full_debug = [] diff --git a/crates/ignore-files/src/filter.rs b/crates/ignore-files/src/filter.rs index 922cbc5..59e8de7 100644 --- a/crates/ignore-files/src/filter.rs +++ b/crates/ignore-files/src/filter.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::path::{Path, PathBuf}; use futures::stream::{FuturesUnordered, StreamExt}; @@ -11,12 +12,23 @@ use tracing::{trace, trace_span}; use crate::{simplify_path, Error, IgnoreFile}; -#[derive(Clone, Debug)] +#[derive(Clone)] +#[cfg_attr(feature = "full_debug", derive(Debug))] struct Ignore { gitignore: Gitignore, builder: Option, } +#[cfg(not(feature = "full_debug"))] +impl fmt::Debug for Ignore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Ignore") + .field("gitignore", &"ignore::gitignore::Gitignore{...}") + .field("builder", &"ignore::gitignore::GitignoreBuilder{...}") + .finish() + } +} + /// A mutable filter dedicated to ignore files and trees of ignore files. /// /// This reads and compiles ignore files, and should be used for handling ignore files. It's created diff --git a/crates/lib/CHANGELOG.md b/crates/lib/CHANGELOG.md index 8fed8a0..7622a71 100644 --- a/crates/lib/CHANGELOG.md +++ b/crates/lib/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next (YYYY-MM-DD) +- Feature: non-recursive watches with `WatchedPath::non_recursive()` +- Fix: `config.pathset()` now preserves `WatchedPath` attributes +- Refactor: move `WatchedPath` to the root of the crate (old path remains as re-export for now) + ## v4.0.0 (2024-04-20) - Deps: replace command-group with process-wrap (in supervisor, but has flow-on effects) diff --git a/crates/lib/src/config.rs b/crates/lib/src/config.rs index fa7fbf0..74443e2 100644 --- a/crates/lib/src/config.rs +++ b/crates/lib/src/config.rs @@ -1,6 +1,6 @@ //! Configuration and builders for [`crate::Watchexec`]. -use std::{future::Future, path::Path, pin::pin, sync::Arc, time::Duration}; +use std::{future::Future, pin::pin, sync::Arc, time::Duration}; use tokio::sync::Notify; use tracing::{debug, trace}; @@ -195,9 +195,9 @@ impl Config { pub fn pathset(&self, pathset: I) -> &Self where I: IntoIterator, - P: AsRef, + P: Into, { - let pathset = pathset.into_iter().map(|p| p.as_ref().into()).collect(); + let pathset = pathset.into_iter().map(|p| p.into()).collect(); debug!(?pathset, "Config: pathset"); self.pathset.replace(pathset); self.signal_change() diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 3d06032..125137a 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -68,12 +68,14 @@ pub mod config; mod id; mod late_join_set; +mod watched_path; mod watchexec; #[doc(inline)] pub use crate::{ id::Id, watchexec::{ErrorHook, Watchexec}, + watched_path::WatchedPath, }; #[doc(no_inline)] diff --git a/crates/lib/src/sources/fs.rs b/crates/lib/src/sources/fs.rs index 3239d43..d8f969f 100644 --- a/crates/lib/src/sources/fs.rs +++ b/crates/lib/src/sources/fs.rs @@ -4,7 +4,6 @@ use std::{ collections::{HashMap, HashSet}, fs::metadata, mem::take, - path::{Path, PathBuf}, sync::Arc, time::Duration, }; @@ -20,6 +19,9 @@ use crate::{ Config, }; +// re-export for compatibility, until next major version +pub use crate::WatchedPath; + /// What kind of filesystem watcher to use. /// /// For now only native and poll watchers are supported. In the future there may be additional @@ -72,42 +74,6 @@ impl Watcher { } } -/// A path to watch. -/// -/// This is currently only a wrapper around a [`PathBuf`], but may be augmented in the future. -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct WatchedPath(PathBuf); - -impl From for WatchedPath { - fn from(path: PathBuf) -> Self { - Self(path) - } -} - -impl From<&str> for WatchedPath { - fn from(path: &str) -> Self { - Self(path.into()) - } -} - -impl From<&Path> for WatchedPath { - fn from(path: &Path) -> Self { - Self(path.into()) - } -} - -impl From for PathBuf { - fn from(path: WatchedPath) -> Self { - path.0 - } -} - -impl AsRef for WatchedPath { - fn as_ref(&self) -> &Path { - self.0.as_ref() - } -} - /// Launch the filesystem event worker. /// /// While you can run several, you should only have one. @@ -190,6 +156,7 @@ pub async fn worker( // now let's calculate which paths we should add to the watch, and which we should drop: let config_pathset = config.pathset.get(); + tracing::info!(?config_pathset, "obtaining pathset"); let (to_watch, to_drop) = if pathset.is_empty() { // if the current pathset is empty, we can take a shortcut (config_pathset, Vec::new()) @@ -222,7 +189,7 @@ pub async fn worker( for path in to_drop { trace!(?path, "removing path from the watcher"); - if let Err(err) = watcher.unwatch(path.as_ref()) { + if let Err(err) = watcher.unwatch(path.path.as_ref()) { error!(?err, "notify unwatch() error"); for e in notify_multi_path_errors(watcher_type, path, err, true) { errors.send(e).await?; @@ -234,13 +201,18 @@ pub async fn worker( for path in to_watch { trace!(?path, "adding path to the watcher"); - if let Err(err) = watcher.watch(path.as_ref(), notify::RecursiveMode::Recursive) { + if let Err(err) = watcher.watch( + path.path.as_ref(), + if path.recursive { + notify::RecursiveMode::Recursive + } else { + notify::RecursiveMode::NonRecursive + }, + ) { error!(?err, "notify watch() error"); for e in notify_multi_path_errors(watcher_type, path, err, false) { errors.send(e).await?; } - // TODO: unwatch and re-watch manually while ignoring all the erroring paths - // See https://github.com/watchexec/watchexec/issues/218 } else { pathset.insert(path); } @@ -250,13 +222,13 @@ pub async fn worker( fn notify_multi_path_errors( kind: Watcher, - path: WatchedPath, + watched_path: WatchedPath, mut err: notify::Error, rm: bool, ) -> Vec { let mut paths = take(&mut err.paths); if paths.is_empty() { - paths.push(path.into()); + paths.push(watched_path.into()); } let generic = err.to_string(); diff --git a/crates/lib/src/watched_path.rs b/crates/lib/src/watched_path.rs new file mode 100644 index 0000000..446ed60 --- /dev/null +++ b/crates/lib/src/watched_path.rs @@ -0,0 +1,82 @@ +use std::path::{Path, PathBuf}; + +/// A path to watch. +/// +/// Can be a recursive or non-recursive watch. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WatchedPath { + pub(crate) path: PathBuf, + pub(crate) recursive: bool, +} + +impl From for WatchedPath { + fn from(path: PathBuf) -> Self { + Self { + path, + recursive: true, + } + } +} + +impl From<&str> for WatchedPath { + fn from(path: &str) -> Self { + Self { + path: path.into(), + recursive: true, + } + } +} + +impl From for WatchedPath { + fn from(path: String) -> Self { + Self { + path: path.into(), + recursive: true, + } + } +} + +impl From<&Path> for WatchedPath { + fn from(path: &Path) -> Self { + Self { + path: path.into(), + recursive: true, + } + } +} + +impl From for PathBuf { + fn from(path: WatchedPath) -> Self { + path.path + } +} + +impl From<&WatchedPath> for PathBuf { + fn from(path: &WatchedPath) -> Self { + path.path.clone() + } +} + +impl AsRef for WatchedPath { + fn as_ref(&self) -> &Path { + self.path.as_ref() + } +} + +impl WatchedPath { + /// Create a new watched path, recursively descending into subdirectories. + pub fn recursive(path: impl Into) -> Self { + Self { + path: path.into(), + recursive: true, + } + } + + /// Create a new watched path, not descending into subdirectories. + pub fn non_recursive(path: impl Into) -> Self { + Self { + path: path.into(), + recursive: false, + } + } +} diff --git a/doc/watchexec.1 b/doc/watchexec.1 index 6314178..911a255 100644 --- a/doc/watchexec.1 +++ b/doc/watchexec.1 @@ -4,7 +4,7 @@ .SH NAME watchexec \- Execute commands when watched files change .SH SYNOPSIS -\fBwatchexec\fR [\fB\-w\fR|\fB\-\-watch\fR] [\fB\-c\fR|\fB\-\-clear\fR] [\fB\-o\fR|\fB\-\-on\-busy\-update\fR] [\fB\-r\fR|\fB\-\-restart\fR] [\fB\-s\fR|\fB\-\-signal\fR] [\fB\-\-stop\-signal\fR] [\fB\-\-stop\-timeout\fR] [\fB\-\-map\-signal\fR] [\fB\-d\fR|\fB\-\-debounce\fR] [\fB\-\-stdin\-quit\fR] [\fB\-\-no\-vcs\-ignore\fR] [\fB\-\-no\-project\-ignore\fR] [\fB\-\-no\-global\-ignore\fR] [\fB\-\-no\-default\-ignore\fR] [\fB\-\-no\-discover\-ignore\fR] [\fB\-\-ignore\-nothing\fR] [\fB\-p\fR|\fB\-\-postpone\fR] [\fB\-\-delay\-run\fR] [\fB\-\-poll\fR] [\fB\-\-shell\fR] [\fB\-n \fR] [\fB\-\-emit\-events\-to\fR] [\fB\-\-only\-emit\-events\fR] [\fB\-E\fR|\fB\-\-env\fR] [\fB\-\-no\-process\-group\fR] [\fB\-\-wrap\-process\fR] [\fB\-N\fR|\fB\-\-notify\fR] [\fB\-\-color\fR] [\fB\-\-timings\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-\-bell\fR] [\fB\-\-project\-origin\fR] [\fB\-\-workdir\fR] [\fB\-e\fR|\fB\-\-exts\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-filter\-file\fR] [\fB\-j\fR|\fB\-\-filter\-prog\fR] [\fB\-i\fR|\fB\-\-ignore\fR] [\fB\-\-ignore\-file\fR] [\fB\-\-fs\-events\fR] [\fB\-\-no\-meta\fR] [\fB\-\-print\-events\fR] [\fB\-v\fR|\fB\-\-verbose\fR]... [\fB\-\-log\-file\fR] [\fB\-\-manual\fR] [\fB\-\-completions\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICOMMAND\fR] +\fBwatchexec\fR [\fB\-w\fR|\fB\-\-watch\fR] [\fB\-W\fR|\fB\-\-watch\-non\-recursive\fR] [\fB\-c\fR|\fB\-\-clear\fR] [\fB\-o\fR|\fB\-\-on\-busy\-update\fR] [\fB\-r\fR|\fB\-\-restart\fR] [\fB\-s\fR|\fB\-\-signal\fR] [\fB\-\-stop\-signal\fR] [\fB\-\-stop\-timeout\fR] [\fB\-\-map\-signal\fR] [\fB\-d\fR|\fB\-\-debounce\fR] [\fB\-\-stdin\-quit\fR] [\fB\-\-no\-vcs\-ignore\fR] [\fB\-\-no\-project\-ignore\fR] [\fB\-\-no\-global\-ignore\fR] [\fB\-\-no\-default\-ignore\fR] [\fB\-\-no\-discover\-ignore\fR] [\fB\-\-ignore\-nothing\fR] [\fB\-p\fR|\fB\-\-postpone\fR] [\fB\-\-delay\-run\fR] [\fB\-\-poll\fR] [\fB\-\-shell\fR] [\fB\-n \fR] [\fB\-\-emit\-events\-to\fR] [\fB\-\-only\-emit\-events\fR] [\fB\-E\fR|\fB\-\-env\fR] [\fB\-\-no\-process\-group\fR] [\fB\-\-wrap\-process\fR] [\fB\-N\fR|\fB\-\-notify\fR] [\fB\-\-color\fR] [\fB\-\-timings\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-\-bell\fR] [\fB\-\-project\-origin\fR] [\fB\-\-workdir\fR] [\fB\-e\fR|\fB\-\-exts\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-filter\-file\fR] [\fB\-j\fR|\fB\-\-filter\-prog\fR] [\fB\-i\fR|\fB\-\-ignore\fR] [\fB\-\-ignore\-file\fR] [\fB\-\-fs\-events\fR] [\fB\-\-no\-meta\fR] [\fB\-\-print\-events\fR] [\fB\-\-manual\fR] [\fB\-\-completions\fR] [\fB\-v\fR|\fB\-\-verbose\fR]... [\fB\-\-log\-file\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICOMMAND\fR] .SH DESCRIPTION Execute commands when watched files change. .PP @@ -48,6 +48,13 @@ This option can be specified multiple times to watch multiple files or directori The special value \*(Aq/dev/null\*(Aq, provided as the only path watched, will cause Watchexec to not watch any paths. Other event sources (like signals or key events) may still be used. .TP +\fB\-W\fR, \fB\-\-watch\-non\-recursive\fR=\fIPATH\fR +Watch a specific directory, non\-recursively + +Unlike \*(Aq\-w\*(Aq, folders watched with this option are not recursed into. + +This option can be specified multiple times to watch multiple directories non\-recursively. +.TP \fB\-c\fR, \fB\-\-clear\fR=\fIMODE\fR Clear screen before running command @@ -439,7 +446,7 @@ This can also be used via the $WATCHEXEC_FILTER_FILES environment variable. /!\\ This option is EXPERIMENTAL and may change and/or vanish without notice. -Provide your own custom filter programs in jaq (similar to jq) syntax. Programs are given an event in the same format as described in \*(Aq\-\-emit\-events\-to\*(Aq and must return a boolean. +Provide your own custom filter programs in jaq (similar to jq) syntax. Programs are given an event in the same format as described in \*(Aq\-\-emit\-events\-to\*(Aq and must return a boolean. Invalid programs will make watchexec fail to start; use \*(Aq\-v\*(Aq to see program runtime errors. In addition to the jaq stdlib, watchexec adds some custom filter definitions: @@ -510,7 +517,19 @@ Print events that trigger actions This prints the events that triggered the action when handling it (after debouncing), in a human readable form. This is useful for debugging filters. -Use \*(Aq\-v\*(Aq when you need more diagnostic information. +Use \*(Aq\-vvv\*(Aq instead when you need more diagnostic information. +.TP +\fB\-\-manual\fR +Show the manual page + +This shows the manual page for Watchexec, if the output is a terminal and the \*(Aqman\*(Aq program is available. If not, the manual page is printed to stdout in ROFF format (suitable for writing to a watchexec.1 file). +.TP +\fB\-\-completions\fR=\fICOMPLETIONS\fR +Generate a shell completions script + +Provides a completions script or configuration for the given shell. If Watchexec is not distributed with pre\-generated completions, you can use this to generate them yourself. + +Supported shells: bash, elvish, fish, nu, powershell, zsh. .TP \fB\-v\fR, \fB\-\-verbose\fR Set diagnostic log level @@ -532,18 +551,6 @@ If a path is not provided, the default is the working directory. Note that with If the path provided is a directory, a file will be created in that directory. The file name will be the current date and time, in the format \*(Aqwatchexec.YYYY\-MM\-DDTHH\-MM\-SSZ.log\*(Aq. .TP -\fB\-\-manual\fR -Show the manual page - -This shows the manual page for Watchexec, if the output is a terminal and the \*(Aqman\*(Aq program is available. If not, the manual page is printed to stdout in ROFF format (suitable for writing to a watchexec.1 file). -.TP -\fB\-\-completions\fR=\fICOMPLETIONS\fR -Generate a shell completions script - -Provides a completions script or configuration for the given shell. If Watchexec is not distributed with pre\-generated completions, you can use this to generate them yourself. - -Supported shells: bash, elvish, fish, nu, powershell, zsh. -.TP \fB\-h\fR, \fB\-\-help\fR Print help (see a summary with \*(Aq\-h\*(Aq) .TP diff --git a/doc/watchexec.1.md b/doc/watchexec.1.md index 8212b9b..6ccd492 100644 --- a/doc/watchexec.1.md +++ b/doc/watchexec.1.md @@ -4,7 +4,8 @@ watchexec - Execute commands when watched files change # SYNOPSIS -**watchexec** \[**-w**\|**\--watch**\] \[**-c**\|**\--clear**\] +**watchexec** \[**-w**\|**\--watch**\] +\[**-W**\|**\--watch-non-recursive**\] \[**-c**\|**\--clear**\] \[**-o**\|**\--on-busy-update**\] \[**-r**\|**\--restart**\] \[**-s**\|**\--signal**\] \[**\--stop-signal**\] \[**\--stop-timeout**\] \[**\--map-signal**\] \[**-d**\|**\--debounce**\] \[**\--stdin-quit**\] @@ -20,10 +21,10 @@ watchexec - Execute commands when watched files change \[**\--workdir**\] \[**-e**\|**\--exts**\] \[**-f**\|**\--filter**\] \[**\--filter-file**\] \[**-j**\|**\--filter-prog**\] \[**-i**\|**\--ignore**\] \[**\--ignore-file**\] \[**\--fs-events**\] -\[**\--no-meta**\] \[**\--print-events**\] -\[**-v**\|**\--verbose**\]\... \[**\--log-file**\] \[**\--manual**\] -\[**\--completions**\] \[**-h**\|**\--help**\] -\[**-V**\|**\--version**\] \[*COMMAND*\] +\[**\--no-meta**\] \[**\--print-events**\] \[**\--manual**\] +\[**\--completions**\] \[**-v**\|**\--verbose**\]\... +\[**\--log-file**\] \[**-h**\|**\--help**\] \[**-V**\|**\--version**\] +\[*COMMAND*\] # DESCRIPTION @@ -82,6 +83,15 @@ The special value /dev/null, provided as the only path watched, will cause Watchexec to not watch any paths. Other event sources (like signals or key events) may still be used. +**-W**, **\--watch-non-recursive**=*PATH* + +: Watch a specific directory, non-recursively + +Unlike -w, folders watched with this option are not recursed into. + +This option can be specified multiple times to watch multiple +directories non-recursively. + **-c**, **\--clear**=*MODE* : Clear screen before running command @@ -630,7 +640,8 @@ notice. Provide your own custom filter programs in jaq (similar to jq) syntax. Programs are given an event in the same format as described in -\--emit-events-to and must return a boolean. +\--emit-events-to and must return a boolean. Invalid programs will make +watchexec fail to start; use -v to see program runtime errors. In addition to the jaq stdlib, watchexec adds some custom filter definitions: @@ -746,7 +757,25 @@ This prints the events that triggered the action when handling it (after debouncing), in a human readable form. This is useful for debugging filters. -Use -v when you need more diagnostic information. +Use -vvv instead when you need more diagnostic information. + +**\--manual** + +: Show the manual page + +This shows the manual page for Watchexec, if the output is a terminal +and the man program is available. If not, the manual page is printed to +stdout in ROFF format (suitable for writing to a watchexec.1 file). + +**\--completions**=*COMPLETIONS* + +: Generate a shell completions script + +Provides a completions script or configuration for the given shell. If +Watchexec is not distributed with pre-generated completions, you can use +this to generate them yourself. + +Supported shells: bash, elvish, fish, nu, powershell, zsh. **-v**, **\--verbose** @@ -782,24 +811,6 @@ If the path provided is a directory, a file will be created in that directory. The file name will be the current date and time, in the format watchexec.YYYY-MM-DDTHH-MM-SSZ.log. -**\--manual** - -: Show the manual page - -This shows the manual page for Watchexec, if the output is a terminal -and the man program is available. If not, the manual page is printed to -stdout in ROFF format (suitable for writing to a watchexec.1 file). - -**\--completions**=*COMPLETIONS* - -: Generate a shell completions script - -Provides a completions script or configuration for the given shell. If -Watchexec is not distributed with pre-generated completions, you can use -this to generate them yourself. - -Supported shells: bash, elvish, fish, nu, powershell, zsh. - **-h**, **\--help** : Print help (see a summary with -h)