#!/usr/bin/env bash set -euo pipefail # Single-shot waiter: block until the watched target changes, debounce edits for # 5 seconds, confirm the change is real, then exit 0. Meant to be re-invoked in a # loop, not to stream continuous events. # # Takes one argument: the file or directory to watch. A directory fires when # anything under it changes -- a file added, removed, or modified, at any depth. # # Behavior and design choices: # - resolve the target to an absolute path so the caller's cwd does not matter # - capture the baseline in memory at startup; only changes that happen while # this process is running are detected (no cross-run state is kept, by design) # - debounce bursts of edits (5 seconds of quiet after the last event) # # Change detection uses a fingerprint rather than a single mtime: a recursive # walk produces a sorted "pathmtime" listing that is hashed into one value, # so any add/remove/modify moves the hash. The walk is recursive to match the # watcher scope (fswatch is recursive on macOS via FSEvents, and we pass -r on # Linux), so a deep change can never wake us without also moving the fingerprint. # # On a clean exit it prints "Changed: "; for a directory it also lists the # entries it noticed were added, removed, or modified (capped for readability). # # Known limitations (deliberate, to stay simple): # - mtime-only: a change that does not move an entry's mtime is not detected, # even though fswatch may wake us (we treat it as a spurious event and # re-arm). This covers metadata-only edits (chmod/chown/xattr) and two edits # to the same file within one wall-clock second on the seconds-only stat # fallback. Closing this would require content hashing, not worth the cost. # - symlinks are not followed: find lists the link node and stat reports the # link itself, so a change to a symlink's *target* does not move the # fingerprint. Watching the link node is the intended behavior. # - cost: the walk runs one stat (or date/stat -c) per entry, recomputed on # each wake and once per second while settling. Fine for small trees; on # very large/deep trees a sample may exceed the 1s settle tick. if [ "$#" -ne 1 ]; then echo "Usage: $0 " >&2 exit 1 fi TARGET="$1" if [ ! -e "$TARGET" ]; then echo "Error: path does not exist: $TARGET" >&2 exit 1 fi # Resolve to an absolute path so the watch target is stable even if the caller's # cwd changes. Directories resolve by cd'ing in; a file resolves via its parent # so a symlinked file resolves predictably. if [ -d "$TARGET" ]; then ABS_TARGET="$(cd "$TARGET" && pwd)" else case "$TARGET" in /*) ABS_TARGET="$TARGET" ;; *) ABS_TARGET="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" ;; esac fi if [ ! -e "$ABS_TARGET" ]; then echo "Error: path does not exist: $ABS_TARGET" >&2 exit 1 fi # Accept only a regular file or a directory. Other node types (sockets, FIFOs, # devices) are rejected -- the fingerprint/watch logic is not meant for them. if [ ! -d "$ABS_TARGET" ] && [ ! -f "$ABS_TARGET" ]; then echo "Error: not a regular file or directory: $ABS_TARGET" >&2 exit 1 fi # Build the fswatch argument list once. # - Directory: watch it recursively (-r forces recursion on Linux/inotify; it # is harmless on macOS, where FSEvents is already recursive). # - File: watch the file and its parent directory so atomic editor saves # (write temp + rename over the target, which swaps the inode) are detected. if [ -d "$ABS_TARGET" ]; then WATCH_ARGS=(-r "$ABS_TARGET") # The directory listing relies on `find`. Fail loudly here rather than let a # missing `find` collapse the walk to an empty listing (which would hash to a # constant and make the watcher arm but never fire). if ! command -v find >/dev/null 2>&1; then echo "Error: find is required to watch a directory" >&2 exit 1 fi else WATCH_ARGS=("$ABS_TARGET" "$(dirname "$ABS_TARGET")") fi # Hash whatever arrives on stdin to a stable hex digest. hash_stdin() { if command -v shasum >/dev/null 2>&1; then shasum | awk '{print $1}' else sha1sum | awk '{print $1}' fi } # Normalize an mtime value to seconds.nanoseconds with exactly 9 fractional # digits so values are stable across different stat/date outputs. normalize_mtime() { local mtime="$1" if [[ "$mtime" =~ ^([0-9]+)\.([0-9]+)$ ]]; then local secs="${BASH_REMATCH[1]}" local frac="${BASH_REMATCH[2]}" while [ "${#frac}" -lt 9 ]; do frac="${frac}0"; done frac="${frac:0:9}" printf '%s.%s' "$secs" "$frac" else printf '%s.000000000' "$mtime" fi } # Cross-platform mtime with sub-second precision where available. # Try the macOS/BSD high-precision stat first (avoids a failing `date` exec on # every poll on stock macOS, where BSD `date -r` only accepts epoch seconds). # GNU `date -r ` gives nanoseconds on Linux. The seconds-only GNU stat # fallback is the last resort; rapid same-second edits may be invisible there. # Prints the normalized mtime on stdout and returns 0; returns 1 on failure. mtime_of() { local f="$1" local out if out=$(stat -f '%Fm' "$f" 2>/dev/null); then normalize_mtime "$out" return 0 elif out=$(date -r "$f" +%s.%N 2>/dev/null) && [[ "$out" =~ ^[0-9]+\.[0-9]{9}$ ]]; then printf '%s' "$out" return 0 elif out=$(stat -c '%Y' "$f" 2>/dev/null); then normalize_mtime "$out" return 0 fi return 1 } # Emit the target's "pathmtime" listing on stdout, one line per entry: # - File: a single line. # - Directory: the directory itself and every descendant, sorted by path for # determinism. # Entries that vanish mid-walk are skipped: their absence simply changes the # listing on the next sample, which is the correct signal. A directory always # yields at least itself, so an *empty* listing while it still exists means the # walk tooling failed (find/sort/stat); we fail loudly there rather than emit # nothing, which would hide a change behind the constant empty-string hash. A # truly vanished target also yields nothing, but the main loop's existence check # reports that with a clear message. listing_of() { local target="$1" if [ -d "$target" ]; then local listing # Requires `sort -z` (GNU sort and Apple's sort 2.3+ both support it). If a # platform's sort lacks it, the pipeline yields no output and the empty # listing is caught loudly below rather than degrading to a constant hash. listing=$(find "$target" -print0 2>/dev/null \ | LC_ALL=C sort -z \ | while IFS= read -r -d '' entry; do m=$(mtime_of "$entry") || continue printf '%s\t%s\n' "$entry" "$m" done) || true if [ -z "$listing" ] && [ -d "$target" ]; then echo "Error: directory walk produced no entries for $target (find/sort/stat failure?)" >&2 return 1 fi printf '%s' "$listing" else local m if m=$(mtime_of "$target"); then printf '%s\t%s' "$target" "$m" else # Fail loudly rather than emit nothing and hide the change behind a hash. echo "Error: unable to read mtime of $target" >&2 return 1 fi fi } # Hash a target's listing into one digest -- cheap to compare, used only to # detect that *something* changed. fingerprint_of() { listing_of "$1" | hash_stdin } # Summarize what changed between two listings (the "pathmtime" output of # listing_of) to stdout, as added/removed/modified entries with paths relative # to the watched root. A directory's own mtime bump is always a side effect of a # child being added or removed -- already reported via that child -- so those # "modified" lines are dropped as noise. Output is capped to stay readable. report_changes() { local base="$1" final="$2" root="$3" local max=10 shown=0 total=0 status path rel while IFS=$'\t' read -r status path; do # Drop pure directory mtime bumps; the child add/remove is reported too. if [ "$status" = M ] && [ -d "$path" ]; then continue fi total=$((total + 1)) if [ "$shown" -lt "$max" ]; then case "$path" in "$root") rel="." ;; "$root"/*) rel="${path#"$root"/}" ;; *) rel="$path" ;; esac case "$status" in A) printf ' added: %s\n' "$rel" ;; R) printf ' removed: %s\n' "$rel" ;; M) printf ' modified: %s\n' "$rel" ;; esac shown=$((shown + 1)) fi done < <( awk -F'\t' ' NR==FNR { bm[$1]=$2; b[$1]=1; next } { f[$1]=1 if (!($1 in b)) print "A\t" $1 else if (bm[$1] != $2) print "M\t" $1 } END { for (p in b) if (!(p in f)) print "R\t" p } ' <(printf '%s\n' "$base") <(printf '%s\n' "$final") | LC_ALL=C sort ) if [ "$total" -gt "$shown" ]; then printf ' ... and %d more\n' "$((total - shown))" fi } SETTLE=5 if ! command -v fswatch >/dev/null 2>&1; then echo "Error: fswatch is required (https://github.com/emcrisostomo/fswatch)" >&2 exit 1 fi # Capture the baseline in memory at startup. No state is kept across runs: only # changes that happen while this process is running are detected. Keep the raw # listing too so a directory change can be described, not just detected. BASE_LISTING="$(listing_of "$ABS_TARGET")" BASE_FP="$(printf '%s' "$BASE_LISTING" | hash_stdin)" while :; do CURRENT_FP="$(fingerprint_of "$ABS_TARGET")" if [ "$CURRENT_FP" = "$BASE_FP" ]; then # No change yet; block on a filesystem notification. # NOTE: there is a tiny check-then-arm window between the fingerprint read # above and fswatch starting; a change in that gap may not be noticed until # the next event wakes us. # NOTE: watching a directory (or a file's parent) means unrelated entries can # wake us up; the immediate fingerprint check below keeps that cheap. if FS_OUT=$(fswatch -1 "${WATCH_ARGS[@]}" 2>&1); then FS_RC=0 else FS_RC=$? fi if [ "$FS_RC" -ne 0 ]; then if [ ! -e "$ABS_TARGET" ]; then echo "Error: watched path disappeared: $ABS_TARGET" >&2 else echo "Error: fswatch failed (exit $FS_RC): $FS_OUT" >&2 fi exit "$FS_RC" fi if [ ! -e "$ABS_TARGET" ]; then echo "Error: watched path disappeared: $ABS_TARGET" >&2 exit 1 fi fi # Unrelated events can wake us up. Recompute the fingerprint immediately; if it # has not moved, re-arm without paying the full stabilization penalty. EVENT_FP="$(fingerprint_of "$ABS_TARGET")" if [ "$EVENT_FP" = "$BASE_FP" ]; then continue fi # Stabilize: wait until the target has been quiet for SETTLE seconds. If it # changes again during that window, reset the timer. For a directory this # naturally absorbs bursts like a multi-file save or a checkout. LAST_FP="$EVENT_FP" QUIET=0 while [ "$QUIET" -lt "$SETTLE" ]; do sleep 1 if [ ! -e "$ABS_TARGET" ]; then echo "Error: watched path disappeared: $ABS_TARGET" >&2 exit 1 fi NOW_FP="$(fingerprint_of "$ABS_TARGET")" if [ "$NOW_FP" != "$LAST_FP" ]; then LAST_FP="$NOW_FP" QUIET=0 else QUIET=$((QUIET + 1)) fi done # Confirm the listing actually changed from the baseline. fswatch can report # spurious or duplicate events, so we only fire on a real delta. FINAL_LISTING="$(listing_of "$ABS_TARGET")" FINAL_FP="$(printf '%s' "$FINAL_LISTING" | hash_stdin)" if [ "$FINAL_FP" != "$BASE_FP" ]; then echo "Changed: $ABS_TARGET" >&2 if [ -d "$ABS_TARGET" ]; then report_changes "$BASE_LISTING" "$FINAL_LISTING" "$ABS_TARGET" >&2 fi exit 0 fi done