Make the file watcher respect ignore rules — refresh them live, and stop subscribing to ignored trees - #105
Conversation
`is_ignore_rules_file` decides whether a filesystem event should rebuild and republish the matcher in `ServerState::gitignore`. It recognized `.gitignore` at any depth and root-level `p4ignore.ini`, but not `.ignore`. `.ignore` is a first-class ignore source everywhere else: the walk collects it separately, the matcher applies it, and it even outranks `.gitignore`. Unlike `.gitignore` it is not git-gated, so it is the one source that works outside a repository. Because the event never matched, a `.ignore` written while the server was live never scheduled the refresh, and the write then fell through to the reindex path where `should_skip_watcher_path` drops any dot-prefixed segment. Nothing happened at all: the startup matcher stayed published and files under the newly excluded directory kept being indexed until the hourly reconcile or a restart. Match `.ignore` by file name alongside `.gitignore`, using the existing `tgrep_core::gitignore` filename constants. `p4ignore.ini` stays root-scoped, mirroring the walker. Fixes #104 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds live .ignore change detection so the watcher refreshes its matcher and reconciles indexed files.
Changes:
- Recognizes root and nested
.ignorefiles as ignore-rule sources. - Adds unit and end-to-end regression coverage.
- Extracts shared watcher test helpers.
Show a summary per file
| File | Description |
|---|---|
tgrep-cli/src/serve.rs |
Detects .ignore changes and tests classification. |
tgrep-cli/tests/watcher_dot_ignore.rs |
Verifies live matcher refresh and index reconciliation. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Balanced
The watcher already discarded events for ignored paths, but only after the OS had delivered them. On Linux that is too late to matter: inotify has no recursive mode, so notify's `RecursiveMode::Recursive` walks the tree and spends one watch descriptor per directory. A repository whose `target/` or `node_modules/` holds most of its directories therefore burns most of the per-user `fs.inotify.max_user_watches` budget on events that are thrown away -- and because notify propagates the first registration failure, exhausting that budget makes `watch()` return an error and the server loses its watcher entirely. Subscribe per directory on inotify backends instead. `watchable_dirs` walks the tree once, pruning ignored, hidden and `--exclude`d subtrees before descending, and `WatchRegistry::sync` reconciles the live subscription set against it. The sync runs whenever the ignore matcher is published, so relaxing a rule subscribes to the tree it used to hide and tightening one drops it. A directory that cannot be subscribed is now reported and skipped rather than taking down the whole watcher. Non-recursive watches are not extended by notify, so a directory that appears at runtime is picked up in `watch_new_subtree`, which also indexes the files already inside it to close the create race. Windows (ReadDirectoryChangesW) and macOS (FSEvents) subscribe once for the whole subtree, so there is no per-directory registration to withhold; they keep the single recursive watch and delivery-time filtering. The behaviour the two paths must share -- new directories get indexed, new directories under an ignored path do not -- is tested everywhere. Along the way, `state.gitignore` had three publish sites and only one of them went through the helper. They are unified behind `publish_ignore_matcher` so the sync hook cannot be missed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
.ignore file changesThere was a problem hiding this comment.
Review details
Suppressed comments (2)
tgrep-cli/src/serve.rs:1949
- This union treats every path in
registry.watchedas still subscribed, but inotify automatically removes a watch when a directory is deleted and the remove path never removes it from this set. If that directory is recreated before a full sync, it is already present in the union, sosyncdoes not re-register it; this pass indexes files currently inside it, but later writes are invisible. Remove deleted directories (and descendants) from the registry on removal, or force a newly created subtree root to be re-watched.
let union: std::collections::HashSet<PathBuf> =
registry.watched.union(&desired).cloned().collect();
registry.sync(&union);
tgrep-cli/src/serve.rs:1968
- A pre-populated directory moved into the watched tree can contain a nested
.ignoreor.gitignore, but adding an inotify watch does not emit events for existing files. This recovery scan classifies that dotfile as skipped and never marks the ignore rules dirty, then indexes its siblings using the old matcher. Detect ignore-rule files during the scan and schedule the same matcher refresh used by normal watcher events so nested rules take effect.
let rel = rel.to_string_lossy().replace('\\', "/");
let skip = {
let gitignore = state.gitignore.read().unwrap();
should_skip_watcher_path(&rel, &state.exclude_dirs, gitignore.as_ref())
};
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
`watch_new_subtree` merged the new subtree into the live subscription set by cloning both into a union and handing that to `sync`. That is correct but proportional to the whole watched set, and it runs once per directory created at runtime -- so on a repository holding tens of thousands of watched directories, a checkout or a build that creates many directories does quadratic work copying `PathBuf`s. Split the additive half of `sync` into `add_all` and call that instead. It iterates only the new subtree and tests membership, so the cost is proportional to what actually appeared. `sync` keeps its prune-and-add behaviour for the whole-tree case and now shares the same code. The distinction matters beyond performance: passing a subtree to `sync` would treat the entire rest of the repository as stale and unsubscribe from it, so a new folder would silently disable file watching. Added a test that pins both behaviours. Measured against the layout reported by the Office monorepo team -- 40,210 directories, ~200 searchable files, `.git` both hidden and `--exclude`d -- the subscription set is 202 directories, computed in 13.7ms. The walk cost is set by the tree that survives pruning, not the physical tree: the same measurement over 8,458 directories takes 12.6ms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
tgrep-cli/src/serve.rs:1791
- When a watched directory is deleted, inotify drops its descriptor but the path remains in
watched. If that path is later recreated, this check treats the stale entry as an active subscription and skipswatch(), so subsequent changes under the recreated directory are lost. Remove the deleted/moved path and its descendants from the registry (best-effort unwatching them) when processing removal or move-out events.
if self.watched.contains(dir) {
continue;
}
tgrep-cli/src/serve.rs:2125
- This branch is reached for every accepted
Modifyevent whose path is a directory, not just for a directory that appeared. A metadata change such as chmod on a large directory therefore recursively walks the entire subtree and stats every file; repeated directory metadata events can turn ordinary watcher activity into repeated O(tree) scans. Restrict subtree discovery to folder-create and rename-to events.
if PER_DIRECTORY_WATCHES && path.is_dir() {
tgrep-cli/src/serve.rs:1870
- This enumerates the complete tree before any descendant subscriptions are installed. With non-recursive inotify, a directory created after its parent was enumerated but before
add_allwatches that parent produces no observed event and is absent fromdesired, leaving it unwatched until the hourly reconcile. Subscribe to each directory before enumerating it, or perform a post-subscription directory reconciliation that also adds newly discovered directories.
let mut stack = vec![start.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
The watcher fixes on this branch change observable behaviour on Linux -- `tgrep serve` no longer takes an inotify watch per directory -- and the Office monorepo report that corroborated the bug was filed against the bundled 1.0.2. A distinct version is what lets that team tell whether a build contains the fix. Both crates inherit `version.workspace`, so the manifest change is one line; `Cargo.lock` is regenerated with `cargo update --workspace` rather than hand-edited. Note that the third-party `equivalent` crate is also at 1.0.2 and is deliberately untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Review details
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
tgrep-cli/src/serve.rs:1790
watchedcan contain stale entries after a directory is deleted: inotify drops the kernel watch automatically, but remove events never prune this set. If that path is recreated before a full sync observes it absent,add_allskips it here even though there is no OS subscription, so later files under the recreated directory are missed. Remove the deleted path and its descendants from registry bookkeeping on removal, or otherwise force re-registration for recreated directories.
if self.watched.contains(dir) {
continue;
tgrep-cli/tests/watcher_watch_registration.rs:200
- This does not actually wait for descendant subscriptions:
normal_source_markerwas already persisted by the initialindexcommand, so it is searchable even whilegitignore_pendingis true. The subsequently added file can also be picked up by the startup stale reconciliation rather than by an inotify event, allowing an implementation that watches only the root to satisfy both the positive control and the low watch-count assertion. Wait until the expected non-ignored directory watches are present (or expose an explicit matcher/subscription-ready signal) before creating the control file.
// Positive control, and a wait for the watcher to be live: subscriptions
// are deferred until the ignore matcher is published, so counting before
// that would pass for the wrong reason.
assert!(
wait_for_match(port, "normal_source_marker", Duration::from_secs(30)),
"expected the seeded source files to be searchable"
);
tgrep-cli/src/serve.rs:2125
Path::is_dir()follows symlinks, so a directory symlink created at runtime enterswatch_new_subtree. Because that helper always includes and reads its start path, it subscribes through the link and indexes its target, contradicting the walker’s defaultfollow_links = falsebehavior and thewatchable_dirscontract. Checksymlink_metadata(path).file_type().is_dir()so only actual directories are added.
if PER_DIRECTORY_WATCHES && path.is_dir() {
tgrep-cli/src/serve.rs:1985
- This recovery scan does not recognize ignore-rule files. If a newly created subtree is populated with a nested
.ignore/.gitignorebefore its parent creation event is handled, the ignore-file event predates the new subscription; this code then skips that dotfile using the old matcher and indexes sibling content without ever scheduling a matcher refresh. Detect discovered ignore sources and trigger a stale refresh before accepting the subtree's files.
let skip = {
let gitignore = state.gitignore.read().unwrap();
should_skip_watcher_path(&rel, &state.exclude_dirs, gitignore.as_ref())
};
tgrep-cli/src/serve.rs:1916
- The initial subscription sync has a race: it finishes the full directory walk before registering any descendant. On the deferred startup path, the stale walk has already completed, so a file or directory created after its parent was scanned but before
registry.syncsubscribes is neither in that snapshot nor delivered by inotify; it can remain unindexed/unwatched until the hourly reconcile. Register directories while traversing, or run a post-registration metadata reconciliation after all watches are active.
let gitignore = state.gitignore.read().unwrap();
watchable_dirs(root, root, &state.exclude_dirs, gitignore.as_ref())
};
let total = desired.len();
let (added, removed) = registry.sync(&desired);
- Files reviewed: 4/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Subscriptions were established only after the walk that produced the ignore matcher, so a file written to a directory in that window was in neither place: not in the walk's results, and not able to report itself yet. It stayed invisible until the hourly reconcile. sync_watch_registrations now returns the directories it newly subscribed to, and the stale check rechecks them once the merge has settled. It has to be after the merge: stream_merge_stale_changes replaces file_stamps wholesale, so an earlier scan would be discarded and would re-read every changed file on the way. reindex_file compares stamps first, so on a tree that did not move under us this costs one metadata call per file. The two index-build publishes deliberately skip the scan. There "newly watched" is the whole repository, and the stale check that follows startup already does a full walk-versus-index diff, which is a superset. Scanning there would stat the entire tree while holding the gate, on the path a warm start exists to keep fast. watch_new_subtree had two more problems of its own. It read each directory before subscribing to it, leaving the same race one level down for anything created in between; it now subscribes to a level before enumerating it. And a subtree that arrives already populated -- a clone, a mv, a branch switch -- can carry its own .gitignore. Those files are dot-prefixed, so the recovery scan dropped them silently and indexed the rest of the subtree against rules that had never heard of it. It now looks for ignore rules first and defers to a refresh rather than indexing under stale ones. Finally, Path::is_dir follows symlinks. A link to a directory was therefore subscribed to and walked through, indexing a target the walker never descends into and that may sit outside the root entirely. is_real_dir asks the question we actually mean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Review details
Suppressed comments (2)
tgrep-cli/src/serve.rs:1585
publish_ignore_matchercan run before this registry is installed becauserunspawns the stale/build thread first. In that ordering the publish reports no newly watched directories; this branch then adds all descendants but discards them. A write after the matcher-producing walk passed a directory and before this sync is therefore in neither the walk nor an event, and the bootstrap path returns without another stale check. Start the watcher before spawning the producer, or perform the same recovery scan/reconcile for the directories added here.
// The newly watched directories are deliberately not rechecked here.
// This is every directory in the repository, the index was built or
// opened moments ago, and the stale check that follows startup
// reconciles the same drift while holding `snapshot_gate` — which this
// path does not hold and must not take, since the watcher has to be
// receiving events before that check runs.
let _ = sync_watch_registrations(&state, root);
tgrep-cli/src/serve.rs:1993
- This recovery pass silently skips an ignore-rules file because dot-prefixed paths satisfy
should_skip_watcher_path. If a newly visible directory receives.ignore/.gitignoreafter the stale walk visited it but before its new subscription is installed, this is the only recovery scan that can see that write; the matcher remains stale and neighboring files may be indexed under the wrong rules. Detectis_ignore_rules_filehere and schedule a refresh before indexing these directories, aswatch_new_subtreealready does for the equivalent runtime-subtree race.
let skip = {
let gitignore = state.gitignore.read().unwrap();
should_skip_watcher_path(&rel, &state.exclude_dirs, gitignore.as_ref())
};
if !skip {
reindex_file(state, &path, &rel);
}
- Files reviewed: 4/5 changed files
- Comments generated: 1
- Review effort level: Balanced
The kernel releases an inotify watch by itself when its directory is deleted or moved away, and nothing reports that the descriptor is gone. The path stayed in `watched`, so a directory recreated at the same location looked subscribed while receiving no events at all. Nothing downstream could recover it either. `add_all` skipped it as already watched, and so did every later `sync`: the path is in `desired` *and* in `watched`, which is indistinguishable from a live subscription. The entry stayed poisoned for the life of the process, so `rm -rf build && mkdir build`, a branch switch or a `git clean` silently stopped the directory being watched until the server restarted. Two cheap halves. `forget` clears the entry when a removal event arrives, which is a single hash lookup — deleting a tree delivers one event per directory in it, so anything proportional to the whole watched set would make that quadratic. And `watch_new_subtree` now re-issues subscriptions rather than trusting `watched`, since a directory that has just appeared is precisely the case where that belief is worthless; `inotify_add_watch` is idempotent, so re-adding costs a syscall and returns the existing descriptor. The forced path still reports only genuinely new directories, so the recovery scan does not treat a whole subtree as freshly watched. Descendants carried off by a move deliver no events of their own, but the next sync no longer finds them under the root and unsubscribes them there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
tgrep-cli/src/serve.rs:2181
- The loop does not react to
found_ignore_rulesuntil after traversing the entire subtree. A moved-in tree with a root.gitignoreexcluding a largenode_modules/therefore subscribes to every descendant under stale rules before refreshing, potentially exhausting inotify—the failure this change is intended to prevent. Schedule the refresh and return after the current level is enumerated, before descending intonext.
level = next;
tgrep-cli/src/serve.rs:3194
- This discards the recovery set on the assumption that a startup stale check follows, but
background_index_buildreturns immediately whenbootstrap_index_buildsucceeds; no such check runs. A file written after the builder's walk but before these descendant subscriptions is therefore neither in the built index nor observable as an event. Preserve and scan the newly watched directories after loading stamps, or run an immediate stale reconciliation before declaring indexing complete.
let _ = publish_ignore_matcher(state, root, matcher);
tgrep-cli/src/serve.rs:3291
- No stale check follows this publish either: execution indexes the original
walk.filesand later replacesfile_stampsfrom a metadata walk. A file created in a newly subscribed directory after the path walk can be absent fromnew_fileswhile that later metadata walk records it as current; periodic reconciliation does not compare index membership, so it can remain unindexed indefinitely. Carry the returned directories to a recovery scan after final stamp publication, or perform an immediate membership-aware stale check.
let _ = publish_ignore_matcher(state, root, matcher);
tgrep-cli/src/serve.rs:2184
- Files discovered in a newly arrived subtree are sent directly to
reindex_file, which does not enforcestate.max_file_sizeor the binary-extension filter used by both indexing walks. A populated subtree can thus add files that a fresh index would exclude, making the live index diverge immediately. Apply the same file eligibility checks before collecting/reindexing these files, preferably through the shared reindex helper.
for (path, rel) in &files {
reindex_file(state, path, rel);
- Files reviewed: 4/5 changed files
- Comments generated: 3
- Review effort level: Balanced
Three follow-ups from review, all cases where the watcher and the walk could disagree about the index. 1. The directories returned by the three non-stale subscription passes were discarded. The justification comments were wrong: on a warm start the stale check runs on a thread spawned before the watcher, so it can publish while the registry is still empty, making the startup sync the first descendant pass with nothing following it. Files written to a directory between the walk reaching it and the subscription being taken were then invisible until the hourly reconcile. All three sites now hand their directories to spawn_recovery_scan, which waits out `indexing` before looking -- during a cold build the stamps are not yet written and every file would read as changed. 2. watch_new_subtree ran for every directory event, including Modify(Metadata). A recursive chmod or a branch switch re-walked and re-subscribed each subtree once per directory in it. Gated to the kinds that can actually introduce a directory: Create and Modify(Name). 3. reindex_file ignored the walker's binary-extension and max-filesize rules, so a file arriving through the watcher was indexed even when a walk of the same tree would reject it -- and the next reconcile deleted it again. The check now mirrors walk_file_metadata, and drops any entry it already holds when a file stops being eligible. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 3
New issues introduced by this change (3)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — symlink_metadata only refuses a symlink in the final component; it still follows symlinked… |
|
tgrep-cli/src/serve.rs — Existence is not enough to prove this is still an ignore-rule source. If a previously read… |
|
tgrep-cli/src/serve.rs — A matching stamp does not prove this path is actually indexed. The background build derives content… |
Pre-existing issues (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — Size plus mtime does not establish that this is the content the matcher read. An archive/rsync… View comment |
Issues resolved since last review (2)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The removal candidates come only from file_stamps, but the active reader/overlay can contain… View resolved comment |
|
tgrep-cli/src/serve.rs — This time comparison misses edits on filesystems with coarse mtimes. since has subsecond… View resolved comment |
Size and mtime do not establish that a rule file is the one the matcher read. `rsync -a`, `tar -x` and a restore from an archive all preserve mtime, and two different sets of rules are easily the same length -- so the pair is identical across the replacement, and the recovery scan accepts a matcher built from rules that are gone. That is precisely the case this path exists for: a change made while no event was observable. `ignore_source_stamps` now records a hash of each source's bytes, and `changed_ignore_rules_in` compares against it. Never persisted, so the hash only has to be stable within a run. These are rule files -- a few hundred bytes each, already in the page cache from the walk that found them -- so this is not the same proposition as hashing indexed content, where the cost is the whole repository and a wrong stamp costs one stale file rather than a whole subtree indexed under the wrong rules. The digest is taken at publication rather than inside the matcher builder because the `ignore` crate opens these files itself and does not hand back what it read. The mtime window stays, and is what covers that residual gap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
utimes takes microseconds and ext4 stores nanoseconds, so restoring a timestamp read back from the filesystem loses precision and the fixture's own precondition fails. Pin both writes to a whole second instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 4
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The digest is captured after the matcher has already read each source. If an unwatched nested rule… |
Pre-existing issues (4)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — A matching stamp does not prove this path is actually indexed. The background build derives content… View comment |
|
tgrep-cli/src/serve.rs — symlink_metadata only refuses a symlink in the final component; it still follows symlinked… View comment |
|
tgrep-cli/src/serve.rs — Size plus mtime does not establish that this is the content the matcher read. An archive/rsync… View comment |
|
tgrep-cli/src/serve.rs — Existence is not enough to prove this is still an ignore-rule source. If a previously read… View comment |
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 4
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The sweep only considers files whose immediate parent was enumerated. If a whole directory d… |
Pre-existing issues (4)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The digest is captured after the matcher has already read each source. If an unwatched nested rule… View comment |
|
tgrep-cli/src/serve.rs — A matching stamp does not prove this path is actually indexed. The background build derives content… View comment |
|
tgrep-cli/src/serve.rs — symlink_metadata only refuses a symlink in the final component; it still follows symlinked… View comment |
|
tgrep-cli/src/serve.rs — Existence is not enough to prove this is still an ignore-rule source. If a previously read… View comment |
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — Size plus mtime does not establish that this is the content the matcher read. An archive/rsync… View resolved comment |
Four defects the fourteenth review wave found, all in the watcher's trust boundaries. `is_real_dir` only ever inspected the last component, so `root/a/b` qualified while `a` was a symlink to anywhere on the machine. Add `is_contained_dir`, which walks from the served root and requires a real directory at every level, and use it where a path from an event becomes a subscription. `WatchRegistry` now carries the root and short-circuits through its own watched set, so the startup sync still costs one stat per directory rather than one per level. The vanished-source test used `exists`, which a rule file replaced by a directory, a FIFO or a socket passes — while the walker would no longer collect it and the digest check skips it for not being a file. `is_file` is the question that was meant. The background build stamped from a metadata walk taken after the walk that fed the index, so a file created between the two got a stamp with no index entry: `reindex_file` returns early on a matching stamp and the periodic reconcile compares stamps alone, so it would never be searchable. Stamp only what the index holds. The matcher reads its own sources inside the build, so digests taken afterwards describe whatever the replace left behind. Build the matcher inside `publish_ignore_matcher` and digest its sources on both sides; a mismatch publishes the matcher but marks it stale and schedules a refresh. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 2
New issues introduced by this change (2)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — This publishes a post-build metadata stamp even for a path whose watcher event was deferred during… |
|
tgrep-cli/src/serve.rs — desired is a HashSet, so this iteration is not parent-first even though contained relies on… |
Pre-existing issues (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The sweep only considers files whose immediate parent was enumerated. If a whole directory d… View comment |
Issues resolved since last review (4)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — The digest is captured after the matcher has already read each source. If an unwatched nested rule… View resolved comment |
|
tgrep-cli/src/serve.rs — A matching stamp does not prove this path is actually indexed. The background build derives content… View resolved comment |
|
tgrep-cli/src/serve.rs — Existence is not enough to prove this is still an ignore-rule source. If a previously read… View resolved comment |
|
tgrep-cli/src/serve.rs — symlink_metadata only refuses a symlink in the final component; it still follows symlinked… View resolved comment |
Review round 15. A whole directory that goes away leaves indexed files whose own parent was never enumerated. The recovery sweep judged a file only from its immediate parent's listing, so everything under a directory that was deleted or moved away stayed searchable — and no event names those files either: a removal delivers one event for the directory, a move away delivers nothing at all. The scan now records which directories it saw present and which it could not list, derives the ones that are gone from a parent listing that did succeed, and the sweep walks ancestors so descendants go with them. `WatchRegistry::sync` fed `add_all` straight from a `HashSet`, so a child could be subscribed before its parent and the containment check had no watched parent to lean on — one `symlink_metadata` per level per directory instead of one per directory. Sorting by depth restores the fast path. Stamps published by a build described a metadata walk taken after the content walk that fed the index, so a file written in between was stamped as indexed while the index held the older bytes. `reindex_file` returns early on a matching stamp, so the replay of the very event that reported the write read nothing and the reconcile behind it agreed. Those paths are already named by the deferred-event buffer, so their stamps are withheld; when the buffer has overflowed nothing can be told apart from what changed and no stamp from that build is published at all. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — This no-follow check protects only the final component. If a vanished directory is replaced by a… |
Issues resolved since last review (7)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — desired is a HashSet, so this iteration is not parent-first even though contained relies on… View resolved comment |
|
tgrep-cli/src/serve.rs — This publishes a post-build metadata stamp even for a path whose watcher event was deferred during… View resolved comment |
|
tgrep-cli/src/serve.rs — The sweep only considers files whose immediate parent was enumerated. If a whole directory d… View resolved comment |
|
tgrep-cli/src/serve.rs — The digest is captured after the matcher has already read each source. If an unwatched nested rule… View resolved comment |
|
tgrep-cli/src/serve.rs — A matching stamp does not prove this path is actually indexed. The background build derives content… View resolved comment |
|
tgrep-cli/src/serve.rs — Existence is not enough to prove this is still an ignore-rule source. If a previously read… View resolved comment |
|
tgrep-cli/src/serve.rs — symlink_metadata only refuses a symlink in the final component; it still follows symlinked… View resolved comment |
Review round 16. The sweep asks whether a candidate is back on disk before dropping it, and asked with `symlink_metadata`, which refuses to follow only the *final* component. A directory that vanished and returned as a link to another tree then makes `root/gone-dir/a.rs` resolve to an ordinary file outside the root, the recheck reads that as "it came back", and the stale in-root entry is kept — permanently, since nothing under a link is walked or watched and no later event names it. Rechecked through `open_within_root` instead, which is the contract `reindex_file` opens under: every ancestor is resolved without following a link. Errors are classified with the existing `proves_ineligible`, so a descriptor limit or a sharing violation preserves the entry for the next reconcile rather than evicting live content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: None
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — This no-follow check protects only the final component. If a vanished directory is replaced by a… View resolved comment |
Five defects, all in how the watcher decides what to look at. 1. An ignore-rules refresh could race a resumed build. `background_index_build` publishes its matcher part-way through Phase 2 while holding no gate, so the refresh it schedules took `snapshot_gate` uncontended and replaced `file_stamps` from its own walk, only for the build to overwrite them from a walk that predates the new rules. The refresh worker now waits out `indexing`, mirroring the recovery scan. 2. The point-query matcher omitted git's `core.ignorecase` narrowing that both walks apply as a `filter_entry`, so the watcher subscribed to and indexed exactly the trees the walk excluded — and the next stale check evicted them again. `IgnoreMatcher` now carries the same `CaseInsensitiveIgnore`, applied where the walk applies it. 3. Overflow left dead watches recorded as live. A native drop (`IN_Q_OVERFLOW`, a lost `ReadDirectoryChangesW` buffer) only logged and triggered no reconcile at all, and after any overflow the dropped removal events left the registry recording watches the kernel had released — which every later sync skipped as already present. The error branch now reconciles too, and sets a flag one sync consumes to re-issue subscriptions once. 4. A populated directory moved in from outside the root stayed unindexed on Windows and macOS: the enumeration was gated on per-directory watches, but a recursive backend reports the move as a single event and never describes the contents. The indexing half now runs everywhere; only subscribing stays per-directory. 5. Ignore-source tracking omitted parent rule files and the repository exclude, and resolved the exclude at a literal `.git/info/exclude` — wrong in a linked worktree, where `WalkBuilder` follows the gitdir/commondir chain and this did not. Both are now tracked and resolved the way the walk resolves them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 1
New issues introduced by this change (3)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — Path::exists() maps metadata errors to false, so a transient EACCES or sharing error is… |
|
tgrep-core/src/gitignore.rs — CaseInsensitiveIgnore is now retained inside the long-lived watcher matcher, but its OnceLock… |
|
tgrep-cli/src/serve.rs — Recording an in-root symlink target does not guarantee that its edits are observable. For example,… |
1. A transient stat failure was read as a deletion. `Path::exists` and `Path::is_file` fold every metadata error into `false`, so one `EACCES` or a Windows sharing violation from a build holding a file open made the watcher tombstone content that was still perfectly valid — and it did so before `reindex_file`, whose whole policy is to preserve entries through exactly those errors, was ever called. The event path now classifies its stat through the same `proves_ineligible` contract the recovery sweep answers to, with an explicit "this stat proves nothing" case that leaves the index alone. 2. The tracked-file exemption froze at the first path it was asked about. That was sound while the matcher was a walk-local object, but round 17 put it inside the watcher's long-lived matcher, and there a `git add -f` or a `git rm --cached` rewrites only `.git/index` — hidden, so no ignore source changes and nothing republishes. The exemption kept answering from the set as it stood at startup and diverged from a fresh walk until the hourly reconcile. It is now reloaded when the index it was read from changes, at the cost of one stat on the paths that reach it, which for most repositories is none. 3. A rule file symlinked into a directory the rules hide was unobservable. `handle_fs_event` already recognises an event naming a symlinked source's target rather than a name rules usually go by, and its comment claimed such targets are watched "because only those are watched". On a per-directory backend they were not: nothing subscribes to `build/` when `build/` is ignored, so editing the file the matcher was built from produced no event at all. Their directory is now subscribed to explicitly — the file, not its subtree, and only for targets inside the root. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bcc85b55-e821-447e-b9a9-144b78829ae4
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: None
Issues resolved since last review (3)
| Severity | Finding |
|---|---|
tgrep-cli/src/serve.rs — Recording an in-root symlink target does not guarantee that its edits are observable. For example,… View resolved comment |
|
tgrep-core/src/gitignore.rs — CaseInsensitiveIgnore is now retained inside the long-lived watcher matcher, but its OnceLock… View resolved comment |
|
tgrep-cli/src/serve.rs — Path::exists() maps metadata errors to false, so a transient EACCES or sharing error is… View resolved comment |
Suppressed comments (1)
tgrep-core/src/gitignore.rs:563
- Reloading the tracked-file cache on demand does not make
git add -forgit rm --cachedlive. Those operations modify only.git/index, which the watcher filters (and does not subscribe to on Linux), so no call toexcludesoccurs for the affected source path and the active index remains stale until periodic reconciliation. The watcher must observe Git index identity changes and schedule a refresh/reindex; the point-query unit test only proves that a later query would reload the cache.
let identity = index_identity(&self.repo_root);
{
let cache = self.tracked.read().unwrap();
if cache.loaded_from.as_ref() == Some(&identity) {
return Self::hides(cache.tracked.as_ref(), relative, is_dir);


Fixes #104.
tgrep serve's watcher was ignoring ignore rules in two different ways. Both are addressed here.1. A
.ignorefile written while the server was live had no effectis_ignore_rules_file— the predicate that decides whether a change should rebuild the ignore matcher — matched.gitignoreat any depth and a rootp4ignore.ini, but not.ignore. So writing a.ignorenever setignore_rules_dirtyand never scheduledschedule_ignore_rules_refresh. The write then fell through to the ordinary reindex path, whereshould_skip_watcher_pathdrops anything with a dot-prefixed segment — so nothing happened at all. The rules only took effect on the next server restart.The fix is one predicate, using the filename constants
tgrep-corealready exports so the watcher and the walker cannot drift apart again.2. The watcher subscribed to ignored directories and filtered on delivery
Events for ignored paths were discarded, but only after the OS had delivered them. On Linux that is too late to matter:
RecursiveMode::Recursivemakes notify walk the tree and spend one watch descriptor per directory — which is exactly the 1:1 ratio the field report measured.target/,node_modules/orout/holds most of its directories burns most of the per-userfs.inotify.max_user_watchesbudget on events that are immediately thrown away.?. A repo large enough to exhaust that budget makeswatcher.watch()return an error, and the server loses its watcher entirely — silently falling back to the hourly reconcile. At 40,212 watches against a common default of 65,536 shared across every process on the machine, that ceiling is close.--no-watch"started immediately".So on inotify backends the watcher now subscribes per directory:
watchable_dirs(root, start, ..)walks once and prunes ignored / hidden /--excluded subtrees before descending.rootandstartare separate because ignore rules are anchored at the repository root, so a subtree discovered at runtime still has to be matched against root-relative paths.WatchRegistry::syncreconciles the live subscription set against that, on every ignore-matcher publish, so relaxing a rule subscribes to the tree it used to hide and tightening one drops it.WatchRegistry::add_allis the additive half, used for subtrees that appear at runtime. Keeping it separate is both a performance and a correctness matter: it is proportional to the new subtree rather than the whole watched set, and passing a subtree tosyncwould treat the entire rest of the repo as stale and unsubscribe from it.The initial descendant sync is deferred until the ignore matcher is published. Events are dropped while
gitignore_pendingis set anyway, and subscribing first and narrowing afterwards would mean briefly holding exactly the watches this is meant to avoid.state.gitignorehad three publish sites and only one went through the helper; they are unified behindpublish_ignore_matcherso the sync hook cannot be missed.Measured against the reported layout
Reproducing the report's shape — 40,210 directories, ~200 searchable files,
.gitboth hidden and--excluded:The walk cost is set by the tree that survives pruning, not the physical tree: the same measurement over 8,458 directories takes 12.6 ms. Pruning happens before descent, so the ignored subtrees are never entered.
The reported
.gitsymptom is covered twice over — it is skipped as a hidden directory and again by--exclude, both asserted inwatchable_dirs_prunes_ignored_and_hidden_subtrees.Scope caveat, stated plainly: this reduces the watch count in proportion to how much of the tree is pruned by directory-level ignore rules. A repo whose directories are mostly tracked but whose files are excluded by file patterns would still be watched, and correctly so — a new source file can appear in any of those directories. The
[trace] watcher subscriptions: N directoriesline reports the real number so this is verifiable on any repo rather than assumed.A pre-existing Linux bug this also fixes
The control run turned up something I was not looking for: on Linux, a file created in a directory that did not exist when the server started was never indexed. Recursive inotify registration is racy by construction — notify only adds a watch for
src/fresh/when it receives that directory's creation event, sosrc/fresh/deeper/new.rs, written moments later, lands before the watch exists and its event is lost until the next hourly reconcile.watch_new_subtreecloses this by indexing what it finds as it subscribes.Platform scope
Part 1 applies to every platform.
Testing
Unit:
identifies_live_ignore_rule_changesextended to cover.ignore.watchable_dirs_prunes_ignored_and_hidden_subtrees— covers both reported.gitpaths (hidden and--excluded) plus a gitignored subtree.watchable_dirs_without_a_matcher_keeps_everything_visible—--no-ignoremust not silently narrow the watch set below what gets indexed.watchable_dirs_anchors_rules_at_the_root_not_the_start_directory— regression for the root/start distinction; caught a real bug while writing it.watch_registry_add_all_is_additive_but_sync_prunes— pins the distinction whose confusion would unsubscribe a whole repo.skip_watcher_dir_applies_directory_semantics— abuild/rule does not match the pathbuildunder file semantics, which is why the directory check is separate.End-to-end:
late_dot_ignore_refreshes_the_watchers_ignore_rules.watcher_does_not_subscribe_to_gitignored_directories(Linux) — counts the server's real inotify watch descriptors via/proc/<pid>/fdinfo, the same way the field report measured. It first proves the watcher is live by indexing a newly created file, so a watcher that registered nothing could not pass by accident.watcher_indexes_files_in_directories_created_after_startup(all platforms) — a new nested directory gets indexed, a new directory under a gitignored path does not.Both fixes were checked against a control rather than assumed to work:
PER_DIRECTORY_WATCHESforced tofalse(the old behaviour) was run through CI on ubuntu.watcher_does_not_subscribe_to_gitignored_directoriesfailed with "watcher holds 69 inotify watches ... expected at most 12", andwatcher_indexes_files_in_directories_created_after_startupfailed too — which is how the pre-existing race above was found. That branch has been deleted.cargo test --workspace,cargo clippy --all-targets -- -D warningsandcargo fmt --all --checkare clean; CI is green on ubuntu, macOS and Windows.Review round
Three races the review caught, all fixed in
6ddba29.Watches were established after the walk they were derived from. A file written to a directory between the matcher-producing walk and its subscription was in neither the walk's results nor any event, and
RECONCILE_INTERVALis 3600s, so it stayed invisible for an hour.sync_watch_registrationsnow returns the directories it newly subscribed to;publish_ignore_matcherpasses them through (#[must_use], so a future call site cannot quietly drop them) and the stale check rechecks them.Placement matters:
stream_merge_stale_changesreplacesfile_stampswholesale, so the scan has to run after the merge or it would be discarded and would re-read every stale file on the way.background_refresh_staleis now a thin wrapper holdingstale_refresh_lock+snapshot_gateacrossrefresh_stale_lockedand then the recovery, and only scans when the inner call succeeded. The two index-build publishes skip it deliberately — there "newly watched" is the whole repo and the startup stale check already does a superset.watch_new_subtreeread each directory before subscribing to it, leaving the same race one level down. It now subscribes to a level before enumerating it, releasing the registry lock before the I/O.A subtree that arrives whole can carry its own ignore rules. A clone, a
git mv, a branch switch or an unpacked archive all land that way. Those files are dot-prefixed, so the recovery scan dropped them silently and indexed the rest against rules that had never heard of the subtree. It now looks for ignore rules first and defers to a refresh instead of indexing under stale ones.Path::is_dirfollows symlinks. A link to a directory was subscribed to and walked through, indexing a target the walker never descends into and that can sit outsideroot.is_real_dirusessymlink_metadataand is applied at thehandle_fs_eventcall site and insidewatch_new_subtree.Verified against a control again rather than assumed. A throwaway branch with only the ignore-rule detection disabled failed on ubuntu with exactly the intended assertion — "watcher indexed a file excluded by a .gitignore that arrived inside the same subtree" — confirming the new e2e test is not vacuous on the one platform that exercises per-directory watches. That branch has been deleted.
New coverage:
watcher_honors_ignore_rules_inside_a_subtree_that_arrives_whole(e2e) andis_real_dir_rejects_a_symlink_to_a_directory(unit,#[cfg(unix)], and it assertslink.is_dir()istrueso the test documents the trap rather than just the fix).A directory removed and recreated stopped being watched, permanently. The kernel releases an inotify watch when its directory goes away and reports nothing, so the path stayed in
watchedwith no descriptor behind it. No latersynccould recover it either — the recreated path is indesiredand inwatched, which is indistinguishable from a live subscription — sorm -rf build && mkdir build, a branch switch or agit cleansilently stopped that directory being watched for the life of the process. Fixed in2907e1bwith two cheap halves:forgetclears the entry on a removal event (one hash lookup, not a prefix sweep — deleting a tree delivers one event per directory, so anything proportional to the watched set would be quadratic on arm -rf), andwatch_new_subtreere-issues subscriptions rather than trustingwatched, since a directory that has just appeared is exactly where that belief is worthless. The second half covers what the first cannot: amvaway delivers no events for the descendants it carries off.Controlled again: with both halves disabled, ubuntu fails the new unit test, and — once that is skipped so the run continues — the new e2e test
watcher_rewatches_a_directory_that_is_removed_and_recreatedfails with "watcher never saw a file written to a directory that was removed and recreated". It writes its file three seconds after the recreation is processed, so the subscription pass's own scan cannot mask a missing watch. Branch deleted.The recovery scan was still discarded at three of the four sites. My justification comments for that were wrong, which the review caught. On a warm start the stale check runs on a thread spawned before
start_file_watcher, so it can publish whilewatch_registryis stillNoneand take no subscriptions at all; its walk is then already over by the time the startup sync runs, making that sync the first descendant pass in the process with nothing following it until the hourly reconcile. All three sites now go throughspawn_recovery_scan. It runs on a thread and waits outindexing, becausebackground_index_buildpublishes early in the build while the stamps are only written at the end — scanning at the publish point would read every file as changed and duplicate the whole build.watch_new_subtreeran for every directory event, includingModify(Metadata). A recursivechmodor a branch switch over 40k directories re-walked and re-subscribed each subtree once per directory in it. Gated toCreateandModify(Name), the only kinds that can introduce a directory.The watcher ignored the walker's per-file rules.
should_skip_watcher_pathfilters by location only, so a binary extension or a file over--max-filesizewas indexed when it arrived through the watcher even though a walk of the same tree rejects it — and the next reconcile then deleted it again.reindex_filenow applies the same two rules aswalk_file_metadata, and additionally drops an entry it already holds when a file stops being eligible (a file can grow past the cap, and the smaller version's trigrams would otherwise keep being served).New coverage:
watcher_applies_the_same_file_eligibility_rules_as_the_walkerasserts both rejections next to an eligible file written at the same moment, so it cannot pass against a watcher that has simply stopped indexing. Confirmed decisive — short-circuiting the eligibility check totruefails it onbinary_extension_marker. CI green on ubuntu, macOS and Windows.Validated against a real enlistment
Run against Microsoft's Substrate monorepo (290,016 indexed files) with an A/B control: this branch (
329ec66) and its merge-base withmain(86a000c), back to back on the same machine, each against its own copy of the prebuilt 2.8 GiB index.The reported bug reproduces there, and is fixed. With a file already indexed and an ignore rule then written over it, the control leaves it searchable indefinitely while
.gitignore— the control half, always recognised — drops correctly:86a000c329ec66.ignore.gitignoreThe round-3 eligibility fix matters on real data too. A 65 MiB file written into a watched tree is indexed by the merge-base — its trace log shows
reindex: modified .../big.cs— even though a walk of the same tree rejects it, so the next reconcile would silently delete it again. This branch skips it, as it does a.png.Worth noting for reviewers:
PER_DIRECTORY_WATCHESiscfg!(linux), so the selective registration,forget-on-removal and event-kind gate are inert on Windows and that run cannot exercise them. It covers the two platform-independent fixes and rules out a regression.No regression. Median visibility latency across 1/10/100/1000-file batches ranges from −17% to +4% versus the control; startup is 385.9 ms vs 405.4 ms and the stale check 2659 ms vs 2733 ms over the same 290,016 files. The 5,000-file branch-switch burst is identical on correctness — 5000/5000 observed, 0 residual, 0 queue overflows — and peak memory is lower (633 MiB vs 714 MiB).
One figure initially looked like a +24% regression in burst deletion (20.7 s vs 16.7 s). It is variance: re-running the same binary gave 15.1 s, so that build alone spans 15.1–20.7 s and the control sits inside its range. That matches the code, since the deletion path is byte-identical on Windows — the only change to it,
WatchRegistry::forget, is behindPER_DIRECTORY_WATCHES.An earlier comparison against the suite's stored baseline appeared to show a uniform +25–32% regression. That baseline was recorded three days earlier from a different worktree and its measurement floor was ~100 ms lower, which accounted for the whole difference; it is why the merge-base was built and measured instead.
Review round 4 — five reported issues, all confirmed and fixed
All five were real. Fixed in
371bc77; CI green on ubuntu / macOS / Windows.1. Symlink escape (high).
reindex_fileusedstd::fs::metadata, which follows links, and then read through the link. The indexer walks withfollow_links(false), where a symlink is neither file nor dir and is skipped, so a link inside the repository had its target's bytes indexed under the link's own path — and the target need not be under the served root. Round 1 fixed this for directories (is_real_dir); the file case one line below was missed. Nowsymlink_metadata, withis_fileon the link's own metadata as an eligibility rule, so a link falls into the branch that drops whatever was indexed at that path before.Control experiment: on a scratch branch with only this hunk reverted, the new test fails on both ubuntu and macOS with
the watcher followed a symlink and indexed content from outside the served root; everything else passes.2. Recovery scan gaps.
reindex_files_inlooked only at files directly inside each newly watched directory. It now also:read_dirsucceeded are swept, and the "still there" set is built from every entry regardless of ignore rules or eligibility, so a file indexed under a laxer configuration is never deleted for being ineligible now;3. Stamp publication race.
background_index_buildassignedstate.file_stampsafter dropping the publish gate but clearedindexingbefore it. The recovery scan waits onindexing, then on the gate, so it ran in that window against empty stamps: every file read as changed and the whole repository was re-read — exactly what the wait exists to prevent — and the assignment then discarded the stamps the scan had just recorded. Stamps are now published inside the gate before the flag flips, and the flush takes a read guard rather than a clone (verified: nothing on the flush path touchesfile_stamps).4. Over-subscription of ignored moved trees.
watch_new_subtreerecorded an ignore-rules file and kept descending, taking a watch descriptor per level of a tree the rules it was about to publish would exclude — the moved-innode_modulescase this PR exists to fix. It now abandons the descent at the point of discovery; the refresh redoes the traversal properly.5. Stamp-dependent drop. The "no longer eligible" branch only deleted when a stamp entry existed. It now also consults the live overlay. Deliberately not unconditional the way the removal branch is: removals are rare, but this runs for every ineligible file a recovery scan walks past, and
delete_filerecords a tombstone and dirties the overlay even for a path that was never indexed.Coverage
watcher_does_not_index_through_symlinks(unix) — a link to a file outside the root is not indexed, and a real file replaced by a link loses its indexed content.watcher_applies_the_same_file_eligibility_rules_as_the_walkerextended with a file that grows past--max-filesizeafter being indexed.Note on platform
Issues 2, 3 and 4 are on Linux-only code paths —
sync_watch_registrationsandspawn_recovery_scanreturn early unlessPER_DIRECTORY_WATCHES. The Substrate run reported above was on Windows and could not have exercised them.Review round 5
Four more findings, all valid, all fixed in
700e64e.The eligibility check and the read were two different lookups.
symlink_metadata(path)followed bystd::fs::read(path)resolves the name twice, and a tree being rewritten underneath the watcher — a checkout, a build, agit mv— can put a symlink there in between. The bytes indexed were then not the ones judged eligible, which is the same escape round 4 closed, through a smaller window. There is now one handle:open_no_followopens the path itself (O_NOFOLLOWon unix; the reparse point on Windows, where theis_filecheck on the handle's metadata is what rejects it), and the type, size, mtime and contents all come off it.The ineligible-file drop was conditional on evidence that can go missing.
ServerStateaccepts an empty stamp map whenfilestamps.jsonis missing or unreadable, and the reader can still hold the path in that state, sohad_stamp || in_overlaycould be false for something that is very much still searchable. The delete is now unconditional. The reason it was not before is real —live::delete_filerecords a tombstone and dirties the overlay even for a path that was never indexed, and this runs for every binary asset a startup scan walks past — so an existing tombstone is now taken as proof there is nothing left to do, which bounds that to one per distinct path.sincecame from the wrong walk. It was the moment the subscription sync started, but the window that needs closing opens when the walk that produced the matcher starts. A nested.ignorewritten between the two carries an mtime that predates the later timestamp, so the recovery scan reads it as already accounted for and its subtree stays indexed under rules that never saw it.publish_ignore_matchernow takessincefrom the caller, and both build paths capture it before their traversal.Events discarded during the initial build were simply lost. They cannot be applied while
indexingis set — the stamps do not describe the index yet, so every path would compare as changed — but they are the only record that those paths moved, and the build's own walk misses anything written to a directory it has already passed. They are now buffered and replayed once the build publishes, as synthetic create events so they go through exactly the filtering an ordinary event gets. The buffer is capped at 100k paths and gives up as a whole on overflow (a truncated set is indistinguishable from a complete one at replay time), falling back to a full reconcile. Note this is the first recovery of any kind on Windows and macOS, where there are no per-directory subscriptions and the previous scan returned immediately.The root was never rechecked. It is subscribed as the watcher starts, before any matcher exists, so every later sync sees it as already watched and it never appears in the newly-watched list. Nothing covered a file written to the top level while a build walk was deeper in the tree. It now costs one directory listing.
Verification
cargo fmt/cargo clippy --all-targets -D warnings/cargo test --workspaceclean; CI green on ubuntu, macOS and Windows.Four new tests, confirmed to run on Linux rather than silently skipped:
open_no_follow_reads_a_regular_file— the metadata and the bytes come off one handle.open_no_follow_refuses_a_symlink(unix) — the link does not open as its target.deferring_more_changes_than_the_cap_gives_up_on_the_whole_set— overflow marks the buffer unusable rather than truncating it, and stays that way.changes_deferred_during_a_build_are_applied_once_it_publishes— an event during a build touches nothing, and the change lands after.Control experiment: a scratch branch with only the deferral call removed, run through CI on a draft PR, to confirm the last of those fails without the fix.
Review round 6
Two more, both valid, fixed in
f9b9579.Refusing to follow the final component is not enough.
O_NOFOLLOWandFILE_FLAG_OPEN_REPARSE_POINTguard the file being opened and nothing above it, so a path that arrives from an event or a replay asroot/a/filestill resolves throughaifais a link — and the file at the end of that is a perfectly ordinary file, outside the tree we serve. Same escape as rounds 4 and 5, one level up.open_within_rootnow resolves the path a component at a time starting from the root, which is the trust anchor (a link there is the user's to have). On unix that isopenatagainst the parent's handle withO_NOFOLLOWon every component andO_DIRECTORYon the intermediates — the name is never re-resolved, so this is race-free, not merely checked. On Windows there is noopenat; each ancestor is checked by path for a reparse point (is_symlinkcovers junctions, since both are name-surrogate reparse tags) and the final component opened as before. That rejects a link that is actually there but not one substituted mid-flight; closing that needsNtCreateFilewith aRootDirectory, which is a lot of unsafe code for a platform where creating a symlink requires a privilege the machine does not grant by default. Non-literal components (.., prefixes) are refused rather than interpreted.Two indexers could race on one path.
snapshot_gateis held for read by everything that indexes a file, so a recovery scan and the watcher worker can both be insidereindex_filefor the same path, both see the same old stamp, both read — and the one that read the older content can commit last. The newer event is already consumed, so the stale version survives until the next reconcile. A newreindex_lockmakes the whole check-read-commit cycle atomic. Taken per file rather than per scan, so a recovery pass and the watcher interleave instead of one waiting out the other, and searches never take it.Also opened with
O_NONBLOCKwhile I was there, so a fifo in the tree answers immediately instead of blocking the watcher thread until someone opens the write end.Verification
CI green on ubuntu, macOS and Windows. Four new tests, confirmed to run on Linux:
open_within_root_reads_a_regular_fileopen_within_root_refuses_a_symlinked_file(unix)open_within_root_refuses_a_symlinked_ancestor(unix) — asserts first thatFile::openon that path does succeed, so the test is about containment rather than about the file being unreachableopen_within_root_refuses_paths_that_escape_or_are_not_literalReview round 7
Five more, all the same shape: a decision made on evidence that does not support it.
Replay reconstructed the wrong event kind. Deferred paths were replayed as
Createregardless of what actually happened, which is whathandle_fs_eventuses to decide whether to walk a new subtree. A recursivechmodor a checkout during the initial build therefore put every directory throughwatch_new_subtree— quadratic, and on Linux a watch descriptor per level of trees that should never have been entered. The buffer now carries a flag per path (Create/ rename vs. everything else) and the replay reconstructs from it. Covered bya_deferred_metadata_change_does_not_replay_as_a_subtree_arrival.The deferral handoff was not synchronized.
handle_fs_eventcheckedindexing, then called intodefer_events_during_build, which inserted without re-checking. Between the two the build could finish and the replay swap the buffer out, leaving the event in a set nothing would ever look at again.indexingis now re-read under the buffer lock, and the function reports back so the caller handles the event normally when the build ended underneath it. That is what makes the handoff provable: the replay cannot swap untilindexingis false, and cannot swap without that lock, so seeing it set while holding the lock proves the swap has not happened.Any failure to open was treated as proof of ineligibility.
reindex_filedropped the indexed entry wheneveropen_within_rootreturned an error — includingEACCES, a Windows sharing violation, or a descriptor limit, none of which say anything about whether the file belongs in the index. A build holding a file open for a moment was enough to evict live content, and it contradicted the stale path, which deliberately keeps unreadable files and retries them later. Only errors that establish something structural —NotFound,NotADirectory, our ownInvalidInput, and unixELOOP— drop the entry now. Covered byan_unreadable_file_keeps_its_indexed_content.Recovery could see an ignore file arrive but not one leave. The mtime heuristic finds a
.gitignorewritten during the window; a deleted one leaves nothing to stat, so the matcher kept enforcing rules whose source was gone and the subtree they hid stayed unsubscribed and unindexed until an unrelated rebuild happened along — the more damaging direction of the two.publish_ignore_matchernow records the sources it built from (including rootp4ignore.ini, which is a separate walker filter but invalidates the rules the same way), and a scan checks them directly at one stat apiece, once per scan rather than once per file. Covered bya_recovery_scan_notices_an_ignore_file_that_was_deleted.Windows containment was still check-then-open. Round 6 walked the ancestors with
symlink_metadatabefore opening, which rejects a junction that happens to be there but not one substituted between the check and the open. The file is now opened without following a final reparse point and the handle is asked where it ended up, viaGetFinalPathNameByHandleW, compared against the canonicalized root. There is no second lookup to race. The per-ancestor stat walk goes away with it, so this is also one open plus one query instead of depth-many stats.Control experiment
Reverting all three testable hunks on a scratch branch —
proves_ineligibleforced true, the replay kind forced toCreate, the vanished-source check disabled — failed exactly the three new tests and nothing else, on all three platforms. Restoring them passes.Review round 8
Three more, all variations on concluding something from evidence that does not support it.
read_dir''s per-entry errors were flattened away, and the directory was then recorded as swept. A name that failed to yield is simply missing frompresent, sosweep_removed_filesread it as a deletion and tombstoned a file it had no reason to believe was gone. This was already handled correctly one level up — a failed listingcontinues without claiming the directory — and one level down, where an unclassifiablefile_type()is inserted intopresentprecisely so absence cannot be concluded. The per-entry case now matches: the directory joinssweptonly if it enumerated cleanly. Covered bya_directory_that_was_not_fully_enumerated_is_not_swept.An indexed file replaced in place by a non-file kept its contents searchable. A
mvof a fifo, a socket, or a symlink-to-directory overx.rsis not a removal —path.exists()is still true, and inotify may report only the rename destination — sois_removemisses it. It is not a regular file either, so the branch below returned after considering only whether to subscribe to a subtree, and the oldx.rsstayed in the index indefinitely. Classification is now explicit: a real directory gets the subtree handling, and everything else drops what was indexed under that path. Covered bya_file_replaced_by_a_fifo_loses_its_indexed_content.notify registers inotify watches without
IN_DONT_FOLLOW. The descriptor therefore lands on whatever the name resolves to at registration time, not on the directory the walk validated earlier, so a checkout or rename in between could leave a descriptor watching an inode outside the root while the registry recorded the in-root name as covered — and, if the name were swapped back, the real directory would be considered subscribed while its events went elsewhere. The registration is now re-checked no-follow and undone on a mismatch, so a poisoned entry is retried by the nextsyncinstead of being trusted.This narrows the window rather than closing it, and the PR does not claim otherwise: notify takes a path rather than a handle, so a swap reverted before the check cannot be detected through its API. What remains is bounded — missed events on a real directory, which the periodic reconcile picks up — and never misplaced content, since
open_within_rootestablishes containment from the handle it actually reads, whatever a watch descriptor happens to point at.Control experiment
Reverting both hunks failed exactly one test —
a_file_replaced_by_a_fifo_loses_its_indexed_content— and nothing else. That is the honest result, and it corrected a claim: the sweep test exercisessweep_removed_filesdirectly, so it pins the invariant the fix depends on (a directory absent fromswepttombstones nothing) rather than the wiring that withholds it. A per-entryreaddirfailure cannot be induced portably, so that half has no test and the doc comment now says so. The watch-registration re-check is likewise untested: it guards a race that cannot be staged deterministically.Review round 9
Two more races in the reconcile path, both real.
The sweep deleted on stale evidence.
sweep_removed_filesacted on adirectory listing taken earlier in the scan, and took neither
reindex_locknor a second look at the filesystem. A file recreated in between has already
had its create event consumed by the watcher, so deleting it here dropped it
until the next reconcile with nothing left to replay. Each candidate is now
rechecked under
reindex_lock-- the lock is what makes the recheck meananything, since without it the file could be reindexed between the check and
the delete. The recheck uses
symlink_metadata, so a path that came back as asymlink still stays swept. The trace line now counts what was actually dropped.
The content read was unbounded. The size that qualified a file was stat'd
before its contents were read, and appending in between is what a log or a
build artifact does -- so a file could be pulled into memory whole and indexed
past
--max-filesize. Extractedread_within_limit, which reads at most onebyte past the cap and reports
TooLargewhen that byte is there;reindex_filethen drops what the index holds rather than committing oversized content.
Tests:
the_sweep_does_not_delete_a_file_that_came_backanda_read_stops_one_byte_past_the_cap(which also covers the uncapped, under-capand exactly-at-cap paths).
Control (draft PR #116, since closed): with both hunks reverted, exactly
those two tests failed and nothing else. That control also showed
a_file_that_outgrows_the_cap_loses_its_indexed_contentstill passing -- itpins the eligibility gate that catches growth between visits, not the new
bound, so it was renamed
a_file_that_outgrew_the_cap_between_visits_loses_its_indexed_content.Review round 10
Three defects, all valid, plus a fourth the fix for the second one uncovered.
A removal could be undone by an in-flight reindex. The watcher's removal
branches mutated the index without
reindex_lock, so areindex_filealreadyholding a file's bytes could commit them after the delete -- resurrecting a
file that is gone, with a fresh stamp, so nothing afterwards disagrees and no
further event is coming to correct it. Both branches now take the lock. It stays
the caller's rather than
drop_indexed_file's becausereindex_filecalls inwhile already holding it and a
Mutexis not reentrant; the three existingsites moved onto a shared
lock_reindexhelper.Ignore-file arrival was detected by mtime alone.
git checkout,tar -xand
rsync -aall restore mtimes from what they unpack, so a nested.gitignorecan arrive dated months ago and sail straight past a recency test.Absence from
ignore_sourcesis the exact question instead -- this file did notfeed the published matcher -- and the mtime window stays for what the source
list cannot answer: an existing source that has just been edited.
Symlinked rule files were invisible.
DirEntry::file_typedoes not followlinks, but the walker collects rule files with
Path::is_file, which does. Asymlinked
.gitignoretherefore contributed rules that both the recovery scanand the new-subtree descent were blind to.
And the ordering defect the macOS runner then caught. With the first version
of the fix, two of the new tests passed on Linux and failed on macOS:
read_dirpromises no ordering, and on macOS
.gitignoreroutinely comes back after itssiblings, so a per-entry check indexes part of a directory under the stale rules
before reaching the file that changes them -- and across the scan, rules in a
later directory arrive after earlier ones were already indexed. Replaced the
per-entry check with
changed_ignore_rules_in, which answers for the wholescan up front by probing for rule files by name. That is how the walker finds
them, so the two agree by construction; it is ordering-independent; and
Path::is_filefollows links, which subsumes the symlink fix.Tests:
a_removal_waits_for_an_in_flight_reindex,an_arriving_ignore_file_with_a_preserved_mtime_still_refreshes_the_matcher,a_symlinked_ignore_file_is_still_seen_by_a_recovery_scan,a_scan_checks_every_directory_for_rules_before_indexing_any_file(which putsthe rules in a directory later in the scan than the file, so it pins the
ordering on every platform rather than relying on macOS
readdirorder).Control (draft PR #117, since closed): with the lock removed, the
known-source test dropped,
is_fileswapped for a no-follow check, and therules probe moved back inside the per-directory loop, exactly those four tests
failed -- 120 passed, 4 failed.
Review round 11
Three threads, all valid, all the same shape: a pathname was being treated as evidence about contents.
Symlinked rule-file targets were not watched.
ignore_files_incollects withPath::is_file, which follows links, so a.gitignoresymlinked toshared-rulescontributes the target's rules. Editing the target produces an event namingshared-rules— a basenameis_ignore_rules_filehas no opinion about — and touches nothing whose name it does recognise, so no refresh was scheduled and the matcher stayed stale until the hourly reconcile.A pathname is not proof of content. A source replaced in place (
git checkout,tar -x, a restore from an archive) keeps its path and can carry an mtime that predates the scan window. Known name, untouched clock: neither test inchanged_ignore_rules_infired, and the subtree was indexed under rules that were never read.Stale doc.
publish_ignore_matcherstill described asinceparameter it no longer takes.The fix records what the published matcher actually read —
ignore_source_stamps, size and mtime per source, keyed by relative path, plus an entry for the target of any symlinked source that is itself under the root — and asks against that instead of against a set of names. The live check now also fires for a path that appears in that map; the recovery scan reportsnot the file the matcher readon a mismatch. The mtime window stays, because the stamps are taken when the matcher is published, which is after the walk that read the files: a write landing between the two would otherwise be recorded as if it had been read. Targets outside the root cannot be watched at all, so for those the periodic reconcile remains the backstop — now stated rather than implied.Two tests:
a_rule_file_swapped_for_an_older_one_is_not_taken_on_faith(asserts the unmodified case stays quiet first, so the check is not trivially always-on) andan_edit_to_a_symlinked_rule_files_target_schedules_a_refresh.Control (draft PR #118, since closed): with the two decision points removed and the tests kept, ubuntu reported
124 passed; 2 failed— exactly the two new tests, nothing else.Review round 12
Two threads, both valid, both a proxy standing in for the thing itself.
The sweep took its candidates from
file_stampsalone. A stamp is not what makes a file searchable — the index is.filestamps.jsonis optional by design, and a missing or unreadable one is tolerated everywhere else, so a seeded index whose stamps could not be read had no sweep candidates at all: every file deleted during the unwatched window kept answering searches until the hourly reconcile. Candidates now come from the reader and the overlay as well. Reader paths already hidden by a tombstone are skipped, becausedelete_filetombstones unconditionally and counts a mutation, so re-deleting them would make every scan over a directory with deletions look like fresh churn and pull flushes forward.reader_pathswould have allocated a copy of every path in the index to answer this, soreader_paths_matchingfilters in place instead.The mtime window compared a rounded timestamp against a wall-clock instant. HFS+ and ext3 store whole seconds, FAT-derived filesystems two, so a write that followed the walk can be dated before it. For a source edited between the walk and the publication the recorded stamp matches the file, which leaves the window as the only test — and it was failing by up to two seconds. Widened by
MTIME_GRANULARITYat the near end. The far end stays where it is: a future mtime is clock skew, and treating it as an edit is what loops. Over-triggering costs one rewalk that finds nothing, and it is self-limiting, since later scans take later timestamps.Two tests, both cross-platform:
a_rule_file_stamped_a_second_early_is_still_inside_the_window(asserts an hour-old source stays quiet first, so the widening cannot pass by being always-on) andthe_sweep_drops_a_deleted_file_that_never_had_a_stamp.Control: with the two changes removed and the tests kept,
107 passed; 2 failed— exactly the two new tests.Review round 13
One thread, valid: size and mtime do not establish that a rule file is the one the matcher read.
rsync -aandtar -xpreserve mtime, and two different sets of rules are easily the same length, so the pair is identical across a replacement — and this path exists precisely for changes made while no event was observable.ignore_source_stampsnow records a hash of each source's bytes andchanged_ignore_rules_incompares against that. It is never persisted, so the hash only has to be stable within a run.On cost: these are rule files, a few hundred bytes each, already in the page cache from the walk that found them. That is a different proposition from hashing indexed content, where the read is the whole repository on every reconcile and a wrong stamp costs one stale file rather than a whole subtree indexed under rules that no longer exist. The per-file
FileStampscheme is left as it is for that reason.The digest is taken at publication rather than inside the matcher builder because the
ignorecrate opens these files itself and does not hand back what it read. The mtime window is what covers that residual gap, and is why it stays.Test:
a_rule_file_swapped_for_one_of_the_same_size_and_age_is_still_caught, which swaps seven bytes of rules for seven different ones and restores the mtime withutimes, asserting the size and mtime really are identical before it asks. Control with the digest reverted to metadata:128 passed; 1 failed— that test alone.Review round 14
Four threads, all valid. Each one is a check that was asking a narrower question than the thing it was guarding.
Containment was only checked on the last component.
is_real_dirrefuses a symlink it is pointed at, which says nothing about how the path was reached:root/a/bis a perfectly real directory whileais a symlink to anywhere on the machine. The walker never descends througha, so nothing under it is part of the served tree — but aCreateforroot/a/bwas enough to subscribe to it and enumerate it.open_within_rootkeeps that from misfiling content, so the cost is watch descriptors and work: on a large linked-in tree, exactly the inotify exhaustion this registration exists to avoid.is_contained_dirnow walks down from the served root and requires a real directory at every level, and it is whatwatch_new_subtreeand the post-registration re-check ask. The root itself is not tested — it may legitimately be reached through a link, which is the ordinary case under/varon macOS, and it is the anchoropen_within_rootalready trusts.WatchRegistrycarries the root and short-circuits through its own watched set, whose every entry was checked on the way in, so the startup sync still costs onesymlink_metadataper directory instead of one per level.existsis not the question for a vanished rule file. A.gitignorereplaced by a directory, a FIFO or a socket still exists, but the walker collects sources withPath::is_fileand would no longer take it, andchanged_ignore_rules_inskips candidates that are not files — so nothing else could notice either, and the matcher kept enforcing rules from a file that had stopped being one. One word:is_file.A stamp claimed more than the build could support. The background build publishes stamps from a metadata walk taken after the walk that fed the index, so a file created between the two appeared in the stamps and in no index. Every later check then agreed it was current:
reindex_filereturns early on a matching stamp, and every automatic caller of the reconcile passescompare_index_membership = false, so it compares stamps alone. The file would have stayed unsearchable until something changed it again.stamps_for_index_membersnow publishes stamps only for paths the reader or the overlay actually holds; anything else is left unstamped and is picked up as new by the next event or scan.The digest was taken after the matcher had already read.
GitignoreBuilder::addopens each source itself, inside the build, and hands nothing back — so digests taken at publication describe whatever is on disk when the build finishes. An mtime-preserving atomic replace in that window left the matcher on the old rules while the stamps recorded the new bytes, and pathname, timestamp and digest then all agreed there was nothing to reread.publish_ignore_matchernow takes the build as a closure and digests the sources on both sides of it. A mismatch still publishes — no matcher at all means indexing ignored paths — but marks the matcher stale and schedules a refresh. It converges: a filesystem that has stopped moving produces matching digests on the next pass.Four tests:
a_directory_below_a_symlinked_one_is_not_subscribed_to(unix),a_rule_file_replaced_by_a_directory_counts_as_gone,stamps_are_published_only_for_what_the_build_indexed, anda_rule_file_rewritten_during_the_build_marks_the_matcher_stale— the last asserting that an unraced build stays quiet first, so it cannot pass by marking everything stale.Control (draft PR #121, since closed): with the four decision points reverted and the tests kept, macOS reported
127 passed; 4 failed— exactly the four new tests, nothing else.Review round 15
Three more threads, all three real.
A whole directory that vanishes takes nothing with it (High). The recovery
sweep decided a file was gone from its immediate parent's listing. When a
directory is deleted or moved away entirely, its descendants have no enumerated
parent, so none of them were ever candidates — and no event names them either:
a removal delivers one event for the directory, and a move away delivers
nothing at all for what was inside it. Those files answered searches until the
hourly reconcile.
reindex_files_innow tracks the directories it saw presentand the ones whose listing failed, derives the vanished ones from a parent
listing that did succeed, and
sweep_removed_fileswalks ancestors so a fileunder a vanished directory at any depth is swept. The ancestor walk only runs
when something actually vanished, since that closure runs once per indexed path
in the repository.
Subscriptions were established in hash order (Medium). Round 14 justified
WatchRegistry::contained's fast path by claimingsyncfeeds directoriesparent-first. It does not —
desiredis aHashSet. Unordered, a childusually arrives before its parent, gets no watched parent to lean on, and walks
every ancestor with a
symlink_metadataper level. On a 40k-directory monorepothat is hundreds of thousands of syscalls in the path that exists to make
startup cheap.
syncnow sorts by component count before subscribing; a parentis always strictly shallower than its children, so depth order is enough.
A build stamped files it had not indexed (Medium). The stamp map comes from
a metadata walk taken after the content walk that fed the index. A file
written between the two is stamped with the new size and mtime while the index
holds the old bytes — and that claim is load-bearing in exactly the place that
should have repaired it, since
reindex_filereturns early on a matching stampand the reconcile behind it compares the same stamps. The replay of the very
event that reported the write read nothing, and the old content stayed
searchable indefinitely. The deferred-event buffer already names those paths, so
their stamps are withheld at publication (both in
background_index_buildandin the bootstrap path, which has the same race). When that buffer has
overflowed it names nothing and no file can be told apart from what changed, so
no stamp from that build is published at all and the reconcile overflow already
schedules re-reads the tree instead.
Three tests, all cross-platform:
a_directory_that_went_away_whole_takes_its_files_with_it,subscriptions_are_established_from_the_root_down,a_file_written_during_the_build_is_not_stamped_by_it.Control: with only the three decision points reverted and the tests kept,
exactly those three fail (112 passed / 3 failed) and nothing else moves.
Review round 16
One thread, and it is the same class of defect round 14 fixed for
subscriptions:
symlink_metadatarefuses to follow only the finalcomponent. The sweep used it to ask "is this path back on disk?" before
dropping a candidate, so a directory that vanished and returned as a link to
another tree made
root/gone-dir/a.rsresolve to an ordinary file outside theroot. The recheck read that as a return, kept the stale in-root entry, and
nothing ever corrected it — a linked-in tree is not walked and not watched, so
no descendant event exists.
The recheck now goes through
open_within_root, the same contractreindex_fileopens under, which resolves every ancestor without following alink (race-free on unix via
openat). Errors are classified with the existingproves_ineligible, so "gone, escaped, or not a directory on the way" sweepswhile a descriptor limit or a sharing violation preserves the entry for the
next reconcile rather than evicting live content — matching how
reindex_filealready treats the same failures.
Test:
a_path_that_returns_through_a_symlinked_ancestor_is_still_swept.Control (draft PR, since closed): with only the recheck reverted, Linux CI
reports
136 passed; 1 failed— that test and nothing else.Review round 17
Five claims, at head
e585f37. All five reproduce. Fixed inc07eb81.1. High — an ignore refresh can race a resumed build
background_index_buildpublishes its matcher part-way through Phase 2 andholds no gate while doing it (
bootstrap_index_builddoes, which is why onlythe resumed path is affected). When that publish sees changed rules it schedules
a refresh, and the refresh thread took
snapshot_gate.write()uncontended andreplaced
file_stampswholesale from its own walk — after which the buildoverwrote them from a walk that predates the new rules. The result is an index
and a stamp map describing two different trees, with no scan left to notice,
because both sides believe they finished.
spawn_recovery_scanalready waits outindexingfor exactly this reason. Therefresh worker now does the same, ahead of
background_refresh_stale, so theguard covers every caller. A wait rather than a lock: it cannot deadlock against
the build, and nothing is lost by waiting, because the build is still walking
the tree the refresh would walk.
Test:
an_ignore_refresh_waits_for_a_running_build.2. High — the watcher omitted git's case-insensitivity narrowing
Both walks apply
git_ignorecase_filteras afilter_entry(walker.rs:257,walker.rs:498), which hides trees that acore.ignorecaserepository treatsas matching an ignore rule under a different case.
IgnoreMatcherhad no suchfield and
is_ignorednever consulted it, soshould_skip_watcher_entryandwatchable_dirsadmitted exactly the trees the walk excluded: the watchersubscribed to them, indexed their files, and the next stale check evicted them
again. Reproduced on Windows.
IgnoreMatchernow carries the sameCaseInsensitiveIgnore, applied where thewalk applies it — last, and to whitelisted paths too, because a
filter_entryrejection is not undone by a whitelist rule. It is a pure narrowing, so it can
only ever add exclusions.
serveexposes none of the search-time--no-ignore-*flags, so the watcher's matcher is built with the full flag set.Tests:
the_point_query_matcher_hides_exactly_what_the_walk_hides, andthe_point_query_matcher_follows_the_case_sensitivity_gateguarding the otherdirection — a case-sensitive repository must have nothing hidden from it.
3. High — overflow could leave dead watches recorded as live
Two halves. A native drop —
IN_Q_OVERFLOW, a lostReadDirectoryChangesWbuffer — arrived on notify's error branch, which only logged; just the channel's
TrySendError::Fullsetoverflowed, so the loss that most needs a reconciletriggered none at all. And after any overflow the dropped removal events left
watchedrecording descriptors the kernel had already released, which everylater
syncskipped as already present: a directory removed and recreatedduring the gap stayed permanently unsubscribed.
The error branch now reconciles too, and both branches set a flag that one
following sync consumes to re-issue subscriptions once, through the existing
resubscribe_all— whose doc already described this poisoning, but whichnothing on the reconcile path called.
Test:
a_forced_sync_retires_a_subscription_the_kernel_already_dropped.4. Medium — populated directories moved in stayed unindexed off Linux
watch_new_subtreeboth subscribes and enumerates, but its call site inhandle_fs_eventwas gated onPER_DIRECTORY_WATCHES. A recursive backendreports a moved-in tree as a single event for the directory and never describes
the contents, so on Windows and macOS a
mvof a populated tree from outsidethe root — a checkout, an unpacked archive — left every file unindexed until the
hourly reconcile. Reproduced on Windows.
The enumeration now runs everywhere; only the subscribing half stays behind
PER_DIRECTORY_WATCHES, where it belongs.Test:
a_populated_directory_that_arrives_whole_is_indexed_on_every_platform.5. Medium — ignore-source tracking had gaps, and mis-resolved the exclude
ignore_sources_oflisted only the walk's.gitignore/.ignorefiles and theroot
p4ignore.ini, omitting the ancestor rule files and the repository excludethat the published matcher does enforce — so editing one changed what the
watcher filtered with no digest change to notice it.
Separately, both the matcher builder and
CaseInsensitiveIgnore::newresolvedthe exclude at a literal
.git/info/exclude. In a linked worktree or asubmodule
.gitis a file holding agitdir:pointer, and that directory holdsa
commondirnaming the repository every worktree shares — which is where theone
info/excludelives.WalkBuilderfollows that chain internally, so thewalk honored the exclude and the watcher did not, in exactly the layouts where
the two differ.
Both are fixed:
repo_exclude_pathfollows gitdir → commondir →info/excludethe way the walk does, and
ignore_sources_oftracks the ancestors and theexclude alongside everything else. Both helpers filter on
is_file(), becausereindex_files_intreats a listed source that does not exist as vanished andwould otherwise re-refresh forever.
Deliberately still untracked: the user's global gitignore. The
ignorecrateresolves it through git config precedence and does not expose which path it
chose, so guessing would report a vanished source on every scan. The reasoning
is in the
ignore_sources_ofdoc comment.Tests:
ignore_sources_include_the_rules_that_live_outside_the_tree,the_repository_exclude_is_found_through_a_worktree_pointer,a_relative_commondir_resolves_against_the_worktree_git_dir.Control
All eight tests are cross-platform, so the control ran locally. Reverting only
the six decision points and keeping the tests:
115 passed; 4 failedintgrep-cliand207 passed; 3 failedintgrep-core— those seven and nothingelse, every other suite still green.
the_point_query_matcher_follows_the_case_sensitivity_gatepasses under the control by construction, as a guard against over-application
should. Restored:
119/210/101/211, all green.Review round 18
Three claims, at head
e585f37. All three reproduce. Fixed ina784a01.1. High — a transient stat failure was read as a deletion
Path::existsandPath::is_filefold every metadata error intofalse, so afile held open by a build, a Windows sharing violation, or a momentary
EACCESread as "gone" at one site and "no longer a regular file" at the other. Both
branches then evicted content that was still valid.
reindex_filedeliberatelypreserves entries through exactly those failures, but it never got the chance:
the drop happens earlier in
handle_fs_event, before it is ever called.Rather than swap in
try_existsat two sites and let them drift again, thepolicy is now explicit. One stat feeds
classify_event_target, which answersthrough the same
proves_ineligiblecontract the recovery sweep uses:NotFound,ELOOP,NotADirectoryandInvalidInputare a removal, anythingelse is
Unknownand concludes nothing.Unknownleaves the index alone andlets the stale path retry, which is what it already does for unreadable files.
Test:
an_unreadable_path_is_not_treated_as_a_deletion.2. High — the tracked-file exemption froze
A regression from round 17.
CaseInsensitiveIgnorecaches the tracked-file setbehind a
OnceLock, which was sound while it was a walk-local object built,used and dropped inside one traversal. Retaining it in the watcher's long-lived
matcher changed its lifetime without changing its freshness model, and there a
git add -for agit rm --cachedrewrites only.git/index— hidden, so noignore source changes and nothing republishes. Both directions hurt: a newly
tracked file stays hidden from the watcher, and an untracked-again one keeps
being indexed and then evicted by the next stale check.
Now an
RwLock<TrackedCache>keyed on the index's identity —(mtime, len)ofthe file
git_dir()resolves to, the same pair git's own racy-index handlingrelies on, and git installs a new index by renaming
index.lockover it soevery rewrite lands as a new mtime. The common path stays a read lock, and the
laziness is preserved: the identity stat only runs for paths the
case-insensitive rules have already claimed, which for most repositories is
none.
Test:
the_tracked_exemption_reloads_when_the_git_index_changes.3. Medium — a rule file symlinked into a hidden directory was unobservable
handle_fs_eventrecognises an event naming a symlinked source's targetrather than a name rules usually go by, and asserted in a comment that such
targets are watched "because only those are watched". On a per-directory backend
they were not: nothing subscribes to
build/when the rules hide it, so editingthe file the matcher was built from produced no event at all and the matcher
stayed stale until the hourly reconcile.
Windows and macOS were already correct — the subtree watch is native and
recursive, the event arrives, and the stamps lookup runs before
should_skip_watcher_path, so the rules hiding the directory do not suppressit. The hole was Linux-only.
ignore_target_dirsnow resolves each symlinked source to its in-root targetand adds that file's own directory — not its subtree — to the desired set.
watchable_dirsis untouched, so nothing descends into the ignored tree;should_skip_watcher_pathstill discards everything else delivered from there;and the registry's
containedcheck already falls back to a full containmenttest when a parent is not watched, so no ancestor chain is required. Normally
the set is empty and this costs nothing.
Not taken: dropping the target entry instead. That would break the case where it
does work — a target that is itself a watched
.gitignore/.ignore— withoutmaking the matcher any fresher. Targets outside the root remain deliberately
uncovered, since subscribing there means watching outside the tree the server
was asked to serve.
Tests:
a_rule_file_symlinked_into_a_hidden_directory_is_still_watched, andordinary_and_outside_rule_files_add_no_subscriptionsguarding againstover-subscribing.
Control
All four tests are cross-platform, so the control ran locally. Reverting only
the three decision points and keeping the tests:
120 passed; 2 failedintgrep-cliand210 passed; 1 failedintgrep-core— those three and nothingelse, every other suite still green. The over-subscription guard passes under
the control, as it should. Restored:
122/211/101/211, all green.