summaryrefslogtreecommitdiff
path: root/bin/sz-wait-for
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-09-17 19:45:23 +0100
committerIgor Soarez <igor@soarez.org>2026-09-17 19:45:23 +0100
commit13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch)
tree684690c61b7b59758c2aac132c65b81c86de0608 /bin/sz-wait-for
dotfiles
Shell, editor and terminal configuration, the tools that go with them, and szecret, an age-based secret manager whose store lives elsewhere. History restarts here. What came before was not written to be read by anyone else, and carried machine names, addresses and an encrypted secret store; it is kept privately instead. What is left is meant to be safe on any machine, including one whose owner is not me: see CLAUDE.md for the rule it is written to.
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