Start on watchexec v2

This commit is contained in:
Félix Saparelli 2021-08-16 21:49:12 +12:00
parent e21a3a99f6
commit b15615bbaa
No known key found for this signature in database
GPG Key ID: B948C4BAE44FC474
18 changed files with 552 additions and 2414 deletions

View File

@ -8,9 +8,8 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
[lib/src/*.rs]
indent_style = tab
indent_size = 8
[*.toml]
indent_size = 2

293
Cargo.lock generated
View File

@ -144,6 +144,12 @@ version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]]
name = "bytes"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040"
[[package]]
name = "cache-padded"
version = "1.1.1"
@ -195,11 +201,11 @@ dependencies = [
[[package]]
name = "clearscreen"
version = "1.0.4"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eff80751e49709362458c0b612c3c682693d7a58966e0bf429872888f647e20"
checksum = "5a657a584d3350fd861a098df2c84e39bba869546101e4097fe123aca98c2d8d"
dependencies = [
"nix 0.20.0",
"nix 0.22.0",
"terminfo",
"thiserror",
"which",
@ -235,9 +241,9 @@ dependencies = [
[[package]]
name = "command-group"
version = "1.0.3"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba0e6179c8bfe0251e18e65da6f251f81451ead1968aa3c24401b425fc1a184b"
checksum = "758ddf93da6b6b45c6e44a73d07362945b98814e8c9bb57a8c9353f921c18ba3"
dependencies = [
"nix 0.22.0",
"winapi 0.3.9",
@ -271,6 +277,16 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "crossbeam-channel"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4"
dependencies = [
"cfg-if 1.0.0",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.5"
@ -505,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6"
dependencies = [
"bitflags 1.2.1",
"fsevent-sys",
"fsevent-sys 2.0.1",
]
[[package]]
@ -517,6 +533,15 @@ dependencies = [
"libc",
]
[[package]]
name = "fsevent-sys"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e564d24da983c053beff1bb7178e237501206840a3e6bf4e267b9e8ae734a"
dependencies = [
"libc",
]
[[package]]
name = "fuchsia-zircon"
version = "0.3.3"
@ -689,6 +714,15 @@ dependencies = [
"regex",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@ -712,6 +746,17 @@ dependencies = [
"libc",
]
[[package]]
name = "inotify"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b031475cb1b103ee221afb806a23d35e0570bf7271d7588762ceba8127ed43b3"
dependencies = [
"bitflags 1.2.1",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
@ -779,6 +824,26 @@ dependencies = [
"winapi-build",
]
[[package]]
name = "kqueue"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "058a107a784f8be94c7d35c1300f4facced2e93d2fbe5b1452b44e905ddca4a9"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8367585489f01bc55dd27404dcf56b95e6da061a256a666ab23be9ba96a2e587"
dependencies = [
"bitflags 1.2.1",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@ -803,6 +868,15 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "lock_api"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.14"
@ -848,6 +922,28 @@ dependencies = [
"autocfg",
]
[[package]]
name = "miette"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5022841ae4424a04441131a02ea2cc5eafeb1b2cf337b1b3281f57c809b8eb32"
dependencies = [
"indenter",
"miette-derive",
"thiserror",
]
[[package]]
name = "miette-derive"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b33d4f2f59c7ad2219e01e5a20abc6bab961a4275c3692e99abb17d04c83278"
dependencies = [
"proc-macro2",
"quote 1.0.9",
"syn 1.0.73",
]
[[package]]
name = "miniz_oxide"
version = "0.4.4"
@ -871,12 +967,25 @@ dependencies = [
"kernel32-sys",
"libc",
"log",
"miow",
"miow 0.2.2",
"net2",
"slab",
"winapi 0.2.8",
]
[[package]]
name = "mio"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16"
dependencies = [
"libc",
"log",
"miow 0.3.7",
"ntapi",
"winapi 0.3.9",
]
[[package]]
name = "mio-extras"
version = "2.0.6"
@ -885,7 +994,7 @@ checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19"
dependencies = [
"lazycell",
"log",
"mio",
"mio 0.6.23",
"slab",
]
@ -901,6 +1010,15 @@ dependencies = [
"ws2_32-sys",
]
[[package]]
name = "miow"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "nb-connect"
version = "1.2.0"
@ -935,18 +1053,6 @@ dependencies = [
"void",
]
[[package]]
name = "nix"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa9b4819da1bc61c0ea48b63b7bc8604064dd43013e7cc325df098d49cd7c18a"
dependencies = [
"bitflags 1.2.1",
"cc",
"cfg-if 1.0.0",
"libc",
]
[[package]]
name = "nix"
version = "0.22.0"
@ -979,15 +1085,33 @@ dependencies = [
"bitflags 1.2.1",
"filetime",
"fsevent",
"fsevent-sys",
"inotify",
"fsevent-sys 2.0.1",
"inotify 0.7.1",
"libc",
"mio",
"mio 0.6.23",
"mio-extras",
"walkdir",
"winapi 0.3.9",
]
[[package]]
name = "notify"
version = "5.0.0-pre.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c614e7ed2b1cf82ec99aeffd8cf6225ef5021b9951148eb161393c394855032c"
dependencies = [
"bitflags 1.2.1",
"crossbeam-channel",
"filetime",
"fsevent-sys 4.0.0",
"inotify 0.9.3",
"kqueue",
"libc",
"mio 0.7.13",
"walkdir",
"winapi 0.3.9",
]
[[package]]
name = "notify-rust"
version = "4.5.2"
@ -1002,6 +1126,15 @@ dependencies = [
"zvariant_derive",
]
[[package]]
name = "ntapi"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "num-integer"
version = "0.1.44"
@ -1021,6 +1154,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
@ -1077,6 +1220,31 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
[[package]]
name = "parking_lot"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb"
dependencies = [
"instant",
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018"
dependencies = [
"cfg-if 1.0.0",
"instant",
"libc",
"redox_syscall 0.2.9",
"smallvec",
"winapi 0.3.9",
]
[[package]]
name = "phf"
version = "0.8.0"
@ -1377,6 +1545,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2"
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "serde"
version = "1.0.126"
@ -1440,6 +1614,15 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
dependencies = [
"libc",
]
[[package]]
name = "similar"
version = "1.3.0"
@ -1448,9 +1631,9 @@ checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec"
[[package]]
name = "siphasher"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbce6d4507c7e4a3962091436e56e95290cb71fa302d0d270e32130b75fbff27"
checksum = "729a25c17d72b06c68cb47955d44fda88ad2d3e7d77e025663fdd69b93dd71a1"
[[package]]
name = "slab"
@ -1458,6 +1641,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527"
[[package]]
name = "smallvec"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e"
[[package]]
name = "socket2"
version = "0.4.1"
@ -1609,6 +1798,37 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "tokio"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01cf844b23c6131f624accf65ce0e4e9956a8bb329400ea5bcc26ae3a5c20b0b"
dependencies = [
"autocfg",
"bytes",
"libc",
"memchr",
"mio 0.7.13",
"num_cpus",
"once_cell",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"tokio-macros",
"winapi 0.3.9",
]
[[package]]
name = "tokio-macros"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110"
dependencies = [
"proc-macro2",
"quote 1.0.9",
"syn 1.0.73",
]
[[package]]
name = "toml"
version = "0.5.8"
@ -1774,6 +1994,20 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
[[package]]
name = "watchexec"
version = "1.17.1"
dependencies = [
"chrono",
"miette",
"notify 5.0.0-pre.11",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "watchexec"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c52e0868bc57765fa91593a173323f464855e53b27f779e1110ba0fb4cb6b406"
dependencies = [
"clearscreen",
"command-group",
@ -1783,7 +2017,7 @@ dependencies = [
"lazy_static",
"log",
"nix 0.22.0",
"notify",
"notify 4.0.17",
"walkdir",
"winapi 0.3.9",
]
@ -1800,7 +2034,7 @@ dependencies = [
"insta",
"log",
"notify-rust",
"watchexec",
"watchexec 1.17.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@ -1814,11 +2048,12 @@ dependencies = [
[[package]]
name = "which"
version = "4.1.0"
version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe"
checksum = "ea187a8ef279bc014ec368c27a920da2024d2a711109bfbe3440585d5cf27ad9"
dependencies = [
"either",
"lazy_static",
"libc",
]

1
lib/.rustfmt.toml Normal file
View File

@ -0,0 +1 @@
hard_tabs = true

View File

@ -15,18 +15,13 @@ readme = "README.md"
edition = "2018"
[dependencies]
clearscreen = "1.0.4"
command-group = "1.0.3"
derive_builder = "0.10.0"
glob = "0.3.0"
globset = "=0.4.6"
lazy_static = "1.1.0"
log = "0.4.14"
notify = "4.0.15"
walkdir = "2.3.2"
chrono = "0.4.19"
miette = "0.7.0"
notify = "5.0.0-pre.11"
thiserror = "1.0.26"
tracing = "0.1.26"
[target.'cfg(unix)'.dependencies]
nix = "0.22.0"
[dependencies.tokio]
version = "1.10.0"
features = ["full"]
[target.'cfg(windows)'.dependencies.winapi]
version = "0.3.9"

View File

@ -1,151 +0,0 @@
//! Configuration for watchexec.
//!
//! The [`Config`] struct is not constructable, use [`ConfigBuilder`].
//!
//! # Examples
//!
//! ```
//! # use watchexec::config::ConfigBuilder;
//! ConfigBuilder::default()
//! .cmd(vec!["echo hello world".into()])
//! .paths(vec![".".into()])
//! .build()
//! .expect("mission failed");
//! ```
use derive_builder::Builder;
use std::{path::PathBuf, time::Duration};
use crate::run::OnBusyUpdate;
use crate::Shell;
/// Arguments to the watcher
#[derive(Builder, Clone, Debug)]
#[builder(setter(into, strip_option))]
#[builder(build_fn(validate = "Self::validate"))]
#[non_exhaustive]
pub struct Config {
/// Command to execute.
///
/// When `shell` is [`Shell::None`], this is expected to be in “execvp(3)”
/// format: first program, rest arguments. Otherwise, all elements will be
/// joined together with a single space and passed to the shell. More
/// control can then be obtained by providing a 1-element vec, and doing
/// your own joining and/or escaping there.
pub cmd: Vec<String>,
/// List of paths to watch for changes.
pub paths: Vec<PathBuf>,
/// Positive filters (trigger only on matching changes). Glob format.
#[builder(default)]
pub filters: Vec<String>,
/// Negative filters (do not trigger on matching changes). Glob format.
#[builder(default)]
pub ignores: Vec<String>,
/// Clear the screen before each run.
#[builder(default)]
pub clear_screen: bool,
/// If Some, send that signal (e.g. SIGHUP) to the command on change.
#[builder(default)]
pub signal: Option<String>,
/// Specify what to do when receiving updates while the command is running.
#[builder(default)]
pub on_busy_update: OnBusyUpdate,
/// Interval to debounce the changes.
#[builder(default = "Duration::from_millis(100)")]
pub debounce: Duration,
/// Run the commands right after starting.
#[builder(default = "true")]
pub run_initially: bool,
/// Specify the shell to use.
#[builder(default)]
pub shell: Shell,
/// Ignore metadata changes.
#[builder(default)]
pub no_meta: bool,
/// Do not set WATCHEXEC_*_PATH environment variables for the process.
#[builder(default)]
pub no_environment: bool,
/// Skip auto-loading .gitignore files
#[builder(default)]
pub no_vcs_ignore: bool,
/// Skip auto-loading .ignore files
#[builder(default)]
pub no_ignore: bool,
/// For testing only, always set to false.
#[builder(setter(skip), default)]
#[doc(hidden)]
pub once: bool,
/// Force using the polling backend.
#[builder(default)]
pub poll: bool,
/// Interval for polling.
#[builder(default = "Duration::from_secs(1)")]
pub poll_interval: Duration,
/// Whether to use a process group to run the command.
#[builder(default = "true")]
pub use_process_group: bool,
}
impl ConfigBuilder {
fn validate(&self) -> Result<(), String> {
if self.cmd.as_ref().map_or(true, Vec::is_empty) {
return Err("cmd must not be empty".into());
}
if self.paths.as_ref().map_or(true, Vec::is_empty) {
return Err("paths must not be empty".into());
}
Ok(())
}
#[deprecated(since = "1.15.0", note = "does nothing. set the log level instead")]
pub fn debug(&mut self, _: impl Into<bool>) -> &mut Self {
self
}
/// Do not wrap the commands in a shell.
#[deprecated(since = "1.15.0", note = "use shell(Shell::None) instead")]
pub fn no_shell(&mut self, s: impl Into<bool>) -> &mut Self {
if s.into() {
self.shell(Shell::default())
} else {
self.shell(Shell::None)
}
}
#[deprecated(since = "1.15.0", note = "use on_busy_update(Restart) instead")]
pub fn restart(&mut self, b: impl Into<bool>) -> &mut Self {
if b.into() {
self.on_busy_update(OnBusyUpdate::Restart)
} else {
self
}
}
#[deprecated(since = "1.15.0", note = "use on_busy_update(DoNothing) instead")]
pub fn watch_when_idle(&mut self, b: impl Into<bool>) -> &mut Self {
if b.into() {
self.on_busy_update(OnBusyUpdate::DoNothing)
} else {
self
}
}
}

View File

@ -1,86 +1,69 @@
use std::{error::Error as StdError, fmt, io, sync::PoisonError};
//! Watchexec has two error types: for critical and for runtime errors.
pub type Result<T> = ::std::result::Result<T, Error>;
use std::path::PathBuf;
use thiserror::Error;
use miette::Diagnostic;
use tokio::sync::mpsc;
use crate::{event::Event, fs::Watcher};
/// Errors which are not recoverable and stop watchexec execution.
#[derive(Debug, Diagnostic, Error)]
#[non_exhaustive]
pub enum Error {
Canonicalization(String, io::Error),
Glob(globset::Error),
Io(io::Error),
Notify(notify::Error),
Generic(String),
PoisonedLock,
ClearScreen(clearscreen::Error),
pub enum CriticalError {
/// A critical I/O error occurred.
#[error(transparent)]
#[diagnostic(code(watchexec::critical::io_error))]
IoError(#[from] std::io::Error),
/// Error received when an event cannot be sent to the errors channel.
#[error("cannot send internal runtime error: {0}")]
#[diagnostic(code(watchexec::critical::error_channel_send))]
ErrorChannelSend(#[from] mpsc::error::SendError<RuntimeError>),
}
impl StdError for Error {}
/// Errors which _may_ be recoverable, transient, or only affect a part of the operation, and should
/// be reported to the user and/or acted upon programatically, but will not outright stop watchexec.
#[derive(Debug, Diagnostic, Error)]
#[non_exhaustive]
pub enum RuntimeError {
/// Generic I/O error, with no additional context.
#[error(transparent)]
#[diagnostic(code(watchexec::runtime::io_error))]
IoError(#[from] std::io::Error),
impl From<String> for Error {
fn from(err: String) -> Self {
Self::Generic(err)
}
}
impl From<globset::Error> for Error {
fn from(err: globset::Error) -> Self {
Self::Glob(err)
}
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Self {
Self::Io(match err.raw_os_error() {
Some(7) => io::Error::new(io::ErrorKind::Other, "There are so many changed files that the environment variables of the command have been overrun. Try running with --no-meta or --no-environment."),
_ => err,
})
}
}
impl From<notify::Error> for Error {
fn from(err: notify::Error) -> Self {
match err {
notify::Error::Io(err) => Self::Io(err),
other => Self::Notify(other),
}
}
}
impl<'a, T> From<PoisonError<T>> for Error {
fn from(_err: PoisonError<T>) -> Self {
Self::PoisonedLock
}
}
impl From<clearscreen::Error> for Error {
fn from(err: clearscreen::Error) -> Self {
match err {
clearscreen::Error::Io(err) => Self::Io(err),
other => Self::ClearScreen(other),
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let (error_type, error) = match self {
Self::Canonicalization(path, err) => (
"Path",
format!("couldn't canonicalize '{}':\n{}", path, err),
),
Self::Generic(err) => ("", err.clone()),
Self::Glob(err) => ("Globset", err.to_string()),
Self::Io(err) => ("I/O", err.to_string()),
Self::Notify(err) => ("Notify", err.to_string()),
Self::PoisonedLock => ("Internal", "poisoned lock".to_string()),
Self::ClearScreen(err) => ("ClearScreen", err.to_string()),
};
write!(f, "{} error: {}", error_type, error)
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Display::fmt(self, f)
}
/// Error received when creating a filesystem watcher fails.
#[error("{kind:?} watcher failed to instantiate: {err}")]
#[diagnostic(
code(watchexec::runtime::fs_watcher_error),
help("perhaps retry with the poll watcher"),
)]
FsWatcherCreate { kind: Watcher, #[source] err: notify::Error },
/// Error received when reading a filesystem event fails.
#[error("{kind:?} watcher received an event that we could not read: {err}")]
#[diagnostic(
code(watchexec::runtime::fs_watcher_event),
)]
FsWatcherEvent { kind: Watcher, #[source] err: notify::Error },
/// Error received when adding to the pathset for the filesystem watcher fails.
#[error("while adding {path:?} to the {kind:?} watcher: {err}")]
#[diagnostic(
code(watchexec::runtime::fs_watcher_path_add),
)]
FsWatcherPathAdd { path: PathBuf, kind: Watcher, #[source] err: notify::Error },
/// Error received when removing from the pathset for the filesystem watcher fails.
#[error("while removing {path:?} from the {kind:?} watcher: {err}")]
#[diagnostic(
code(watchexec::runtime::fs_watcher_path_remove),
)]
FsWatcherPathRemove { path: PathBuf, kind: Watcher, #[source] err: notify::Error },
/// Error received when an event cannot be sent to the event channel.
#[error("cannot send event from {ctx}: {err}")]
#[diagnostic(code(watchexec::runtime::event_channel_send))]
EventChannelSend { ctx: &'static str, #[source] err: mpsc::error::TrySendError<Event> },
}

36
lib/src/event.rs Normal file
View File

@ -0,0 +1,36 @@
//! Fundamentally, events in watchexec have three purposes:
//!
//! 1. To trigger the launch, restart, or other interruption of a process;
//! 2. To be filtered upon according to whatever set of criteria is desired;
//! 3. To carry information about what caused the event, which may be provided to the process.
use chrono::NaiveDateTime;
use std::{collections::HashMap, path::PathBuf};
/// An event, as far as watchexec cares about.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Event {
pub particulars: Vec<Particle>,
pub metadata: HashMap<String, Vec<String>>,
}
/// Something which can be used to filter an event.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Particle {
Time(NaiveDateTime),
Path(PathBuf),
Source(Source),
Process(u32),
}
/// The general origin of the event.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Source {
Filesystem,
Keyboard,
Mouse,
Time,
}

168
lib/src/fs.rs Normal file
View File

@ -0,0 +1,168 @@
use std::{collections::{HashMap, HashSet}, path::PathBuf};
use tokio::{sync::{mpsc, watch}};
use tracing::{debug, trace};
use crate::{error::{CriticalError, RuntimeError}, event::{Event, Particle, Source}};
/// What kind of filesystem watcher to use.
///
/// For now only native and poll watchers are supported. In the future there may be additional
/// watchers available on some platforms.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Watcher {
Native,
Poll,
}
impl Default for Watcher {
fn default() -> Self {
Self::Native
}
}
impl Watcher {
fn create(self, f: impl notify::EventFn) -> Result<Box<dyn notify::Watcher>, RuntimeError> {
match self {
Self::Native => notify::RecommendedWatcher::new(f).map(|w| Box::new(w) as Box<dyn notify::Watcher>),
Self::Poll => notify::PollWatcher::new(f).map(|w| Box::new(w) as Box<dyn notify::Watcher>),
}.map_err(|err| RuntimeError::FsWatcherCreate { kind: self, err })
}
}
/// The working data set of the filesystem worker.
///
/// This is marked non-exhaustive so new configuration can be added without breaking.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct WorkingData {
pub pathset: Vec<PathBuf>,
pub watcher: Watcher,
}
/// Launch a filesystem event worker.
///
/// This only does a bare minimum of setup; to actually start the work, you need to set a non-empty pathset on the
/// [`WorkingData`] with the [`watch`] channel.
pub async fn worker(
mut working: watch::Receiver<WorkingData>,
errors: mpsc::Sender<RuntimeError>,
events: mpsc::Sender<Event>,
) -> Result<(), CriticalError> {
debug!("launching filesystem worker");
let mut watcher_type = Watcher::default();
let mut watcher: Option<Box<dyn notify::Watcher>> = None;
let mut pathset: HashSet<PathBuf> = HashSet::new();
while working.changed().await.is_ok() {
// In separate scope so we drop the working read lock as early as we can
let (new_watcher, to_watch, to_drop) = {
let data = working.borrow();
trace!(?data, "filesystem worker got a working data change");
if data.pathset.is_empty() {
trace!("no more watched paths, dropping watcher");
watcher.take();
pathset.drain();
continue;
}
if watcher.is_none() || watcher_type != data.watcher {
pathset.drain();
(Some(data.watcher), data.pathset.clone(), Vec::new())
} else {
let mut to_watch = Vec::with_capacity(data.pathset.len());
let mut to_drop = Vec::with_capacity(pathset.len());
for path in data.pathset.iter() {
if !pathset.contains(path) {
to_watch.push(path.clone());
}
}
for path in pathset.iter() {
if !data.pathset.contains(path) {
to_drop.push(path.clone());
}
}
(None, to_watch, to_drop)
}
};
if let Some(kind) = new_watcher {
debug!(?kind, "creating new watcher");
let n_errors = errors.clone();
let n_events = events.clone();
match kind.create(move |nev: Result<notify::Event, notify::Error> | {
trace!(event = ?nev, "receiving possible event from watcher");
match nev {
Err(err) => {
n_errors.try_send(RuntimeError::FsWatcherEvent { kind, err }).ok();
},
Ok(nev) => {
let mut particulars = Vec::with_capacity(4);
particulars.push(Particle::Source(Source::Filesystem));
for path in nev.paths {
particulars.push(Particle::Path(path));
}
if let Some(pid) = nev.attrs.process_id() {
particulars.push(Particle::Process(pid));
}
let ev = Event {
particulars,
metadata: HashMap::new(), // TODO
};
trace!(event = ?ev, "processed notify event into watchexec event");
if let Err(err) = n_events.try_send(ev) {
n_errors.try_send(RuntimeError::EventChannelSend {
ctx: "fs watcher",
err,
}).ok();
}
}
}
}) {
Ok(w) => {
watcher.insert(w);
watcher_type = kind;
},
Err(e) => {
errors.send(e).await?;
}
}
}
if let Some(w) = watcher.as_mut() {
debug!(?to_watch, ?to_drop, "applying changes to the watcher");
for path in to_drop {
trace!(?path, "removing path from the watcher");
if let Err(err) = w.unwatch(&path) {
errors.send(RuntimeError::FsWatcherPathRemove { path, kind: watcher_type, err }).await?;
} else {
pathset.remove(&path);
}
}
for path in to_watch {
trace!(?path, "adding path to the watcher");
if let Err(err) = w.watch(&path, notify::RecursiveMode::Recursive) {
errors.send(RuntimeError::FsWatcherPathAdd { path, kind: watcher_type, err }).await?;
} else {
pathset.insert(path);
}
}
}
}
Ok(())
}

View File

@ -1,363 +0,0 @@
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use log::debug;
use std::borrow::ToOwned;
use std::fs;
use std::io;
use std::io::Read;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub struct Gitignore {
files: Vec<GitignoreFile>,
}
#[derive(Debug)]
pub enum Error {
GlobSet(globset::Error),
Io(io::Error),
}
struct GitignoreFile {
set: GlobSet,
patterns: Vec<Pattern>,
root: PathBuf,
}
struct Pattern {
pattern: String,
pattern_type: PatternType,
anchored: bool,
}
enum PatternType {
Ignore,
Whitelist,
}
#[derive(PartialEq)]
enum MatchResult {
Ignore,
Whitelist,
None,
}
pub fn load(paths: &[PathBuf]) -> Gitignore {
let mut files = vec![];
for path in paths {
let mut top_level_git_dir = None;
let mut p = Some(path.as_path());
while let Some(ref current) = p {
debug!("Looking in {:?} for a .git directory", current);
// Stop if we see a .git directory
if let Ok(metadata) = current.join(".git").metadata() {
if metadata.is_dir() {
top_level_git_dir = Some(*current);
break;
}
}
p = current.parent();
}
if let Some(root) = top_level_git_dir {
debug!("Found the top level git directory: {:?}", root);
// scan in subdirectories
for entry in WalkDir::new(root)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| e.file_name() == ".gitignore")
{
let gitignore_path = entry.path();
if let Ok(f) = GitignoreFile::new(gitignore_path) {
debug!("Loaded {:?}", gitignore_path);
files.push(f);
} else {
debug!("Unable to load {:?}", gitignore_path);
}
}
}
// p.pop();
}
Gitignore::new(files)
}
impl Gitignore {
const fn new(files: Vec<GitignoreFile>) -> Self {
Self { files }
}
pub fn is_excluded(&self, path: &Path) -> bool {
let mut applicable_files: Vec<&GitignoreFile> = self
.files
.iter()
.filter(|f| path.starts_with(&f.root))
.collect();
applicable_files.sort_by_key(|f| f.root_len());
// TODO: add user gitignores
let mut result = MatchResult::None;
for file in applicable_files {
match file.matches(path) {
MatchResult::Ignore => result = MatchResult::Ignore,
MatchResult::Whitelist => result = MatchResult::Whitelist,
MatchResult::None => {}
}
}
result == MatchResult::Ignore
}
}
impl GitignoreFile {
pub fn new(path: &Path) -> Result<Self, Error> {
let mut file = fs::File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let lines: Vec<_> = contents.lines().collect();
let root = path.parent().expect("gitignore file is at filesystem root");
Self::from_strings(&lines, root)
}
pub fn from_strings(strs: &[&str], root: &Path) -> Result<Self, Error> {
let mut builder = GlobSetBuilder::new();
let mut patterns = vec![];
let parsed_patterns = Self::parse(strs);
for p in parsed_patterns {
let mut pat = p.pattern.clone();
if !p.anchored && !pat.starts_with("**/") {
pat = "**/".to_string() + &pat;
}
if !pat.ends_with("/**") {
pat += "/**";
}
let glob = GlobBuilder::new(&pat).literal_separator(true).build()?;
builder.add(glob);
patterns.push(p);
}
Ok(Self {
set: builder.build()?,
patterns,
root: root.to_owned(),
})
}
#[cfg(test)]
fn is_excluded(&self, path: &Path) -> bool {
self.matches(path) == MatchResult::Ignore
}
fn matches(&self, path: &Path) -> MatchResult {
if let Ok(stripped) = path.strip_prefix(&self.root) {
let matches = self.set.matches(stripped);
if let Some(i) = matches.iter().rev().next() {
let pattern = &self.patterns[*i];
return match pattern.pattern_type {
PatternType::Whitelist => MatchResult::Whitelist,
PatternType::Ignore => MatchResult::Ignore,
};
}
}
MatchResult::None
}
pub fn root_len(&self) -> usize {
self.root.as_os_str().len()
}
fn parse(contents: &[&str]) -> Vec<Pattern> {
contents
.iter()
.filter_map(|l| {
if !l.is_empty() && !l.starts_with('#') {
Some(Pattern::parse(l))
} else {
None
}
})
.collect()
}
}
impl Pattern {
fn parse(pattern: &str) -> Self {
let mut normalized = String::from(pattern);
let pattern_type = if normalized.starts_with('!') {
normalized.remove(0);
PatternType::Whitelist
} else {
PatternType::Ignore
};
let anchored = if normalized.starts_with('/') {
normalized.remove(0);
true
} else {
false
};
if normalized.ends_with('/') {
normalized.pop();
}
if normalized.starts_with("\\#") || normalized.starts_with("\\!") {
normalized.remove(0);
}
Self {
pattern: normalized,
pattern_type,
anchored,
}
}
}
impl From<globset::Error> for Error {
fn from(error: globset::Error) -> Self {
Self::GlobSet(error)
}
}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
#[cfg(test)]
mod tests {
use super::GitignoreFile;
use std::path::PathBuf;
fn base_dir() -> PathBuf {
PathBuf::from("/home/user/dir")
}
fn build_gitignore(pattern: &str) -> GitignoreFile {
GitignoreFile::from_strings(&[pattern], &base_dir()).expect("test gitignore file invalid")
}
#[test]
fn matches_exact() {
let file = build_gitignore("Cargo.toml");
assert!(file.is_excluded(&base_dir().join("Cargo.toml")));
}
#[test]
fn does_not_match() {
let file = build_gitignore("Cargo.toml");
assert!(!file.is_excluded(&base_dir().join("src").join("main.rs")));
}
#[test]
fn matches_simple_wildcard() {
let file = build_gitignore("targ*");
assert!(file.is_excluded(&base_dir().join("target")));
}
#[test]
fn matches_subdir_exact() {
let file = build_gitignore("target");
assert!(file.is_excluded(&base_dir().join("target/")));
}
#[test]
fn matches_subdir() {
let file = build_gitignore("target");
assert!(file.is_excluded(&base_dir().join("target").join("file")));
assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("file")));
}
#[test]
fn wildcard_with_dir() {
let file = build_gitignore("target/f*");
assert!(file.is_excluded(&base_dir().join("target").join("file")));
assert!(!file.is_excluded(&base_dir().join("target").join("subdir").join("file")));
}
#[test]
fn leading_slash() {
let file = build_gitignore("/*.c");
assert!(file.is_excluded(&base_dir().join("cat-file.c")));
assert!(!file.is_excluded(&base_dir().join("mozilla-sha1").join("sha1.c")));
}
#[test]
fn leading_double_wildcard() {
let file = build_gitignore("**/foo");
assert!(file.is_excluded(&base_dir().join("foo")));
assert!(file.is_excluded(&base_dir().join("target").join("foo")));
assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("foo")));
}
#[test]
fn trailing_double_wildcard() {
let file = build_gitignore("abc/**");
assert!(!file.is_excluded(&base_dir().join("def").join("foo")));
assert!(file.is_excluded(&base_dir().join("abc").join("foo")));
assert!(file.is_excluded(&base_dir().join("abc").join("subdir").join("foo")));
}
#[test]
fn sandwiched_double_wildcard() {
let file = build_gitignore("a/**/b");
assert!(file.is_excluded(&base_dir().join("a").join("b")));
assert!(file.is_excluded(&base_dir().join("a").join("x").join("b")));
assert!(file.is_excluded(&base_dir().join("a").join("x").join("y").join("b")));
}
#[test]
fn empty_file_never_excludes() {
let file =
GitignoreFile::from_strings(&[], &base_dir()).expect("test gitignore file invalid");
assert!(!file.is_excluded(&base_dir().join("target")));
}
#[test]
fn checks_all_patterns() {
let patterns = vec!["target", "target2"];
let file = GitignoreFile::from_strings(&patterns, &base_dir())
.expect("test gitignore file invalid");
assert!(file.is_excluded(&base_dir().join("target").join("foo.txt")));
assert!(file.is_excluded(&base_dir().join("target2").join("bar.txt")));
}
#[test]
fn handles_negative_patterns() {
let patterns = vec!["target", "!target/foo.txt"];
let file = GitignoreFile::from_strings(&patterns, &base_dir())
.expect("test gitignore file invalid");
assert!(!file.is_excluded(&base_dir().join("target").join("foo.txt")));
assert!(file.is_excluded(&base_dir().join("target").join("blah.txt")));
}
}

