Align FolderWatcher with the newly ratified design decisions

The parallel design session resolved the m3 watcher findings; two
changed behavior: .git internals are now filtered at any depth (a
nested clone or submodule is a stray whose internals never render),
and a root change landing mid-bracket is owned by the root-change
path — endBracket() skips its mandatory reload once a .rootChanged
tore the stream down, cleared when a fresh stream attaches. The skip
is scoped by an explicit flag, not by stream absence: a watcher whose
stream never came up still delivers post-bracket reloads, since
distrust of FSEvents delivery is the mandatory reload's whole reason.

Two new watcher tests; full suite 281 tests in 54 suites green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 19:24:11 -04:00
parent 66516d38d1
commit 2ba6989481
2 changed files with 99 additions and 10 deletions
+55
View File
@@ -344,6 +344,34 @@ struct FolderWatcherTests {
#expect(log.events == [.treeChanged(.foreign)])
}
@Test("A repo nested inside a card folder is filtered like the root's own")
func nestedGitChurnIsFiltered() async throws {
let fixture = try WatchFixture()
defer { fixture.tearDown() }
let cardFolder = "1e9a7c5e-0000-4000-8000-000000000000"
fixture.makeDirectory("\(cardFolder)/.git")
let log = EventLog()
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
log.record($0)
}
#expect(watcher.start())
defer { watcher.stop() }
await drainStartupChurn(log)
// A clone living inside a card folder is a stray (01-storage-format.md) whose internals
// never render filtered at any depth, not just the board root's own repo (02, settled).
fixture.write("\(cardFolder)/.git/index", "fake index")
fixture.write("\(cardFolder)/.git/refs/heads/main", "deadbeef")
await quiet(.milliseconds(700))
#expect(log.events.isEmpty)
// The nested repo's *working files* are ordinary stray paths and still fire.
fixture.write("\(cardFolder)/README.md")
await waitUntil { log.count >= 1 }
await quiet()
#expect(log.events == [.treeChanged(.foreign)])
}
// MARK: Root identity
@Test("Deleting the watched root delivers rootChanged and tears the stream down")
@@ -367,6 +395,33 @@ struct FolderWatcherTests {
#expect(!watcher.isWatching)
}
@Test("A root vanish mid-bracket is owned by the root-change path; endBracket() is a no-op")
func rootVanishMidBracketSkipsThePostBracketReload() async throws {
let fixture = try WatchFixture()
defer { fixture.tearDown() }
let log = EventLog()
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
log.record($0)
}
#expect(watcher.start())
defer { watcher.stop() }
await drainStartupChurn(log)
watcher.beginBracket()
fixture.remove()
// `.rootChanged` bypasses the bracket the consumer must know *now*.
await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) }
#expect(log.events == [.rootChanged])
#expect(!watcher.isWatching)
// The bracket's mandatory final reload is skipped: the stream is gone, and the
// root-change path owns recovery from here (02, settled) `reattach(to:)` or the
// vanished-root lock, each ending in a reload of its own.
watcher.endBracket()
await quiet(.milliseconds(700))
#expect(log.events == [.rootChanged])
}
// MARK: Stop
@Test("Nothing is delivered after stop()")