From 2408640e5f70e65732ef4af73badd4e9e6fe9c2f Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Tue, 16 Jun 2026 15:13:38 +0100 Subject: [PATCH] git: Avoid unnecessary git repo rescans when unrelated git files change (#59318) # Objective Zed triggers many git rescans whenever an outside git command modifies files inside `.git` dir. This becomes especially problematic when working on large repos or doing remote development on machines with slow filesystems. ## Solution Events for object writes, hook files, lock files, and the reflogs of HEAD/branches/remote-tracking branches carry no git changes that Zed cares about beyond what the ref or events already cover. So changes to these files shouldn't trigger a full git rescan. ## Testing I extended the existing unittests to add the additionally ignored directories and lock files. I also manually verified the changes by viewing the zed git debug logs that get generated when running `git gc` on a freshly gc'ed repo. Previously Zed triggered **7 worktree updates**, with these changes it was reduced to **a single worktree update** which is due to `.git/packed-refs` which we can't ignore. **main:** ``` 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:52+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:52+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:53+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:53+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } 2026-06-13T13:56:54+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:54+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:54+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:54+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:55+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:55+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:55+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:55+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-13T13:56:55+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-13T13:56:56+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-13T13:56:56+01:00 DEBUG [project::git_store] fetched remotes 2026-06-13T13:56:56+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-13T13:56:56+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-13T13:56:56+01:00 DEBUG [project::git_store] load merge details 2026-06-13T13:56:56+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None } ``` **This PR:** ``` 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] received worktree update for repositories: [UpdatedGitRepository { work_directory_id: ProjectEntryId(0), old_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), new_work_directory_abs_path: Some("/Users/lgeiger/code/zed"), dot_git_abs_path: Some("/Users/lgeiger/code/zed/.git"), repository_dir_abs_path: Some("/Users/lgeiger/code/zed/.git"), common_dir_abs_path: Some("/Users/lgeiger/code/zed/.git") }] 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] local worktree repos changed 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] run scheduled git status scan 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] starting compute snapshot 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] fetched branches, head commit, worktrees 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] fetched remotes 2026-06-15T01:19:51+01:00 DEBUG [git::repository] Checking for git status in [""] 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] fetched statuses, diff stats, stash entries 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] load merge details 2026-06-15T01:19:51+01:00 DEBUG [project::git_store] new merge details: MergeDetails { merge_heads_by_conflicted_path: {}, message: None }``` ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Reduced number of git operations when repository state changes outside of zed --- crates/git/src/git.rs | 13 +++- crates/worktree/src/worktree.rs | 33 ++++++-- .../tests/integration/worktree_tests.rs | 77 +++++++++++++++---- 3 files changed, 101 insertions(+), 22 deletions(-) diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 980960f6e8..15efdad753 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -20,8 +20,19 @@ pub const DOT_GIT: &str = ".git"; 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 HOOKS_DIR: &str = "hooks"; +pub const LOGS_DIR: &str = "logs"; +pub const LOGS_REF_STASH: &str = "logs/refs/stash"; +pub const REBASE_MERGE_DIR: &str = "rebase-merge"; +pub const REBASE_APPLY_DIR: &str = "rebase-apply"; +pub const SEQUENCER_DIR: &str = "sequencer"; pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG"; -pub const INDEX_LOCK: &str = "index.lock"; +pub const FETCH_HEAD: &str = "FETCH_HEAD"; +pub const ORIG_HEAD: &str = "ORIG_HEAD"; +pub const BISECT_LOG: &str = "BISECT_LOG"; +pub const GC_PID: &str = "gc.pid"; +pub const INFO_DIR: &str = "info"; pub const REPO_EXCLUDE: &str = "info/exclude"; actions!( diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 00aa15b360..6eda657abd 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -22,8 +22,9 @@ use futures::{ }; use fuzzy::CharBag; use git::{ - COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, REPO_EXCLUDE, - status::GitSummary, + 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, }; use gpui::{ App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority, @@ -4444,8 +4445,17 @@ impl BackgroundScanner { // // Certain directories may have FS changes, but do not lead to git data changes that Zed cares about. // Ignore these, to avoid Zed unnecessarily rescanning git metadata. - let skipped_file_names_in_dot_git = [COMMIT_MESSAGE, INDEX_LOCK]; - let skipped_dirs_in_dot_git = [FSMONITOR_DAEMON, LFS_DIR]; + let skipped_file_names_in_dot_git = + [COMMIT_MESSAGE, FETCH_HEAD, ORIG_HEAD, BISECT_LOG, GC_PID]; + let skipped_dirs_in_dot_git = [ + FSMONITOR_DAEMON, + LFS_DIR, + OBJECTS_DIR, + HOOKS_DIR, + REBASE_MERGE_DIR, + REBASE_APPLY_DIR, + SEQUENCER_DIR, + ]; let mut dot_git_abs_paths = Vec::new(); let mut work_dirs_needing_exclude_update = Vec::new(); @@ -4478,9 +4488,18 @@ impl BackgroundScanner { path_in_git_dir .file_name() .is_some_and(|file_name| file_name == OsStr::new(skipped)) - }) || skipped_dirs_in_dot_git - .iter() - .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)); + }) || (path_in_git_dir.starts_with(LOGS_DIR) + && path_in_git_dir != Path::new(LOGS_REF_STASH)) + || (path_in_git_dir.starts_with(INFO_DIR) + && path_in_git_dir != Path::new(REPO_EXCLUDE)) + || skipped_dirs_in_dot_git.iter().any(|skipped_git_subdir| { + path_in_git_dir.starts_with(skipped_git_subdir) + }) + || path_in_git_dir.extension().is_some_and(|ext| ext == "lock") + || (path_in_git_dir.components().count() == 1 + && path_in_git_dir + .extension() + .is_some_and(|ext| ext == "new" || ext == "tmp")); let is_dot_git = path_in_git_dir == Path::new("") && matches!(event.kind, Some(PathEventKind::Changed)) && self.fs.is_dir(&dot_git_abs_path).await; diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 66cd5f9ba1..3331168b45 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -4108,14 +4108,16 @@ async fn test_linked_worktree_gitfile_event_preserves_repo( } #[gpui::test] -async fn test_linked_worktree_index_lock_event_does_not_emit_git_repo_update( +async fn test_noisy_dot_git_events_do_not_emit_git_repo_update( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { - // Regression test: in a linked worktree, git operations like `git status` - // can touch the worktree-specific `index.lock` under the main repo's - // `.git/worktrees//`. We intend to ignore those events so they do not - // spuriously emit `UpdatedGitRepositories`. + // Events for object database writes, hook files, lock files, and the + // reflogs of HEAD/branches/remote-tracking branches carry no git state + // changes that Zed cares about beyond what the accompanying ref or index + // events already convey, so they must not trigger a git metadata rescan. + // The stash reflog and ref updates themselves must still trigger one. + // init_test(cx); use git::repository::Worktree as GitWorktree; @@ -4177,17 +4179,64 @@ async fn test_linked_worktree_index_lock_event_does_not_emit_git_repo_update( } }); - fs.emit_fs_event( + let skipped_paths = [ + // Standard common git dir skipped paths + path!("/main_repo/.git/objects/aa/bbccddee"), + path!("/main_repo/.git/objects/pack/pack-1234.pack"), + path!("/main_repo/.git/hooks/pre-commit"), + path!("/main_repo/.git/logs/HEAD"), + path!("/main_repo/.git/logs/refs/heads/main"), + path!("/main_repo/.git/logs/refs/remotes/origin/main"), + path!("/main_repo/.git/logs/refs/tags/v1.0"), + path!("/main_repo/.git/rebase-merge/done"), + path!("/main_repo/.git/rebase-apply/onto"), + path!("/main_repo/.git/sequencer/todo"), + path!("/main_repo/.git/index.lock"), + path!("/main_repo/.git/refs/heads/main.lock"), + path!("/main_repo/.git/COMMIT_EDITMSG"), + path!("/main_repo/.git/packed-refs.new"), + path!("/main_repo/.git/config.new"), + path!("/main_repo/.git/index.new"), + path!("/main_repo/.git/index-abc123.tmp"), + path!("/main_repo/.git/FETCH_HEAD"), + path!("/main_repo/.git/ORIG_HEAD"), + path!("/main_repo/.git/BISECT_LOG"), + path!("/main_repo/.git/info/refs"), + path!("/main_repo/.git/info/refs_lzOf51"), + path!("/main_repo/.git/gc.pid"), + // Linked-worktree specific skipped paths path!("/main_repo/.git/worktrees/feature/index.lock"), - Some(PathEventKind::Changed), - ); - cx.run_until_parked(); + ]; + for path in skipped_paths { + fs.emit_fs_event(path, Some(PathEventKind::Changed)); + cx.run_until_parked(); + assert_eq!( + repo_update_count.get(), + 0, + "event for {path} should not emit UpdatedGitRepositories" + ); + } - assert_eq!( - repo_update_count.get(), - 0, - "linked-worktree index.lock events should not emit UpdatedGitRepositories" - ); + let rescan_paths = [ + // Standard common git dir rescan paths + path!("/main_repo/.git/logs/refs/stash"), + path!("/main_repo/.git/refs/heads/main"), + path!("/main_repo/.git/info/exclude"), + path!("/main_repo/.git/refs/heads/branch.new"), + path!("/main_repo/.git/refs/heads/branch.tmp"), + // Linked-worktree worktree-specific rescan paths + path!("/main_repo/.git/worktrees/feature/index"), + path!("/main_repo/.git/worktrees/feature/HEAD"), + ]; + for path in rescan_paths { + let count_before = repo_update_count.get(); + fs.emit_fs_event(path, Some(PathEventKind::Changed)); + cx.run_until_parked(); + assert!( + repo_update_count.get() > count_before, + "event for {path} should emit UpdatedGitRepositories" + ); + } } #[gpui::test]