View File

@ -1,367 +0,0 @@
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use log::debug;
use std::collections::HashSet;
use std::fs;
use std::io;
use std::io::Read;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub struct Ignore {
files: Vec<IgnoreFile>,
}
#[derive(Debug)]
pub enum Error {
GlobSet(globset::Error),
Io(io::Error),
}
struct IgnoreFile {
set: GlobSet,
patterns: Vec<Pattern>,
root: PathBuf,
}
struct Pattern {
pattern: String,
pattern_type: PatternType,
anchored: bool,
}
enum PatternType {
Ignore,
Whitelist,
}
#[derive(PartialEq)]
enum MatchResult {
Ignore,
Whitelist,
None,
}
pub fn load(paths: &[PathBuf]) -> Ignore {
let mut files = vec![];
let mut checked_dirs = HashSet::new();
for path in paths {
let mut p = path.to_owned();
// walk up to root
// FIXME: this makes zero sense and should be removed
// but that would be a breaking change
loop {
if !checked_dirs.contains(&p) {
checked_dirs.insert(p.clone());
let ignore_path = p.join(".ignore");
if ignore_path.exists() {
if let Ok(f) = IgnoreFile::new(&ignore_path) {
debug!("Loaded {:?}", ignore_path);
files.push(f);
} else {
debug!("Unable to load {:?}", ignore_path);
}
}
}
if p.parent().is_none() {
break;
}
p.pop();
}
//also look in subfolders
for entry in WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| e.file_name() == ".ignore")
{
let ignore_path = entry.path();
if let Ok(f) = IgnoreFile::new(ignore_path) {
debug!("Loaded {:?}", ignore_path);
files.push(f);
} else {
debug!("Unable to load {:?}", ignore_path);
}
}
}
Ignore::new(files)
}
impl Ignore {
const fn new(files: Vec<IgnoreFile>) -> Self {
Self { files }
}
pub fn is_excluded(&self, path: &Path) -> bool {
let mut applicable_files: Vec<&IgnoreFile> = self
.files
.iter()
.filter(|f| path.starts_with(&f.root))
.collect();
applicable_files.sort_by_key(|f| f.root_len());
// TODO: add user ignores
let mut result = MatchResult::None;
for file in applicable_files {
match file.matches(path) {
MatchResult::Ignore => result = MatchResult::Ignore,
MatchResult::Whitelist => result = MatchResult::Whitelist,
MatchResult::None => {}
}
}
result == MatchResult::Ignore
}
}
impl IgnoreFile {
pub fn new(path: &Path) -> Result<Self, Error> {
let mut file = fs::File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let lines: Vec<_> = contents.lines().collect();
let root = path.parent().expect("ignore file is at filesystem root");
Self::from_strings(&lines, root)
}
pub fn from_strings(strs: &[&str], root: &Path) -> Result<Self, Error> {
let mut builder = GlobSetBuilder::new();
let mut patterns = vec![];
let parsed_patterns = Self::parse(strs);
for p in parsed_patterns {
let mut pat = p.pattern.clone();
if !p.anchored && !pat.starts_with("**/") {
pat = "**/".to_string() + &pat;
}
if !pat.ends_with("/**") {
pat += "/**";
}
let glob = GlobBuilder::new(&pat).literal_separator(true).build()?;
builder.add(glob);
patterns.push(p);
}
Ok(Self {
set: builder.build()?,
patterns,
root: root.to_owned(),
})
}
#[cfg(test)]
fn is_excluded(&self, path: &Path) -> bool {
self.matches(path) == MatchResult::Ignore
}
fn matches(&self, path: &Path) -> MatchResult {
if let Ok(stripped) = path.strip_prefix(&self.root) {
let matches = self.set.matches(stripped);
if let Some(i) = matches.iter().rev().next() {
let pattern = &self.patterns[*i];
return match pattern.pattern_type {
PatternType::Whitelist => MatchResult::Whitelist,
PatternType::Ignore => MatchResult::Ignore,
};
}
}
MatchResult::None
}
pub fn root_len(&self) -> usize {
self.root.as_os_str().len()
}
fn parse(contents: &[&str]) -> Vec<Pattern> {
contents
.iter()
.filter_map(|l| {
if !l.is_empty() && !l.starts_with('#') {
Some(Pattern::parse(l))
} else {
None
}
})
.collect()
}
}
impl Pattern {
fn parse(pattern: &str) -> Self {
let mut normalized = String::from(pattern);
let pattern_type = if normalized.starts_with('!') {
normalized.remove(0);
PatternType::Whitelist
} else {
PatternType::Ignore
};
let anchored = if normalized.starts_with('/') {
normalized.remove(0);
true
} else {
false
};
if normalized.ends_with('/') {
normalized.pop();
}
if normalized.starts_with("\\#") || normalized.starts_with("\\!") {
normalized.remove(0);
}
Self {
pattern: normalized,
pattern_type,
anchored,
}
}
}
impl From<globset::Error> for Error {
fn from(error: globset::Error) -> Self {
Self::GlobSet(error)
}
}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
#[cfg(test)]
mod tests {
use super::IgnoreFile;
use std::path::PathBuf;
fn base_dir() -> PathBuf {
PathBuf::from("/home/user/dir")
}
fn build_ignore(pattern: &str) -> IgnoreFile {
IgnoreFile::from_strings(&[pattern], &base_dir()).expect("test ignore file invalid")
}
#[test]
fn matches_exact() {
let file = build_ignore("Cargo.toml");
assert!(file.is_excluded(&base_dir().join("Cargo.toml")));
}
#[test]
fn does_not_match() {
let file = build_ignore("Cargo.toml");
assert!(!file.is_excluded(&base_dir().join("src").join("main.rs")));
}
#[test]
fn matches_simple_wildcard() {
let file = build_ignore("targ*");
assert!(file.is_excluded(&base_dir().join("target")));
}
#[test]
fn matches_subdir_exact() {
let file = build_ignore("target");
assert!(file.is_excluded(&base_dir().join("target/")));
}
#[test]
fn matches_subdir() {
let file = build_ignore("target");
assert!(file.is_excluded(&base_dir().join("target").join("file")));
assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("file")));
}
#[test]
fn wildcard_with_dir() {
let file = build_ignore("target/f*");
assert!(file.is_excluded(&base_dir().join("target").join("file")));
assert!(!file.is_excluded(&base_dir().join("target").join("subdir").join("file")));
}
#[test]
fn leading_slash() {
let file = build_ignore("/*.c");
assert!(file.is_excluded(&base_dir().join("cat-file.c")));
assert!(!file.is_excluded(&base_dir().join("mozilla-sha1").join("sha1.c")));
}
#[test]
fn leading_double_wildcard() {
let file = build_ignore("**/foo");
assert!(file.is_excluded(&base_dir().join("foo")));
assert!(file.is_excluded(&base_dir().join("target").join("foo")));
assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("foo")));
}
#[test]
fn trailing_double_wildcard() {
let file = build_ignore("abc/**");
assert!(!file.is_excluded(&base_dir().join("def").join("foo")));
assert!(file.is_excluded(&base_dir().join("abc").join("foo")));
assert!(file.is_excluded(&base_dir().join("abc").join("subdir").join("foo")));
}
#[test]
fn sandwiched_double_wildcard() {
let file = build_ignore("a/**/b");
assert!(file.is_excluded(&base_dir().join("a").join("b")));
assert!(file.is_excluded(&base_dir().join("a").join("x").join("b")));
assert!(file.is_excluded(&base_dir().join("a").join("x").join("y").join("b")));
}
#[test]
fn empty_file_never_excludes() {
let file = IgnoreFile::from_strings(&[], &base_dir()).expect("test ignore file invalid");
assert!(!file.is_excluded(&base_dir().join("target")));
}
#[test]
fn checks_all_patterns() {
let patterns = vec!["target", "target2"];
let file =
IgnoreFile::from_strings(&patterns, &base_dir()).expect("test ignore file invalid");
assert!(file.is_excluded(&base_dir().join("target").join("foo.txt")));
assert!(file.is_excluded(&base_dir().join("target2").join("bar.txt")));
}
#[test]
fn handles_whitelisting() {
let patterns = vec!["target", "!target/foo.txt"];
let file =
IgnoreFile::from_strings(&patterns, &base_dir()).expect("test ignore file invalid");
assert!(!file.is_excluded(&base_dir().join("target").join("foo.txt")));
assert!(file.is_excluded(&base_dir().join("target").join("blah.txt")));
}
}

