bin/sz-wait-for (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 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
|