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
15 changes: 10 additions & 5 deletions crates/worktree/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand All @@ -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();
Expand Down
155 changes: 155 additions & 0 deletions crates/worktree/tests/integration/worktree_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
assert_eq!(
unignored_entries,
Vec::<Arc<RelPath>>::new(),
"entries under the excluded .scratch directories leaked into the unignored traversal",
);
});
}

#[derive(Default)]
struct WorktreeExpectations {
excluded_paths: &'static [&'static str],
Expand Down
Loading