View File

@ -1,24 +1,20 @@
//! [Watchexec]: the library
//! Watchexec: a library for utilities and programs which respond to events;
//! file changes, human interaction, and more.
//!
//! From version 1.16.0, semver applies!
//! Also see the CLI tool: https://watchexec.github.io/
//!
//! [Watchexec]: https://github.com/watchexec/watchexec
//! This library is powered by [Tokio](https://tokio.rs), minimum version 1.10.
//!
//! The main way to use this crate involves constructing a [`Handler`] and running it.
//!
//! This crate does not itself use `unsafe`. However, it depends on a number of libraries which do,
//! most because they interact with the operating system.
#![doc(html_favicon_url = "https://watchexec.github.io/logo:watchexec.svg")]
#![doc(html_logo_url = "https://watchexec.github.io/logo:watchexec.svg")]
#![warn(clippy::unwrap_used)]
#![forbid(unsafe_code)]
pub mod config;
pub mod error;
mod gitignore;
mod ignore;
mod notification_filter;
pub mod pathop;
mod paths;
pub mod run;
mod shell;
mod signal;
mod watcher;
pub use run::{run, watch, Handler};
pub use shell::Shell;
pub mod event;
mod fs;

View File

@ -1,160 +0,0 @@
use crate::error;
use crate::gitignore::Gitignore;
use crate::ignore::Ignore;
use globset::{Glob, GlobSet, GlobSetBuilder};
use log::debug;
use std::path::Path;
pub struct NotificationFilter {
filters: GlobSet,
filter_count: usize,
ignores: GlobSet,
gitignore_files: Gitignore,
ignore_files: Ignore,
}
impl NotificationFilter {
pub fn new(
filters: &[String],
ignores: &[String],
gitignore_files: Gitignore,
ignore_files: Ignore,
) -> error::Result<Self> {
let mut filter_set_builder = GlobSetBuilder::new();
for f in filters {
filter_set_builder.add(Glob::new(f)?);
debug!("Adding filter: \"{}\"", f);
}
let mut ignore_set_builder = GlobSetBuilder::new();
for i in ignores {
let mut ignore_path = Path::new(i).to_path_buf();
if ignore_path.is_relative() && !i.starts_with('*') {
ignore_path = Path::new("**").join(&ignore_path);
}
if !i.ends_with('*') {
ignore_path = ignore_path.join("**");
}
let pattern = ignore_path
.to_str()
.expect("corrupted memory (string -> path -> string)");
ignore_set_builder.add(Glob::new(pattern)?);
debug!("Adding ignore: \"{}\"", pattern);
}
Ok(Self {
filters: filter_set_builder.build()?,
filter_count: filters.len(),
ignores: ignore_set_builder.build()?,
gitignore_files,
ignore_files,
})
}
pub fn is_excluded(&self, path: &Path) -> bool {
if self.ignores.is_match(path) {
debug!("Ignoring {:?}: matched ignore filter", path);
return true;
}
if self.filters.is_match(path) {
return false;
}
if self.ignore_files.is_excluded(path) {
debug!("Ignoring {:?}: matched ignore file", path);
return true;
}
if self.gitignore_files.is_excluded(path) {
debug!("Ignoring {:?}: matched gitignore file", path);
return true;
}
if self.filter_count > 0 {
debug!("Ignoring {:?}: did not match any given filters", path);
}
self.filter_count > 0
}
}
#[cfg(test)]
mod tests {
use super::NotificationFilter;
use crate::gitignore;
use crate::ignore;
use std::path::Path;
#[test]
fn test_allows_everything_by_default() {
let filter = NotificationFilter::new(&[], &[], gitignore::load(&[]), ignore::load(&[]))
.expect("test filter errors");
assert!(!filter.is_excluded(Path::new("foo")));
}
#[test]
fn test_filename() {
let filter = NotificationFilter::new(
&[],
&["test.json".into()],
gitignore::load(&[]),
ignore::load(&[]),
)
.expect("test filter errors");
assert!(filter.is_excluded(Path::new("/path/to/test.json")));
assert!(filter.is_excluded(Path::new("test.json")));
}
#[test]
fn test_multiple_filters() {
let filters = &["*.rs".into(), "*.toml".into()];
let filter = NotificationFilter::new(filters, &[], gitignore::load(&[]), ignore::load(&[]))
.expect("test filter errors");
assert!(!filter.is_excluded(Path::new("hello.rs")));
assert!(!filter.is_excluded(Path::new("Cargo.toml")));
assert!(filter.is_excluded(Path::new("README.md")));
}
#[test]
fn test_multiple_ignores() {
let ignores = &["*.rs".into(), "*.toml".into()];
let filter = NotificationFilter::new(&[], ignores, gitignore::load(&[]), ignore::load(&[]))
.expect("test filter errors");
assert!(filter.is_excluded(Path::new("hello.rs")));
assert!(filter.is_excluded(Path::new("Cargo.toml")));
assert!(!filter.is_excluded(Path::new("README.md")));
}
#[test]
fn test_ignores_take_precedence() {
let ignores = &["*.rs".into(), "*.toml".into()];
let filter =
NotificationFilter::new(ignores, ignores, gitignore::load(&[]), ignore::load(&[]))
.expect("test filter errors");
assert!(filter.is_excluded(Path::new("hello.rs")));
assert!(filter.is_excluded(Path::new("Cargo.toml")));
assert!(filter.is_excluded(Path::new("README.md")));
}
#[test]
fn test_recursive_directory_ignore() {
let ignores = &["target".into()];
let filter = NotificationFilter::new(&[], ignores, gitignore::load(&[]), ignore::load(&[]))
.expect("test filter errors");
assert!(filter.is_excluded(Path::new("target")));
// Make sure that sub-directories/-files are recursively ignored.
assert!(filter.is_excluded(Path::new("target/rls")));
assert!(filter.is_excluded(Path::new("target/rls/debug")));
// Assert that files containing subsets of the path are not ignored.
assert!(!filter.is_excluded(Path::new("target-file")));
assert!(!filter.is_excluded(Path::new("hello.rs")));
assert!(!filter.is_excluded(Path::new("Cargo.toml")));
}
}

