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
This commit is contained in:
Lukas Geiger authored and GitHub committed 2026-06-16 14:13:38 +00:00
1 parent 2252cad9b9
commit 2408640e5f
3 files changed
+101 -22

No files matched your search

+12 -1
View File
@@ -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!(
+26 -7
View File
@@ -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;
@@ -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/<name>/`. 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]