summaryrefslogtreecommitdiff
path: root/bin/sz-wait-for
diff options
context:
space:
mode:
Diffstat (limited to 'bin/sz-wait-for')
-rwxr-xr-xbin/sz-wait-for308
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