diff options
Diffstat (limited to 'bin/sz-wait-for')
| -rwxr-xr-x | bin/sz-wait-for | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/bin/sz-wait-for b/bin/sz-wait-for new file mode 100755 index 0000000..96e32a5 --- /dev/null +++ b/bin/sz-wait-for @@ -0,0 +1,308 @@ +#!/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 "path<TAB>mtime" 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: <path>"; 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 <file-or-directory>" >&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 <file>` 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 "path<TAB>mtime" 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 "path<TAB>mtime" 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 |
