Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/git/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
81 changes: 72 additions & 9 deletions crates/worktree/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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:?}"
Expand Down
83 changes: 83 additions & 0 deletions crates/worktree/tests/integration/worktree_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event>,
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<Event>) -> bool {
let mut found = false;
while let Ok(event) = events.try_recv() {
Expand Down
Loading