Status: Accepted
Context: File watchers typically use OS-native event APIs — inotify on Linux, FSEvents/kqueue on macOS, ReadDirectoryChangesW on Windows. These push events immediately when a file changes rather than requiring periodic checks.
Decision:
Use filepath.WalkDir on a polling interval instead of OS events.
Reasons:
- OS event APIs do not watch recursively — you must manually add every new subdirectory as it appears, which requires handling
Createevents on directories. - Editors write files in bursts (temp file → rename, or multiple saves in rapid succession), requiring event debouncing logic.
- Platform differences require significant per-OS code paths or a cross-platform library with its own edge cases.
- For a typical Go project (hundreds of files), polling at 800ms is fast enough that the latency difference is not noticeable in practice.
Consequences:
- Maximum detection latency equals the poll interval (default 800ms, configurable).
- CPU overhead scales with directory size, but is negligible for typical projects.
- Zero platform-specific code in the watch path.
Status: Accepted
Context:
The original implementation used filepath.Walk, which calls os.Lstat on every directory entry regardless of whether the entry will be used.
Decision:
Switch to filepath.WalkDir and defer d.Info() (the stat syscall) until after all cheap checks pass.
Reasons:
filepath.WalkDirreads directory entries in bulk from the OS viaGetdents-style calls.IsDir()andType()are available without a syscall.- Hidden files,
.git, and.gitignore-matched entries are filtered using onlyd.IsDir()— no stat needed. d.Info()is called only for entries that pass all filters, i.e. the files we actually care about.
Consequences:
- ~22x speedup on the raw walk, ~3x on the full
IsModifycheck vs the originalWalkimplementation. - 94% fewer allocations per poll.
- Requires Go 1.16+ (
fs.WalkDirFunc).
Status: Accepted
Context:
The original IsModify read .gitignore on every poll call — opening, scanning, and closing the file every 800ms. Benchmarks showed this accounted for 91% of total poll time (~5200 ns out of ~5700 ns per call).
Decision:
Move .gitignore reading out of IsModify into the caller. The watcher caches patterns and invalidates the cache only when .gitignore's ModTime changes.
Reasons:
.gitignoreis rarely edited during a work session.- A single
os.Statcall per poll (~200 ns) is far cheaper than opening and scanning the file (~5000 ns). - Keeping
IsModifyfree of I/O makes it easier to test and benchmark in isolation.
Consequences:
IsModifynow takes[]stringinstead of reading patterns internally — callers are responsible for providing them.- Poll path reduces to ~509 ns/op, identical to the raw directory walk cost.
- If
.gitignoreis edited, changes are picked up on the next poll automatically.
Status: Accepted
Context:
When a file changes, the previous run must be stopped before starting a new one. Sending a signal only to the top-level process leaves child processes (e.g. test binaries spawned by go test ./...) running as orphans.
Decision:
Send SIGINT to the entire process group using a negative PID (syscall.Kill(-pid, SIGINT)). If the process group does not exit within 3 seconds, send SIGKILL. Track process lifetime with a done channel closed in a Wait goroutine started by Start.
Reasons:
- Negative PID targets the process group, ensuring all child processes receive the signal.
SIGINTgives processes a chance to clean up (flush buffers, print summaries) beforeSIGKILLforces termination.- The
donechannel letsKillCommanddetect that the process already exited naturally — skipping the signal entirely and avoiding "operation not permitted" errors when the previous run finished on its own.
Consequences:
- On Unix, processes must be started with
Setpgid: trueso they lead their own process group. - Windows uses
TASKKILL /T /Finstead, which achieves the same tree-kill behaviour. - The 3-second grace period adds latency only in the rare case where a process ignores
SIGINT.
Status: Accepted
Context: ANSI escape codes and Unicode characters improve readability in modern terminals but produce garbage in CI logs, piped output, and dumb terminals.
Decision: Detect terminal capabilities at runtime and fall back to plain ASCII when any of the following are true:
$NO_COLORis set (https://no-color.org)$TERMequalsdumb- stdout is not a TTY (
golang.org/x/term.IsTerminal)
Reasons:
$NO_COLORis a widely adopted convention for user opt-out.$TERM=dumbis set by many CI systems and editors that embed terminals.- TTY check catches pipes and redirects (
re ... | tee log). - All three checks together cover the vast majority of non-interactive environments.
Consequences:
- Adds
golang.org/x/termas a dependency (thin wrapper around OS TTY detection). - The spinner erases its line before the first byte of command output via a shared
sync.Onceacross stdout and stderr wrappers, preventing interleaving regardless of which stream writes first. - Plain ASCII fallback (
|/-\,-,rerun) carries the same information as the fancy version.