all repos — dot @ master

Shell, editor and terminal configuration, and the tools that go with them

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