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
|