From 80f5819662904cf5ea56ebf9625a84bc54e67ac2 Mon Sep 17 00:00:00 2001 From: Henrique Ferreiro Date: Tue, 26 May 2026 14:26:25 +0200 Subject: [PATCH 1/2] Honor anchored patterns in .git/info/exclude Patterns in `.git/info/exclude` that contain a slash (e.g. `.claude/worktrees`) are anchored: Git matches them relative to the project root. Zed was instead matching them relative to the `.git/info/` directory that the file lives in, so they matched nothing and had no effect. --- crates/worktree/src/worktree.rs | 23 ++++++-- crates/worktree/tests/integration/main.rs | 66 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 6b0f5a04ba020b..8a8bda35aa8de9 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -3340,12 +3340,20 @@ async fn is_dot_git(path: &Path, fs: &dyn Fs) -> bool { } async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { + let parent = abs_path.parent().unwrap_or_else(|| Path::new("/")); + build_gitignore_with_root(abs_path, parent, fs).await +} + +async fn build_gitignore_with_root( + abs_path: &Path, + root: &Path, + fs: &dyn Fs, +) -> Result { let contents = fs .load(abs_path) .await .with_context(|| format!("failed to load gitignore file at {}", abs_path.display()))?; - let parent = abs_path.parent().unwrap_or_else(|| Path::new("/")); - let mut builder = GitignoreBuilder::new(parent); + let mut builder = GitignoreBuilder::new(root); for line in contents.lines() { builder.add_line(Some(abs_path.into()), line)?; } @@ -5327,7 +5335,12 @@ impl BackgroundScanner { // Load gitignores asynchronously (outside the lock) let mut loaded_excludes: Vec<(Arc, Arc)> = Vec::new(); for (work_dir_abs_path, exclude_abs_path) in excludes_to_load { - if let Ok(current_exclude) = build_gitignore(&exclude_abs_path, self.fs.as_ref()).await + if let Ok(current_exclude) = build_gitignore_with_root( + &exclude_abs_path, + &work_dir_abs_path, + self.fs.as_ref(), + ) + .await { loaded_excludes.push((work_dir_abs_path, Arc::new(current_exclude))); } @@ -5639,7 +5652,9 @@ async fn discover_ancestor_git_repo( let (_, common_dir_abs_path) = discover_git_paths(&dot_git_abs_path, fs.as_ref()).await; let repo_exclude_abs_path = common_dir_abs_path.join(REPO_EXCLUDE); - if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await { + if let Ok(repo_exclude) = + build_gitignore_with_root(&repo_exclude_abs_path, ancestor, fs.as_ref()).await + { exclude = Some(Arc::new(repo_exclude)); } diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index f8e07ffa0b44f7..68b83dd6c9e52e 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -3017,6 +3017,72 @@ async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext }); } +#[gpui::test] +async fn test_repo_exclude_anchored_pattern( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor); + let project_dir = Path::new(path!("/project")); + fs.insert_tree( + project_dir, + json!({ + ".git": { + "info": { + "exclude": "vendor/cache" + } + }, + "vendor": { + "cache": { + "blob.bin": "", + }, + "keep.txt": "", + }, + "elsewhere": { + "vendor": { + "cache": { + "blob.bin": "", + }, + }, + }, + }), + ) + .await; + + let worktree = Worktree::local( + project_dir, + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + worktree + .update(cx, |worktree, _| { + worktree.as_local().unwrap().scan_complete() + }) + .await; + cx.run_until_parked(); + + // An anchored pattern (containing a `/`) is matched relative to the work + // tree root, so only the top-level `vendor/cache` is ignored. + worktree.update(cx, |worktree, _cx| { + check_worktree_entries( + worktree, + WorktreeExpectations { + ignored_paths: &["vendor/cache"], + tracked_paths: &["vendor/keep.txt", "elsewhere/vendor/cache"], + ..Default::default() + }, + ); + }); +} + #[derive(Default)] struct WorktreeExpectations { excluded_paths: &'static [&'static str], From cc866c2ba19e879ddd8b62ce26b243789e6c0286 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 19:17:52 +0000 Subject: [PATCH 2/2] Autofix --- crates/worktree/src/worktree.rs | 15 ++++----------- .../worktree/tests/integration/worktree_tests.rs | 5 +---- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 416180b31bce35..0ee6f0cb310ed1 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -3344,11 +3344,7 @@ async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { build_gitignore_with_root(abs_path, parent, fs).await } -async fn build_gitignore_with_root( - abs_path: &Path, - root: &Path, - fs: &dyn Fs, -) -> Result { +async fn build_gitignore_with_root(abs_path: &Path, root: &Path, fs: &dyn Fs) -> Result { let contents = fs .load(abs_path) .await @@ -5337,12 +5333,9 @@ impl BackgroundScanner { // Load gitignores asynchronously (outside the lock) let mut loaded_excludes: Vec<(Arc, Arc)> = Vec::new(); for (work_dir_abs_path, exclude_abs_path) in excludes_to_load { - if let Ok(current_exclude) = build_gitignore_with_root( - &exclude_abs_path, - &work_dir_abs_path, - self.fs.as_ref(), - ) - .await + if let Ok(current_exclude) = + build_gitignore_with_root(&exclude_abs_path, &work_dir_abs_path, self.fs.as_ref()) + .await { loaded_excludes.push((work_dir_abs_path, Arc::new(current_exclude))); } diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 8616001e4bd175..7691a68392c8f7 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -3018,10 +3018,7 @@ async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext } #[gpui::test] -async fn test_repo_exclude_anchored_pattern( - executor: BackgroundExecutor, - cx: &mut TestAppContext, -) { +async fn test_repo_exclude_anchored_pattern(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(executor);