View File

@ -1,42 +0,0 @@
use notify::op;
use std::path::{Path, PathBuf};
/// Info about a path and its corresponding `notify` event
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct PathOp {
pub path: PathBuf,
pub op: Option<op::Op>,
pub cookie: Option<u32>,
}
impl PathOp {
pub fn new(path: &Path, op: Option<op::Op>, cookie: Option<u32>) -> Self {
Self {
path: path.to_path_buf(),
op,
cookie,
}
}
pub const fn is_create(op_: op::Op) -> bool {
op_.contains(op::CREATE)
}
pub const fn is_remove(op_: op::Op) -> bool {
op_.contains(op::REMOVE)
}
pub const fn is_rename(op_: op::Op) -> bool {
op_.contains(op::RENAME)
}
pub fn is_write(op_: op::Op) -> bool {
let mut write_or_close_write = op::WRITE;
write_or_close_write.toggle(op::CLOSE_WRITE);
op_.intersects(write_or_close_write)
}
pub const fn is_meta(op_: op::Op) -> bool {
op_.contains(op::CHMOD)
}
}

View File

@ -1,289 +0,0 @@
use crate::pathop::PathOp;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
};
/// Collect `PathOp` details into op-categories to pass onto the exec'd command as env-vars
///
/// `WRITTEN` -> `notify::ops::WRITE`, `notify::ops::CLOSE_WRITE`
/// `META_CHANGED` -> `notify::ops::CHMOD`
/// `REMOVED` -> `notify::ops::REMOVE`
/// `CREATED` -> `notify::ops::CREATE`
/// `RENAMED` -> `notify::ops::RENAME`
pub fn collect_path_env_vars(pathops: &[PathOp]) -> Vec<(String, String)> {
#[cfg(target_family = "unix")]
const ENV_SEP: &str = ":";
#[cfg(not(target_family = "unix"))]
const ENV_SEP: &str = ";";
let mut by_op = HashMap::new(); // Paths as `String`s collected by `notify::op`
let mut all_pathbufs = HashSet::new(); // All unique `PathBuf`s
for pathop in pathops {
if let Some(op) = pathop.op {
// ignore pathops that don't have a `notify::op` set
if let Some(s) = pathop.path.to_str() {
// ignore invalid utf8 paths
all_pathbufs.insert(pathop.path.clone());
let e = by_op.entry(op).or_insert_with(Vec::new);
e.push(s.to_owned());
}
}
}
let mut vars = Vec::new();
// Only break off a common path if we have more than one unique path,
// otherwise we end up with a `COMMON_PATH` being set and other vars
// being present but empty.
let common_path = if all_pathbufs.len() > 1 {
let all_pathbufs: Vec<PathBuf> = all_pathbufs.into_iter().collect();
get_longest_common_path(&all_pathbufs)
} else {
None
};
if let Some(ref common_path) = common_path {
vars.push(("WATCHEXEC_COMMON_PATH".to_string(), common_path.to_string()));
}
for (op, paths) in by_op {
let key = match op {
op if PathOp::is_create(op) => "WATCHEXEC_CREATED_PATH",
op if PathOp::is_remove(op) => "WATCHEXEC_REMOVED_PATH",
op if PathOp::is_rename(op) => "WATCHEXEC_RENAMED_PATH",
op if PathOp::is_write(op) => "WATCHEXEC_WRITTEN_PATH",
op if PathOp::is_meta(op) => "WATCHEXEC_META_CHANGED_PATH",
_ => continue, // ignore `notify::op::RESCAN`s
};
let paths = if let Some(ref common_path) = common_path {
paths
.iter()
.map(|path_str| path_str.trim_start_matches(common_path).to_string())
.collect::<Vec<_>>()
} else {
paths
};
vars.push((key.to_string(), paths.as_slice().join(ENV_SEP)));
}
vars
}
pub fn get_longest_common_path(paths: &[PathBuf]) -> Option<String> {
match paths.len() {
0 => return None,
1 => return paths[0].to_str().map(ToString::to_string),
_ => {}
};
let mut longest_path: Vec<_> = paths[0].components().collect();
for path in &paths[1..] {
let mut greatest_distance = 0;
for component_pair in path.components().zip(longest_path.iter()) {
if component_pair.0 != *component_pair.1 {
break;
}
greatest_distance += 1;
}
if greatest_distance != longest_path.len() {
longest_path.truncate(greatest_distance);
}
}
let mut result = PathBuf::new();
for component in longest_path {
result.push(component.as_os_str());
}
result.to_str().map(ToString::to_string)
}
#[cfg(test)]
mod tests {
use crate::pathop::PathOp;
use std::collections::HashSet;
use std::path::PathBuf;
use super::collect_path_env_vars;
use super::get_longest_common_path;
#[test]
#[cfg(unix)]
fn longest_common_path_single_unix() {
assert_eq!(
get_longest_common_path(&[PathBuf::from("/tmp/random/")])
.expect("failed to get longest common path"),
"/tmp/random/"
);
}
#[test]
#[cfg(unix)]
fn longest_common_path_similar_unix() {
assert_eq!(
get_longest_common_path(&[
PathBuf::from("/tmp/logs/hi"),
PathBuf::from("/tmp/logs/bye"),
PathBuf::from("/tmp/logs/bye"),
PathBuf::from("/tmp/logs/fly"),
])
.expect("failed to get longest common path"),
"/tmp/logs"
);
}
#[test]
#[cfg(unix)]
fn longest_common_path_divergent_unix() {
assert_eq!(
get_longest_common_path(&[
PathBuf::from("/tmp/logs/hi"),
PathBuf::from("/var/logs/hi")
])
.expect("failed to get longest common path"),
"/"
);
}
#[test]
#[cfg(unix)]
fn longest_common_path_uneven_unix() {
assert_eq!(
get_longest_common_path(&[
PathBuf::from("/tmp/logs/hi"),
PathBuf::from("/tmp/logs/"),
PathBuf::from("/tmp/logs/bye"),
])
.expect("failed to get longest common path"),
"/tmp/logs"
);
}
#[test]
#[cfg(windows)]
fn longest_common_path_single_windows() {
assert_eq!(
get_longest_common_path(&[PathBuf::from(r"C:\Temp\Random\")])
.expect("failed to get longest common path"),
r"C:\Temp\Random\"
);
}
#[test]
#[cfg(windows)]
fn longest_common_path_similar_windows() {
assert_eq!(
get_longest_common_path(&[
PathBuf::from(r"C:\Temp\Logs\hi"),
PathBuf::from(r"C:\Temp\Logs\bye"),
PathBuf::from(r"C:\Temp\Logs\bye"),
PathBuf::from(r"C:\Temp\Logs\fly"),
])
.expect("failed to get longest common path"),
r"C:\Temp\Logs"
);
}
#[test]
#[cfg(windows)]
fn longest_common_path_divergent_windows() {
assert_eq!(
get_longest_common_path(&[
PathBuf::from(r"C:\Temp\Logs\hi"),
PathBuf::from(r"C:\Perm\Logs\hi")
])
.expect("failed to get longest common path"),
r"C:\"
);
}
#[test]
#[cfg(windows)]
fn longest_common_path_uneven_windows() {
assert_eq!(
get_longest_common_path(&[
PathBuf::from(r"C:\Temp\Logs\hi"),
PathBuf::from(r"C:\Temp\Logs\"),
PathBuf::from(r"C:\Temp\Logs\bye"),
])
.expect("failed to get longest common path"),
r"C:\Temp\Logs"
);
}
#[test]
#[cfg(unix)]
fn pathops_collect_to_env_vars_unix() {
assert_eq!(
collect_path_env_vars(&[
PathOp::new(
&PathBuf::from("/tmp/logs/hi"),
Some(notify::op::CREATE),
None,
),
PathOp::new(
&PathBuf::from("/tmp/logs/hey/there"),
Some(notify::op::CREATE),
None,
),
PathOp::new(
&PathBuf::from("/tmp/logs/bye"),
Some(notify::op::REMOVE),
None,
),
])
.into_iter()
.collect::<HashSet<_>>(),
vec![
("WATCHEXEC_COMMON_PATH".to_string(), "/tmp/logs".to_string()),
("WATCHEXEC_REMOVED_PATH".to_string(), "/bye".to_string()),
(
"WATCHEXEC_CREATED_PATH".to_string(),
"/hi:/hey/there".to_string(),
),
]
.into_iter()
.collect::<HashSet<_>>()
);
}
#[test]
#[cfg(windows)]
fn pathops_collect_to_env_vars_windows() {
assert_eq!(
collect_path_env_vars(&[
PathOp::new(
&PathBuf::from(r"C:\Temp\Logs\hi"),
Some(notify::op::CREATE),
None,
),
PathOp::new(
&PathBuf::from(r"C:\Temp\Logs\hey\there"),
Some(notify::op::CREATE),
None,
),
PathOp::new(
&PathBuf::from(r"C:\Temp\Logs\bye"),
Some(notify::op::REMOVE),
None,
),
])
.into_iter()
.collect::<HashSet<_>>(),
vec![
(
"WATCHEXEC_COMMON_PATH".to_string(),
r"C:\Temp\Logs".to_string()
),
("WATCHEXEC_REMOVED_PATH".to_string(), r"\bye".to_string()),
(
"WATCHEXEC_CREATED_PATH".to_string(),
r"\hi;\hey\there".to_string(),
),
]
.into_iter()
.collect::<HashSet<_>>()
);
}
}

View File

@ -1,437 +0,0 @@
#[cfg(unix)]
use command_group::UnixChildExt;
use command_group::{CommandGroup, GroupChild};
use log::{debug, info, warn};
use std::{
collections::HashMap,
fs::canonicalize,
process::Child,
sync::{
mpsc::{channel, Receiver},
Arc, Mutex,
},
time::Duration,
};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::gitignore;
use crate::ignore;
use crate::notification_filter::NotificationFilter;
use crate::pathop::PathOp;
use crate::signal::{self, Signal};
use crate::watcher::{Event, Watcher};
/// Behaviour to use when handling updates while the command is running.
#[derive(Clone, Copy, Debug)]
pub enum OnBusyUpdate {
/// ignore updates while busy
DoNothing,
/// wait for the command to exit, then start a new one
Queue,
/// restart the command immediately
Restart,
/// send a signal only
Signal,
}
impl Default for OnBusyUpdate {
fn default() -> Self {
Self::DoNothing
}
}
pub trait Handler {
/// Called through a manual request, such as an initial run.
///
/// # Returns
///
/// A `Result` which means:
///
/// - `Err`: an error has occurred while processing, quit.
/// - `Ok(true)`: everything is fine and the loop can continue.
/// - `Ok(false)`: everything is fine but we should gracefully stop.
fn on_manual(&self) -> Result<bool>;
/// Called through a file-update request.
///
/// # Parameters
///
/// - `ops`: The list of events that triggered this update.
///
/// # Returns
///
/// A `Result` which means:
///
/// - `Err`: an error has occurred while processing, quit.
/// - `Ok(true)`: everything is fine and the loop can continue.
/// - `Ok(false)`: everything is fine but we should gracefully stop.
fn on_update(&self, ops: &[PathOp]) -> Result<bool>;
/// Called once by `watch` at the very start.
///
/// Not called again; any changes will never be picked up.
///
/// The `Config` instance should be created using `ConfigBuilder` rather than direct initialisation
/// to resist potential breaking changes (see semver policy on crate root).
fn args(&self) -> Config;
}
/// Starts watching, and calls a handler when something happens.
///
/// Given an argument structure and a `Handler` type, starts the watcher loop, blocking until done.
pub fn watch<H>(handler: &H) -> Result<()>
where
H: Handler,
{
let args = handler.args();
let mut paths = vec![];
for path in &args.paths {
paths.push(
canonicalize(&path)
.map_err(|e| Error::Canonicalization(path.to_string_lossy().into_owned(), e))?,
);
}
let ignore = ignore::load(if args.no_ignore { &[] } else { &paths });
let gitignore = gitignore::load(if args.no_vcs_ignore || args.no_ignore {
&[]
} else {
&paths
});
let filter = NotificationFilter::new(&args.filters, &args.ignores, gitignore, ignore)?;
let (tx, rx) = channel();
#[cfg_attr(not(target_os = "linux"), allow(clippy::redundant_clone, unused_mut))]
let mut maybe_watcher = Watcher::new(tx.clone(), &paths, args.poll, args.poll_interval);
#[cfg(target_os = "linux")]
if !args.poll {
if let Err(notify::Error::Io(ref e)) = maybe_watcher {
if e.raw_os_error() == Some(nix::libc::ENOSPC) {
warn!("System notification limit is too small, falling back to polling mode. For better performance increase system limit:\n\tsysctl fs.inotify.max_user_watches=524288");
maybe_watcher = Watcher::new(tx, &paths, true, args.poll_interval);
}
}
}
let watcher = maybe_watcher?;
if watcher.is_polling() {
warn!("Polling for changes every {:?}", args.poll_interval);
}
// Call handler initially, if necessary
if args.run_initially && !handler.on_manual()? {
return Ok(());
}
loop {
debug!("Waiting for filesystem activity");
let paths = wait_fs(&rx, &filter, args.debounce, args.no_meta);
info!("Paths updated: {:?}", paths);
if !handler.on_update(&paths)? {
break;
}
}
Ok(())
}
#[derive(Debug)]
pub enum ChildProcess {
None,
Grouped(GroupChild),
Ungrouped(Child),
}
impl Default for ChildProcess {
fn default() -> Self {
ChildProcess::None
}
}
impl ChildProcess {
#[cfg(unix)]
fn signal(&mut self, sig: Signal) -> Result<()> {
match self {
Self::None => Ok(()),
Self::Grouped(c) => {
debug!("Sending signal {} to process group id={}", sig, c.id());
c.signal(sig)
}
Self::Ungrouped(c) => {
debug!("Sending signal {} to process id={}", sig, c.id());
c.signal(sig)
}
}
.map_err(|e| e.into())
}
fn kill(&mut self) -> Result<()> {
match self {
Self::None => Ok(()),
Self::Grouped(c) => {
debug!("Killing process group id={}", c.id());
c.kill()
}
Self::Ungrouped(c) => {
debug!("Killing process id={}", c.id());
c.kill()
}
}
.map_err(|e| e.into())
}
fn is_running(&mut self) -> Result<bool> {
match self {
Self::None => Ok(false),
Self::Grouped(c) => c.try_wait().map(|w| w.is_none()),
Self::Ungrouped(c) => c.try_wait().map(|w| w.is_none()),
}
.map_err(|e| e.into())
}
fn wait(&mut self) -> Result<()> {
match self {
Self::None => Ok(()),
Self::Grouped(c) => c.wait().map(drop),
Self::Ungrouped(c) => c.wait().map(drop),
}
.map_err(|e| e.into())
}
}
pub struct ExecHandler {
args: Config,
signal: Option<Signal>,
child_process: Arc<Mutex<ChildProcess>>,
}
impl ExecHandler {
pub fn new(args: Config) -> Result<Self> {
let child_process: Arc<Mutex<ChildProcess>> = Arc::default();
let weak_child = Arc::downgrade(&child_process);
// Convert signal string to the corresponding integer
let signal = signal::new(args.signal.clone());
signal::install_handler(move |sig: Signal| {
if let Some(lock) = weak_child.upgrade() {
let mut child = lock.lock().expect("poisoned lock in install_handler");
match sig {
Signal::SIGCHLD => {
child.is_running().ok();
}
_ => {
#[cfg(unix)]
child.signal(sig).unwrap_or_else(|err| {
warn!("Could not pass on signal to command: {}", err)
});
#[cfg(not(unix))]
child.kill().unwrap_or_else(|err| {
warn!("Could not pass on termination to command: {}", err)
});
}
}
}
});
Ok(Self {
args,
signal,
child_process,
})
}
fn spawn(&self, ops: &[PathOp]) -> Result<()> {
if self.args.clear_screen {
clearscreen::clear()?;
}
let mut child = self.child_process.lock()?;
child.kill().ok();
let mut command = self.args.shell.to_command(&self.args.cmd);
debug!("Assembled command: {:?}", command);
if !self.args.no_environment {
for (name, val) in crate::paths::collect_path_env_vars(ops) {
debug!("Command environment: {}={:?}", name, val);
command.env(name, val);
}
}
debug!("Launching command");
*child = if self.args.use_process_group {
ChildProcess::Grouped(command.group_spawn()?)
} else {
ChildProcess::Ungrouped(command.spawn()?)
};
Ok(())
}
pub fn has_running_process(&self) -> Result<bool> {
self.child_process
.lock()
.expect("poisoned lock in has_running_process")
.is_running()
}
}
impl Handler for ExecHandler {
fn args(&self) -> Config {
self.args.clone()
}
// Only returns Err() on lock poisoning.
fn on_manual(&self) -> Result<bool> {
if self.args.once {
return Ok(true);
}
self.spawn(&[])?;
Ok(true)
}
fn on_update(&self, ops: &[PathOp]) -> Result<bool> {
log::debug!("ON UPDATE: called");
let signal = self.signal.unwrap_or(Signal::SIGTERM);
let has_running_processes = self.has_running_process()?;
log::debug!(
"ON UPDATE: has_running_processes: {} --- on_busy_update: {:?}",
has_running_processes,
self.args.on_busy_update
);
match (has_running_processes, self.args.on_busy_update) {
// If nothing is running, start the command
(false, _) => {
self.spawn(ops)?;
}
// Just send a signal to the command, do nothing more
(true, OnBusyUpdate::Signal) => signal_process(&self.child_process, signal)?,
// Send a signal to the command, wait for it to exit, then run the command again
(true, OnBusyUpdate::Restart) => {
signal_process(&self.child_process, signal)?;
wait_on_process(&self.child_process)?;
self.spawn(ops)?;
}
// Wait for the command to end, then run it again
(true, OnBusyUpdate::Queue) => {
wait_on_process(&self.child_process)?;
self.spawn(ops)?;
}
(true, OnBusyUpdate::DoNothing) => {}
}
// Handle once option for integration testing
if self.args.once {
if let Some(signal) = self.signal {
signal_process(&self.child_process, signal)?;
}
wait_on_process(&self.child_process)?;
return Ok(false);
}
Ok(true)
}
}
pub fn run(args: Config) -> Result<()> {
watch(&ExecHandler::new(args)?)
}
fn wait_fs(
rx: &Receiver<Event>,
filter: &NotificationFilter,
debounce: Duration,
no_meta: bool,
) -> Vec<PathOp> {
let mut paths = Vec::new();
let mut cache = HashMap::new();
loop {
let e = rx.recv().expect("error when reading event");
if let Some(ref path) = e.path {
let pathop = PathOp::new(path, e.op.ok(), e.cookie);
if let Some(op) = pathop.op {
if no_meta && PathOp::is_meta(op) {
continue;
}
}
// Ignore cache for the initial file. Otherwise, in
// debug mode it's hard to track what's going on
let excluded = filter.is_excluded(path);
if !cache.contains_key(&pathop) {
cache.insert(pathop.clone(), excluded);
}
if !excluded {
paths.push(pathop);
break;
}
}
}
// Wait for filesystem activity to cool off
while let Ok(e) = rx.recv_timeout(debounce) {
if let Some(ref path) = e.path {
let pathop = PathOp::new(path, e.op.ok(), e.cookie);
if cache.contains_key(&pathop) {
continue;
}
let excluded = filter.is_excluded(path);
cache.insert(pathop.clone(), excluded);
if !excluded {
paths.push(pathop);
}
}
}
paths
}
fn signal_process(process: &Mutex<ChildProcess>, signal: Signal) -> Result<()> {
let mut child = process.lock().expect("poisoned lock in signal_process");
#[cfg(unix)]
child.signal(signal)?;
#[cfg(not(unix))]
if matches!(signal, Signal::SIGTERM | Signal::SIGKILL) {
child.kill()?;
} else {
debug!("Ignoring signal to send to process");
}
Ok(())
}
fn wait_on_process(process: &Mutex<ChildProcess>) -> Result<()> {
process
.lock()
.expect("poisoned lock in wait_on_process")
.wait()
}

View File

@ -1,197 +0,0 @@
use std::process::Command;
/// Shell to use to run commands.
///
/// `Cmd` and `Powershell` are special-cased because they have different calling
/// conventions. Also `Cmd` is only available in Windows, while `Powershell` is
/// also available on unices (provided the end-user has it installed, of course).
///
/// See [`Config.cmd`][crate::config::Config] for the semantics of `None` vs the
/// other options.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Shell {
/// Use no shell, and execute the command directly.
None,
/// Use the given string as a unix shell invocation.
///
/// This means two things:
/// - the program is invoked with `-c` followed by the command, and
/// - the string will be split on space, and the resulting vec used as
/// execvp(3) arguments: first is the shell program, rest are additional
/// arguments (which come before the `-c` mentioned above). This is a very
/// simplistic approach deliberately: it will not support quoted
/// arguments, for example. Use [`Shell::None`] with a custom command vec
/// if you want that.
Unix(String),
/// Use the Windows CMD.EXE shell.
///
/// This is invoked with `/C` followed by the command.
#[cfg(windows)]
Cmd,
/// Use Powershell, on Windows or elsewhere.
///
/// This is invoked with `-Command` followed by the command.
///
/// This is preferred over `Unix("pwsh")`, though that will also work
/// on unices due to Powershell supporting the `-c` short option.
Powershell,
}
impl Default for Shell {
#[cfg(windows)]
fn default() -> Self {
Self::Powershell
}
#[cfg(not(windows))]
fn default() -> Self {
Self::Unix("sh".into())
}
}
impl Shell {
/// Obtain a [`Command`] given the cmd vec from [`Config`][crate::config::Config].
///
/// Behaves as described in the enum documentation.
///
/// # Panics
///
/// - Panics if `cmd` is empty.
/// - Panics if the string in the `Unix` variant is empty or only whitespace.
pub fn to_command(&self, cmd: &[String]) -> Command {
assert!(!cmd.is_empty(), "cmd was empty");
match self {
Shell::None => {
// UNWRAP: checked by assert
#[allow(clippy::unwrap_used)]
let (first, rest) = cmd.split_first().unwrap();
let mut c = Command::new(first);
c.args(rest);
c
}
#[cfg(windows)]
Shell::Cmd => {
let mut c = Command::new("cmd.exe");
c.arg("/C").arg(cmd.join(" "));
c
}
Shell::Powershell if cfg!(windows) => {
let mut c = Command::new("powershell.exe");
c.arg("-Command").arg(cmd.join(" "));
c
}
Shell::Powershell => {
let mut c = Command::new("pwsh");
c.arg("-Command").arg(cmd.join(" "));
c
}
Shell::Unix(name) => {
assert!(!name.is_empty(), "shell program was empty");
let sh = name.split_ascii_whitespace().collect::<Vec<_>>();
// UNWRAP: checked by assert
#[allow(clippy::unwrap_used)]
let (shprog, shopts) = sh.split_first().unwrap();
let mut c = Command::new(shprog);
c.args(shopts);
c.arg("-c").arg(cmd.join(" "));
c
}
}
}
}
#[cfg(test)]
mod test {
use super::Shell;
use command_group::CommandGroup;
#[test]
#[cfg(unix)]
fn unix_shell_default() -> Result<(), std::io::Error> {
assert!(Shell::default()
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(unix)]
fn unix_shell_none() -> Result<(), std::io::Error> {
assert!(Shell::None
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(unix)]
fn unix_shell_alternate() -> Result<(), std::io::Error> {
assert!(Shell::Unix("bash".into())
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(unix)]
fn unix_shell_alternate_shopts() -> Result<(), std::io::Error> {
assert!(Shell::Unix("bash -o errexit".into())
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(windows)]
fn windows_shell_default() -> Result<(), std::io::Error> {
assert!(Shell::default()
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(windows)]
fn windows_shell_cmd() -> Result<(), std::io::Error> {
assert!(Shell::Cmd
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(windows)]
fn windows_shell_powershell() -> Result<(), std::io::Error> {
assert!(Shell::Powershell
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
#[test]
#[cfg(windows)]
fn windows_shell_unix_style_powershell() -> Result<(), std::io::Error> {
assert!(Shell::Unix("powershell.exe".into())
.to_command(&["echo".into(), "hi".into()])
.group_status()?
.success());
Ok(())
}
}

View File

@ -1,207 +0,0 @@
use std::sync::Mutex;
type CleanupFn = Box<dyn Fn(self::Signal) + Send>;
lazy_static::lazy_static! {
static ref CLEANUP: Mutex<Option<CleanupFn>> = Mutex::new(None);
}
// Indicate interest in SIGCHLD by setting a dummy handler
#[cfg(unix)]
#[allow(clippy::missing_const_for_fn)]
pub extern "C" fn sigchld_handler(_: c_int) {}
#[cfg(unix)]
pub use nix::sys::signal::Signal;
// This is a dummy enum for Windows
#[cfg(windows)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Copy, Clone)]
pub enum Signal {
SIGKILL,
SIGTERM,
SIGINT,
SIGHUP,
SIGSTOP,
SIGCONT,
SIGCHLD,
SIGUSR1,
SIGUSR2,
}
#[cfg(windows)]
use std::fmt;
#[cfg(windows)]
impl fmt::Display for Signal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
Self::SIGKILL => "SIGKILL",
Self::SIGTERM => "SIGTERM",
Self::SIGINT => "SIGINT",
Self::SIGHUP => "SIGHUP",
Self::SIGSTOP => "SIGSTOP",
Self::SIGCONT => "SIGCONT",
Self::SIGCHLD => "SIGCHLD",
Self::SIGUSR1 => "SIGUSR1",
Self::SIGUSR2 => "SIGUSR2",
}
)
}
}
#[cfg(unix)]
use nix::libc::*;
#[cfg(unix)]
pub trait ConvertToLibc {
fn convert_to_libc(self) -> c_int;
}
#[cfg(unix)]
impl ConvertToLibc for Signal {
fn convert_to_libc(self) -> c_int {
// Convert from signal::Signal enum to libc::* c_int constants
match self {
Self::SIGKILL => SIGKILL,
Self::SIGTERM => SIGTERM,
Self::SIGINT => SIGINT,
Self::SIGHUP => SIGHUP,
Self::SIGSTOP => SIGSTOP,
Self::SIGCONT => SIGCONT,
Self::SIGCHLD => SIGCHLD,
Self::SIGUSR1 => SIGUSR1,
Self::SIGUSR2 => SIGUSR2,
_ => panic!("unsupported signal: {:?}", self),
}
}
}
pub fn new(signal_name: Option<String>) -> Option<Signal> {
if let Some(signame) = signal_name {
let signal = match signame.as_ref() {
"SIGKILL" | "KILL" => Signal::SIGKILL,
"SIGTERM" | "TERM" => Signal::SIGTERM,
"SIGINT" | "INT" => Signal::SIGINT,
"SIGHUP" | "HUP" => Signal::SIGHUP,
"SIGSTOP" | "STOP" => Signal::SIGSTOP,
"SIGCONT" | "CONT" => Signal::SIGCONT,
"SIGCHLD" | "CHLD" => Signal::SIGCHLD,
"SIGUSR1" | "USR1" => Signal::SIGUSR1,
"SIGUSR2" | "USR2" => Signal::SIGUSR2,
_ => panic!("unsupported signal: {}", signame),
};
Some(signal)
} else {
None
}
}
#[cfg(unix)]
pub fn install_handler<F>(handler: F)
where
F: Fn(self::Signal) + 'static + Send + Sync,
{
use log::debug;
use nix::sys::signal::*;
use std::thread;
// Mask all signals interesting to us. The mask propagates
// to all threads started after this point.
let mut mask = SigSet::empty();
mask.add(SIGKILL);
mask.add(SIGTERM);
mask.add(SIGINT);
mask.add(SIGHUP);
mask.add(SIGSTOP);
mask.add(SIGCONT);
mask.add(SIGCHLD);
mask.add(SIGUSR1);
mask.add(SIGUSR2);
mask.thread_set_mask().expect("unable to set signal mask");
set_handler(handler);
#[allow(unsafe_code)]
unsafe {
let _ = sigaction(
SIGCHLD,
&SigAction::new(
SigHandler::Handler(sigchld_handler),
SaFlags::empty(),
SigSet::empty(),
),
);
}
// Spawn a thread to catch these signals
thread::spawn(move || {
loop {
let signal = mask.wait().expect("Unable to sigwait");
debug!("Received {:?}", signal);
// Invoke closure
invoke(signal);
// Restore default behavior for received signal and unmask it
if signal != SIGCHLD {
let default_action =
SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
#[allow(unsafe_code)]
unsafe {
let _ = sigaction(signal, &default_action);
}
}
let mut new_mask = SigSet::empty();
new_mask.add(signal);
// Re-raise with signal unmasked
let _ = new_mask.thread_unblock();
let _ = raise(signal);
let _ = new_mask.thread_block();
}
});
}
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn install_handler<F>(handler: F)
where
F: Fn(self::Signal) + 'static + Send + Sync,
{
use winapi::shared::minwindef::{BOOL, DWORD, FALSE, TRUE};
use winapi::um::consoleapi::SetConsoleCtrlHandler;
pub unsafe extern "system" fn ctrl_handler(_: DWORD) -> BOOL {
invoke(self::Signal::SIGTERM);
FALSE
}
set_handler(handler);
unsafe {
SetConsoleCtrlHandler(Some(ctrl_handler), TRUE);
}
}
fn invoke(sig: self::Signal) {
if let Some(ref handler) = *CLEANUP.lock().expect("poisoned lock in signal::invoke") {
handler(sig)
}
}
fn set_handler<F>(handler: F)
where
F: Fn(self::Signal) + 'static + Send + Sync,
{
*CLEANUP
.lock()
.expect("poisoned lock in signal::set_handler") = Some(Box::new(handler));
}

View File

@ -1,62 +0,0 @@
use log::debug;
use notify::{raw_watcher, PollWatcher, RecommendedWatcher, RecursiveMode};
use std::convert::TryFrom;
use std::path::PathBuf;
use std::sync::mpsc::Sender;
use std::time::Duration;
/// Thin wrapper over the notify crate
///
/// `PollWatcher` and `RecommendedWatcher` are distinct types, but watchexec
/// really just wants to handle them without regard to the exact type
/// (e.g. polymorphically). This has the nice side effect of separating out
/// all coupling to the notify crate into this module.
pub struct Watcher {
watcher_impl: WatcherImpl,
}
pub use notify::Error;
pub use notify::RawEvent as Event;
enum WatcherImpl {
Recommended(RecommendedWatcher),
Poll(PollWatcher),
}
impl Watcher {
pub fn new(
tx: Sender<Event>,
paths: &[PathBuf],
poll: bool,
interval: Duration,
) -> Result<Self, Error> {
use notify::Watcher;
let imp = if poll {
let mut watcher = PollWatcher::with_delay_ms(
tx,
u32::try_from(interval.as_millis()).unwrap_or(u32::MAX),
)?;
for path in paths {
watcher.watch(path, RecursiveMode::Recursive)?;
debug!("Watching {:?}", path);
}
WatcherImpl::Poll(watcher)
} else {
let mut watcher = raw_watcher(tx)?;
for path in paths {
watcher.watch(path, RecursiveMode::Recursive)?;
debug!("Watching {:?}", path);
}
WatcherImpl::Recommended(watcher)
};
Ok(Self { watcher_impl: imp })
}
pub fn is_polling(&self) -> bool {
matches!(self.watcher_impl, WatcherImpl::Poll(_))
}
}