From ce4db9f82895374e6089246cd91ebe90a07a93e2 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 9 Jul 2026 11:10:17 +0200 Subject: [PATCH] worktree: Watch .git/refs subdirectories for external ref updates On Linux and FreeBSD the native file watcher is non-recursive, so a watch on the .git directory itself does not report changes to files nested below it. Loose refs live in nested directories under refs, so external git commit, fetch, branch, and update-ref operations that don't also touch a direct child of .git (like the index) went entirely unnoticed. Watch every directory in the refs tree when a repository is inserted, and watch directories subsequently created under refs (new remotes, slash-named branches) as their creation events arrive. On platforms with recursive watchers these registrations dedupe against the existing recursive watch, making them free. --- crates/git/src/git.rs | 2 + crates/worktree/src/worktree.rs | 81 ++++++++++++++++-- .../tests/integration/worktree_tests.rs | 83 +++++++++++++++++++ 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index fe9947f23b5b83..4bb16a39515dce 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -21,6 +21,8 @@ pub const GITIGNORE: &str = ".gitignore"; pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon"; pub const LFS_DIR: &str = "lfs"; pub const OBJECTS_DIR: &str = "objects"; +pub const REFS_DIR: &str = "refs"; +pub const REFTABLE_DIR: &str = "reftable"; pub const HOOKS_DIR: &str = "hooks"; pub const LOGS_DIR: &str = "logs"; pub const LOGS_REF_STASH: &str = "logs/refs/stash"; diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 6c90de189b5f82..900feacbdc4b14 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -24,7 +24,8 @@ use fuzzy::CharBag; use git::{ BISECT_LOG, COMMIT_MESSAGE, DOT_GIT, FETCH_HEAD, FSMONITOR_DAEMON, GC_PID, GITIGNORE, HOOKS_DIR, INFO_DIR, LFS_DIR, LOGS_DIR, LOGS_REF_STASH, OBJECTS_DIR, ORIG_HEAD, - REBASE_APPLY_DIR, REBASE_MERGE_DIR, REPO_EXCLUDE, SEQUENCER_DIR, status::GitSummary, + REBASE_APPLY_DIR, REBASE_MERGE_DIR, REFS_DIR, REFTABLE_DIR, REPO_EXCLUDE, SEQUENCER_DIR, + status::GitSummary, }; use gpui::{ App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority, @@ -3461,14 +3462,9 @@ impl BackgroundScannerState { .context("failed to add repository directory to watcher") .log_err(); - // On Linux and FreeBSD, the native watcher is non-recursive, so subdirectories inside `.git` need explicit watching. - // For repos using the reftable backend, watch the `.git/reftable` directory so that ref changes are detected. - let reftable_path = common_dir_abs_path.join("reftable"); - if fs.is_dir(&reftable_path).await { - watcher - .add(&reftable_path) - .context("failed to add reftable directory to watcher") - .log_err(); + watch_git_dir_subdirectories(&common_dir_abs_path, fs, watcher).await; + if repository_dir_abs_path != common_dir_abs_path { + watch_git_dir_subdirectories(&repository_dir_abs_path, fs, watcher).await; } let work_directory_id = work_dir_entry.id; @@ -3492,6 +3488,55 @@ impl BackgroundScannerState { } } +/// Watches the directories inside a git directory that git writes ref updates to. +/// +/// On Linux and FreeBSD the native file watcher is non-recursive, so a watch on the git +/// directory itself does not report changes to files nested below it, such as the loose +/// refs that git updates on commit, fetch, and branch operations. Watch the `refs` tree +/// (its directories are watched individually because branch names may contain slashes) +/// and, for repositories using the reftable backend, the `reftable` directory. On +/// platforms with recursive watchers these calls are deduplicated against the existing +/// recursive registration, making them effectively free. +async fn watch_git_dir_subdirectories(git_dir_abs_path: &Path, fs: &dyn Fs, watcher: &dyn Watcher) { + let reftable_dir_abs_path = git_dir_abs_path.join(REFTABLE_DIR); + if fs.is_dir(&reftable_dir_abs_path).await { + watcher + .add(&reftable_dir_abs_path) + .context("failed to add reftable directory to watcher") + .log_err(); + } + + watch_dir_tree(git_dir_abs_path.join(REFS_DIR), fs, watcher).await; +} + +/// Watches a directory and all of its descendant directories. +/// +/// Each directory is watched before its children are enumerated, so that a child +/// created concurrently is either seen by the enumeration or reported by the watch. +async fn watch_dir_tree(root_abs_path: PathBuf, fs: &dyn Fs, watcher: &dyn Watcher) { + let mut dirs_to_watch = vec![root_abs_path]; + while let Some(dir_abs_path) = dirs_to_watch.pop() { + if !fs.is_dir(&dir_abs_path).await { + continue; + } + watcher + .add(&dir_abs_path) + .with_context(|| format!("failed to watch directory {dir_abs_path:?}")) + .log_err(); + let Some(mut children) = fs.read_dir(&dir_abs_path).await.log_err() else { + continue; + }; + while let Some(child_abs_path) = children.next().await { + let Some(child_abs_path) = child_abs_path.log_err() else { + continue; + }; + if fs.is_dir(&child_abs_path).await { + dirs_to_watch.push(child_abs_path); + } + } + } +} + async fn is_dot_git(path: &Path, fs: &dyn Fs) -> bool { if let Some(file_name) = path.file_name() && file_name == DOT_GIT @@ -4646,6 +4691,24 @@ impl BackgroundScanner { 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 + // ref updates inside them would go unnoticed. The subtree is walked because + // nested directories may have been created before this watch took effect. + if matches!(event.kind, Some(PathEventKind::Created)) + && path_in_git_dir + .components() + .any(|component| component.as_os_str() == OsStr::new(REFS_DIR)) + { + watch_dir_tree( + abs_path.as_path().to_path_buf(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; + } + if !dot_git_abs_paths.contains(&dot_git_abs_path) { log::debug!( "detected update within git repo at {dot_git_abs_path:?}: {abs_path:?}" diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index f63bf7d1085f2d..b1f7f209408a4e 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -4541,6 +4541,89 @@ async fn test_dot_git_dir_event_does_not_suppress_children( } } +#[gpui::test] +async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAppContext) { + // On Linux and FreeBSD the native file watcher is non-recursive: watching `.git` + // does not deliver events for files nested below it, like the loose refs that git + // updates on commit, fetch, and branch operations. The worktree must watch the + // `refs` tree explicitly, including directories created after the initial scan. + init_test(cx); + cx.executor().allow_parking(); + + let dir = TempTree::new(json!({ + ".git": {}, + "a.txt": "a-contents", + })); + std::fs::write( + dir.path().join(".git/refs/heads/main"), + "0000000000000000000000000000000000000000\n", + ) + .unwrap(); + + let tree = Worktree::local( + dir.path(), + true, + Arc::new(RealFs::new(None, cx.executor())), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + + let mut events = cx.events(&tree); + std::fs::write( + dir.path().join(".git/refs/heads/main"), + "1111111111111111111111111111111111111111\n", + ) + .unwrap(); + expect_git_repo_update(&mut events, cx, "updating a loose ref").await; + + std::fs::create_dir_all(dir.path().join(".git/refs/remotes/origin")).unwrap(); + expect_git_repo_update(&mut events, cx, "creating a directory under refs").await; + tree.flush_fs_events(cx).await; + drain_git_repo_updates(&mut events); + + std::fs::write( + dir.path().join(".git/refs/remotes/origin/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + expect_git_repo_update( + &mut events, + cx, + "updating a ref in a directory created after the initial scan", + ) + .await; +} + +async fn expect_git_repo_update( + events: &mut futures::channel::mpsc::UnboundedReceiver, + cx: &mut TestAppContext, + description: &str, +) { + let mut elapsed = std::time::Duration::ZERO; + let timeout = std::time::Duration::from_secs(10); + let poll_interval = std::time::Duration::from_millis(50); + loop { + match events.try_recv() { + Ok(Event::UpdatedGitRepositories(_)) => return, + Ok(_) => continue, + Err(_) => {} + } + assert!( + elapsed < timeout, + "timed out waiting for UpdatedGitRepositories after {description}" + ); + cx.background_executor.timer(poll_interval).await; + elapsed += poll_interval; + } +} + fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver) -> bool { let mut found = false; while let Ok(event) = events.try_recv() {