From a579c0721376da2be60b11e1ee78e122ae1143d9 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Wed, 22 Jul 2026 18:44:46 -0700 Subject: [PATCH 1/2] worktree: Add failing test for repo excludes within nested repositories The outer repository's .git/info/exclude rules are dropped when an entry's ignore stack is rebuilt from scratch for a path inside a nested repository, because ignore_stack_for_abs_path only consults the exclude file of the nearest ancestor containing .git. --- .../tests/integration/worktree_tests.rs | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index a9a673d10e7a55..6e2c2a947051fd 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -3819,6 +3819,161 @@ async fn test_repo_exclude_anchored_pattern(executor: BackgroundExecutor, cx: &m }); } +#[gpui::test] +async fn test_repo_exclude_applies_within_nested_repos( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor); + let project_dir = Path::new(path!("/project")); + + // Mirrors the layout used by tools that keep working copies of the + // repository inside the repository itself: a bare clone in + // `.scratch/clones` and a linked worktree of that clone in + // `.scratch/worktrees`, both hidden via the outer repository's + // `.git/info/exclude` rather than a `.gitignore`. + fs.insert_tree( + project_dir, + json!({ + ".git": { + "info": { + "exclude": "/.scratch/worktrees/\n/.scratch/clones/\n" + } + }, + "src": { + "main.rs": "fn main() {}", + }, + ".scratch": { + "clones": { + "abc": { + "project.git": { + "HEAD": "ref: refs/heads/main", + "worktrees": { + "project": { + "HEAD": "ref: refs/heads/feature", + "commondir": "../..", + } + } + } + } + }, + "worktrees": { + "abc": { + "project": { + ".git": "gitdir: ../../../clones/abc/project.git/worktrees/project", + "src": { + "main.rs": "fn main() {}", + } + } + } + } + } + }), + ) + .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(); + + // After the initial scan, both excluded directories are ignored. + worktree.update(cx, |worktree, _cx| { + check_worktree_entries( + worktree, + WorktreeExpectations { + ignored_paths: &[".scratch/clones", ".scratch/worktrees"], + tracked_paths: &["src/main.rs"], + ..Default::default() + }, + ); + }); + + // Load a file within the excluded nested repository, as happens when a + // search that includes ignored files runs or when the file is opened. + worktree + .update(cx, |worktree, _| { + worktree.as_local().unwrap().refresh_entries_for_paths(vec![ + rel_path(".scratch/worktrees/abc/project/src/main.rs").into(), + ]) + }) + .recv() + .await; + cx.run_until_parked(); + + // The nested repository's own `.git` must not cause the outer + // repository's `info/exclude` rules to be dropped. + worktree.update(cx, |worktree, _cx| { + check_worktree_entries( + worktree, + WorktreeExpectations { + ignored_paths: &[ + ".scratch/worktrees/abc", + ".scratch/worktrees/abc/project", + ".scratch/worktrees/abc/project/src", + ".scratch/worktrees/abc/project/src/main.rs", + ], + tracked_paths: &["src/main.rs"], + ..Default::default() + }, + ); + }); + + // A file written inside the loaded nested repository (e.g. by a tool + // working in the clone) must also be ignored. + fs.save( + path!("/project/.scratch/worktrees/abc/project/src/generated.rs").as_ref(), + &"fn generated() {}".into(), + Default::default(), + ) + .await + .unwrap(); + cx.run_until_parked(); + + worktree.update(cx, |worktree, _cx| { + check_worktree_entries( + worktree, + WorktreeExpectations { + ignored_paths: &[".scratch/worktrees/abc/project/src/generated.rs"], + ..Default::default() + }, + ); + }); + + // Nothing under the excluded directories is visible to a traversal that + // skips ignored entries, which is what project search uses. + worktree.update(cx, |worktree, _cx| { + let unignored_entries = worktree + .entries(false, 0) + .filter(|entry| { + entry.path.starts_with(rel_path(".scratch")) + && entry.path.as_ref() != rel_path(".scratch") + }) + .map(|entry| entry.path.clone()) + .collect::>(); + assert_eq!( + unignored_entries, + Vec::>::new(), + "entries under the excluded .scratch directories leaked into the unignored traversal", + ); + }); +} + #[derive(Default)] struct WorktreeExpectations { excluded_paths: &'static [&'static str], From e21d015b80b3081454a13e309457e3bf9ecae72a Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Wed, 22 Jul 2026 18:54:04 -0700 Subject: [PATCH 2/2] worktree: Apply outer repository excludes within nested repositories When rebuilding an entry's ignore stack from scratch, only the info/exclude file of the nearest ancestor containing .git was consulted. For paths inside a nested repository, that nearest ancestor is the nested repository itself, so the outer repository's exclude rules were silently dropped and previously-ignored entries became unignored whenever they were loaded or changed on disk. Collect the info/exclude rules of every containing repository instead, outermost first, mirroring how ancestor .gitignore files are stacked. --- crates/worktree/src/worktree.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index ee83eb6544982d..1edef7e5b66802 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -3055,6 +3055,7 @@ impl LocalSnapshot { fs: &dyn Fs, ) -> IgnoreStack { let mut new_ignores = Vec::new(); + let mut repo_excludes = Vec::new(); let mut repo_root = None; for (index, ancestor) in abs_path.ancestors().enumerate() { if index > 0 { @@ -3065,6 +3066,13 @@ impl LocalSnapshot { } } + // Collect the `info/exclude` rules of every containing repository, not just + // the innermost one: a nested repository's files are still governed by the + // exclude rules of the outer repository that contains it. + if let Some((repo_exclude, _)) = self.repo_exclude_by_work_dir_abs_path.get(ancestor) { + repo_excludes.push(repo_exclude.clone()); + } + if repo_root.is_none() { let metadata = fs.metadata(&ancestor.join(DOT_GIT)).await.ok().flatten(); if metadata.is_some() { @@ -3079,11 +3087,8 @@ impl LocalSnapshot { IgnoreStack::none() }; - if let Some((repo_exclude, _)) = repo_root - .as_ref() - .and_then(|abs_path| self.repo_exclude_by_work_dir_abs_path.get(abs_path)) - { - ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone()); + for repo_exclude in repo_excludes.into_iter().rev() { + ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude); } ignore_stack.repo_root = repo_root; let mut ancestor_ignore_stack = ignore_stack.clone();