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() {