Skip to content

Do not index a directory the watcher could not read or classify - #112

Merged
JesseHerrick merged 3 commits into
mainfrom
fix/watcher-unreadable-dir
Oct 4, 2026
Merged

JesseHerrick merged 3 commits into
mainfrom
fix/watcher-unreadable-dir

Conversation

@JesseHerrick

@JesseHerrick JesseHerrick commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Closes four gaps in how nested worktrees and unreadable directories are handled (#111, and the worktree-move fix in #110). The last one is the cause of the flaky TestFSNotifyWatcherSkipsWorktreeAddedWhileRunning failures in CI.

Summary

  • Two reads of one .git file could disagree (the cause of the CI flake). git 2.48 and later rename a worktree on git worktree move and then write its .git file again in place (truncate, then write; confirmed with strace on git 2.55). The watcher's walk asked "is this a worktree?" and "is its .git file still being written?" with two separate reads. The first could see the truncated file and the second the complete one, so a worktree was neither, and every file in it was reported as a plain directory. git 2.47 does not write the file again, which is why it only showed up on CI's newer git.

  • A directory that the watcher cannot read was skipped silently. walkDirectories returned when ReadDir failed, for example when the process had no file descriptor left (the kqueue backend uses one per watched file). The subtree below it was not watched, not marked failed and never retried, so the coverage report said nothing. The Create handler then reported every file in it as if it were a plain directory, also when it was a worktree that had been moved into place.

  • The index walks did not skip a worktree that git is still moving. git worktree move renames the directory and then writes its .git file again in place. Send generated functions to the line that declared them #110 made the watchers treat a .git file that names no git directory yet as a pending worktree, but WalkElixirFiles and CollectElixirFilesParallel did not, so a reconcile or a first build at that moment indexed the whole worktree.

Changes

  • parser.GitFile and parser.GitFileFromEntries return one state (none, plain, worktree, unsettled) from one read of the .git file. The fsnotify walk and pending check, the FSEvents handler and its later check, both index walks, and the runtime's removal of a reported worktree use it. Same cost as before: no syscall for a directory without a .git entry, one read for a directory with one.

  • walkDirectories marks a directory it cannot read as failed. The coverage report then says that the tree is not covered, and the retry timer reads it again. A retry clears the failure only when it could read the directory.

  • The Create handler does not report the files of a directory that it could not read. When coverage comes back, the runtime reconciles, and that walk indexes the files of a plain directory and skips a worktree.

  • Both index walks skip a nested directory whose .git file names no git directory yet, as the watchers do.

  • The watcher's directory reads can be replaced in tests (readDir), so a failed read can be simulated without timing.

Validation

  • TestWatcherDoesNotReportDirectoryItCouldNotRead: a worktree moved into place and a plain directory, each with a failed read, a retry that still fails, and a retry that works. Nothing is reported from the unreadable directory, the coverage goes degraded and then restored, and the retry classifies the directory. With the old behavior, the test reports the moved-in worktree's files.
  • TestWalkAndCollectSkipUnsettledGitFile: both walks skip a directory with an empty .git file. Without the change, both index it.
  • TestGitFileStates (each state) and TestGitFileReadsTheFileOnce, which serves an empty file on the first read and a complete one on the second, as git does: one classification reads once and answers "unsettled".
  • Reproduced the CI failure in a Linux container like CI (Ubuntu 24.04, git 2.55): TestFSNotifyWatcherSkipsWorktreeAddedWhileRunning failed in 2 of 20 race runs before the change. After it: 100 of 100 with git 2.55, and 50 of 50 with git 2.47. Tracing the failing run showed the walk deciding "not a worktree" while the .git file and git's records were valid a moment later.
  • The unreadable-directory fix in the first commit is a separate real gap (proven by its own test), but it was not the cause of the CI flake, as this PR first said.
  • go test ./..., go test -race on internal/workspace and internal/parser, ten race runs of every watcher test, and golangci-lint pass. No change to the walk for directories without a .git entry, so the walk costs the same.

🤖 Generated with Claude Code


Note

Medium Risk
Changes when directories are indexed and how nested worktrees are detected; wrong behavior could skip or duplicate index content, but scope is filesystem watching and walks only.

Overview
Fixes incorrect indexing when a git worktree is moved into the project or when the file watcher cannot read a directory (e.g. EMFILE).

Single-read .git classification — Replaces separate “linked worktree?” and “unsettled .git file?” checks with GitFile / GitFileFromEntries, which classify a directory in one read (WorktreeGitFile, UnsettledGitFile, PlainGitFile, etc.). That closes a race on git 2.48+ where two reads could see an empty then complete .git file and treat a worktree as a normal folder (the CI flake). Index walks (WalkElixirFiles, CollectElixirFilesParallel), fsnotify/FSEvents, and runtime removal all use this API; unsettled (empty) .git files are skipped like settled worktrees.

Unreadable directories — walkDirectories no longer silently skips failed ReadDir: it marks the path failed, reports degraded coverage, and retries later. On Create, files under a directory that could not be read are not indexed immediately (it might be a moved-in worktree); readable-but-unwatchable dirs are still indexed at once.

CHANGELOG documents the fix; new tests cover unsettled .git, single-read behavior, unreadable dirs, and watch-at-limit cases.

Reviewed by Cursor Bugbot for commit 69703fa. Bugbot is set up for automated code reviews on this repo. Configure here.

walkDirectories returned silently when it could not read a directory,
for example when the process had no file descriptor left. The subtree
below it was then not watched, not marked failed and never retried, and
the Create handler reported every file in it as if it were a plain
directory, even when it was a worktree that had been moved into place.
Such a directory is now marked failed: the coverage report says that it
is not covered, the retry reads it again, and a retry clears the failure
only when it could read it. Its files are not reported from the Create
event; the reconcile that follows restored coverage indexes them.

Both index walks (WalkElixirFiles and CollectElixirFilesParallel) now
skip a directory whose .git file names no git directory yet, as the
watchers do, so a reconcile does not index a worktree that git is still
moving.

This is a likely cause of a rare failure of
TestFSNotifyWatcherSkipsWorktreeAddedWhileRunning under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/workspace/watch_fsnotify.go
git 2.48 and later rename a worktree on `git worktree move` and then
write its .git file again in place (truncate, then write). The watcher's
walk asked two questions with two reads: "is this a worktree?" and "is
its .git file still being written?". The first read could see the
truncated file and the second the complete one, so a worktree was
neither, and the walk reported every file in it as a plain directory.
With git 2.55 on Linux this made
TestFSNotifyWatcherSkipsWorktreeAddedWhileRunning fail in 2 of 20 runs;
git 2.47, which does not write the file again, never failed.

parser.GitFile and parser.GitFileFromEntries now return one state
(none, plain, worktree, unsettled) from one read of the file. The
fsnotify walk and pending check, the FSEvents handler and its later
check, both index walks, and the runtime's removal of a reported
worktree use it. The cost is the same: no syscall for a directory
without a .git entry, and one read for a directory with one.

Tests: TestGitFileStates (each state) and TestGitFileReadsTheFileOnce
(serves an empty file on the first read and a complete one on the
second, as git does; one call must read once and answer "unsettled").
On Linux, the watcher test now passes 100 of 100 runs with git 2.55 and
50 of 50 with git 2.47.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1181bba. Configure here.

Comment thread internal/parser/parser.go
The Create handler skipped indexing any directory marked failed, but a
directory is also marked failed when its watch cannot be added (the
inotify watch limit) although it was read and is known to be plain.
When the watcher was already degraded, no coverage edge followed, so
such a directory was never indexed. The handler now skips only a
directory that could not be read.

GitFileFromEntries read any .git entry that is not a directory, while
GitFile reads only a regular file. A .git symlink could then be
"unsettled" in a walk and "none" in the later check. Both now read only
a regular file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JesseHerrick
JesseHerrick merged commit 058a1bf into main Oct 4, 2026
5 checks passed
@JesseHerrick
JesseHerrick deleted the fix/watcher-unreadable-dir branch October 4, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant