worktree: Suppress bare .git events explained by filtered sibling events (#61636)

Fixes an infinite git-rescan loop on Windows introduced by #59876.

On Windows, creating or deleting a file directly inside .git updates the
directory's last-write time, so ReadDirectoryChangesW reports a bare
.git Changed event alongside the file's own event. Since #59876, bare
.git events schedule a git rescan (to cope with coalesced FSEvents on
macOS), so every filtered-out lock-file event still triggered a rescan
through its paired bare event. Because a rescan's own `git diff
--numstat HEAD` can take .git/index.lock (even under
--no-optional-locks, e.g. in jj-colocated repos whose index never
refreshes clean), each rescan re-triggered the next one, spawning ~9 git
processes per cycle, ~4 cycles/sec, indefinitely.

Bare .git events are now deferred while processing an event batch and
only trigger a rescan when the batch contains no filtered event for the
same git dir that explains the directory change. A standalone bare .git
event (the macOS coalescing case #59876 addressed) still triggers a
rescan, as does any batch containing a meaningful .git change.


Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Lukas Wirth authored and GitHub committed 2026-07-25 10:18:26 +00:00
1 parent 73be80748b
commit fb813f70d6
2 files changed
+158 -9

No files matched your search

+40 -9
View File
@@ -4773,6 +4773,18 @@ impl BackgroundScanner {
let snapshot = &self.state.lock().await.snapshot;
let mut ranges_to_drop = SmallVec::<[Range<usize>; 4]>::new();
// On Windows, creating or deleting a file directly inside `.git`
// updates the directory's last-write time, so the watcher reports a
// bare `.git` Changed event alongside the file's own event. When the
// file event is filtered out (e.g. git's transient `index.lock`), the
// bare event carries no information either, so acting on it would
// turn every ignored lock file into a git rescan. Since a rescan's
// own `git diff` can take `index.lock`, that feeds back into an
// infinite loop of rescans. So bare `.git` events are deferred here
// and only rescanned when nothing in the batch explains them; a
// standalone bare event (macOS coalescing) still triggers a rescan.
let mut bare_dot_git_abs_paths = Vec::new();
let mut dot_git_dirs_with_ignored_events = Vec::new();
for (ix, event) in events.iter().enumerate() {
let abs_path = SanitizedPath::new(&event.path);
@@ -4816,6 +4828,21 @@ impl BackgroundScanner {
log::debug!(
"ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories"
);
if !dot_git_dirs_with_ignored_events.contains(&dot_git_abs_path) {
dot_git_dirs_with_ignored_events.push(dot_git_abs_path);
}
skip_ix(&mut ranges_to_drop, ix);
continue;
}
if is_dot_git {
log::debug!(
"ignoring event {abs_path:?} for .git directory itself (kind: {:?})",
event.kind
);
if !bare_dot_git_abs_paths.contains(&dot_git_abs_path) {
bare_dot_git_abs_paths.push(dot_git_abs_path);
}
skip_ix(&mut ranges_to_drop, ix);
continue;
}
@@ -4827,15 +4854,6 @@ impl BackgroundScanner {
dot_git_abs_paths.push(dot_git_abs_path);
}
if is_dot_git {
log::debug!(
"ignoring event {abs_path:?} for .git directory itself (kind: {:?})",
event.kind
);
skip_ix(&mut ranges_to_drop, ix);
continue;
}
// New directories can appear under the `refs` tree at any time, e.g. when a
// remote is added or a branch name contains slashes. On platforms where the
// native watcher is non-recursive they need their own watches, or subsequent
@@ -4894,6 +4912,19 @@ impl BackgroundScanner {
}
}
for dot_git_abs_path in bare_dot_git_abs_paths {
if dot_git_dirs_with_ignored_events.contains(&dot_git_abs_path) {
log::debug!(
"not reloading git repo at {dot_git_abs_path:?}: the bare .git event is explained by filtered events in the same batch"
);
} else if !dot_git_abs_paths.contains(&dot_git_abs_path) {
log::debug!(
"detected update within git repo at {dot_git_abs_path:?}: bare .git directory event"
);
dot_git_abs_paths.push(dot_git_abs_path);
}
}
for range_to_drop in ranges_to_drop.into_iter().rev() {
events.drain(range_to_drop);
}