Add handling of git's core.excludesFile (#33592)

Taking over from #28314.

Part of https://github.com/zed-industries/zed/issues/4824

Co-authored-by: Paul Nameless <reacsdas@gmail.com>

Release Notes:

- Zed now respects git's `core.excludesFile` (~/.config/git/ignore) in
addition to .gitignore.

---------

Co-authored-by: Paul Nameless <reacsdas@gmail.com>
This commit is contained in:
Cole Miller
2025-09-11 21:00:03 -04:00
committed by GitHub
co-authored by Paul Nameless
parent b60e705782
commit 45ee1327a4
15 changed files with 454 additions and 136 deletions
+64 -16
View File
@@ -1,34 +1,60 @@
use ignore::gitignore::Gitignore;
use std::{ffi::OsStr, path::Path, sync::Arc};
#[derive(Clone, Debug)]
pub struct IgnoreStack {
pub repo_root: Option<Arc<Path>>,
pub top: Arc<IgnoreStackEntry>,
}
#[derive(Debug)]
pub enum IgnoreStack {
pub enum IgnoreStackEntry {
None,
Global {
ignore: Arc<Gitignore>,
},
Some {
abs_base_path: Arc<Path>,
ignore: Arc<Gitignore>,
parent: Arc<IgnoreStack>,
parent: Arc<IgnoreStackEntry>,
},
All,
}
impl IgnoreStack {
pub fn none() -> Arc<Self> {
Arc::new(Self::None)
pub fn none() -> Self {
Self {
repo_root: None,
top: Arc::new(IgnoreStackEntry::None),
}
}
pub fn all() -> Arc<Self> {
Arc::new(Self::All)
pub fn all() -> Self {
Self {
repo_root: None,
top: Arc::new(IgnoreStackEntry::All),
}
}
pub fn append(self: Arc<Self>, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Arc<Self> {
match self.as_ref() {
IgnoreStack::All => self,
_ => Arc::new(Self::Some {
pub fn global(ignore: Arc<Gitignore>) -> Self {
Self {
repo_root: None,
top: Arc::new(IgnoreStackEntry::Global { ignore }),
}
}
pub fn append(self, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Self {
let top = match self.top.as_ref() {
IgnoreStackEntry::All => self.top.clone(),
_ => Arc::new(IgnoreStackEntry::Some {
abs_base_path,
ignore,
parent: self,
parent: self.top.clone(),
}),
};
Self {
repo_root: self.repo_root,
top,
}
}
@@ -37,15 +63,37 @@ impl IgnoreStack {
return true;
}
match self {
Self::None => false,
Self::All => true,
Self::Some {
match self.top.as_ref() {
IgnoreStackEntry::None => false,
IgnoreStackEntry::All => true,
IgnoreStackEntry::Global { ignore } => {
let combined_path;
let abs_path = if let Some(repo_root) = self.repo_root.as_ref() {
combined_path = ignore.path().join(
abs_path
.strip_prefix(repo_root)
.expect("repo root should be a parent of matched path"),
);
&combined_path
} else {
abs_path
};
match ignore.matched(abs_path, is_dir) {
ignore::Match::None => false,
ignore::Match::Ignore(_) => true,
ignore::Match::Whitelist(_) => false,
}
}
IgnoreStackEntry::Some {
abs_base_path,
ignore,
parent: prev,
} => match ignore.matched(abs_path.strip_prefix(abs_base_path).unwrap(), is_dir) {
ignore::Match::None => prev.is_abs_path_ignored(abs_path, is_dir),
ignore::Match::None => IgnoreStack {
repo_root: self.repo_root.clone(),
top: prev.clone(),
}
.is_abs_path_ignored(abs_path, is_dir),
ignore::Match::Ignore(_) => true,
ignore::Match::Whitelist(_) => false,
},
+210 -79
View File
@@ -65,7 +65,7 @@ use std::{
use sum_tree::{Bias, Dimensions, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet};
use text::{LineEnding, Rope};
use util::{
ResultExt,
ResultExt, debug_panic,
paths::{PathMatcher, SanitizedPath, home_dir},
};
pub use worktree_settings::WorktreeSettings;
@@ -336,26 +336,10 @@ impl Default for WorkDirectory {
}
}
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
pub struct WorkDirectoryEntry(ProjectEntryId);
impl Deref for WorkDirectoryEntry {
type Target = ProjectEntryId;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<ProjectEntryId> for WorkDirectoryEntry {
fn from(value: ProjectEntryId) -> Self {
WorkDirectoryEntry(value)
}
}
#[derive(Debug, Clone)]
pub struct LocalSnapshot {
snapshot: Snapshot,
global_gitignore: Option<Arc<Gitignore>>,
/// All of the gitignore files in the worktree, indexed by their relative path.
/// The boolean indicates whether the gitignore needs to be updated.
ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
@@ -504,6 +488,7 @@ impl Worktree {
cx.new(move |cx: &mut Context<Worktree>| {
let mut snapshot = LocalSnapshot {
ignores_by_parent_abs_path: Default::default(),
global_gitignore: Default::default(),
git_repositories: Default::default(),
snapshot: Snapshot::new(
cx.entity_id().as_u64(),
@@ -2807,8 +2792,9 @@ impl LocalSnapshot {
inodes
}
fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool) -> Arc<IgnoreStack> {
fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool, fs: &dyn Fs) -> IgnoreStack {
let mut new_ignores = Vec::new();
let mut repo_root = None;
for (index, ancestor) in abs_path.ancestors().enumerate() {
if index > 0 {
if let Some((ignore, _)) = self.ignores_by_parent_abs_path.get(ancestor) {
@@ -2817,12 +2803,21 @@ impl LocalSnapshot {
new_ignores.push((ancestor, None));
}
}
if ancestor.join(*DOT_GIT).exists() {
let metadata = smol::block_on(fs.metadata(&ancestor.join(*DOT_GIT)))
.ok()
.flatten();
if metadata.is_some() {
repo_root = Some(Arc::from(ancestor));
break;
}
}
let mut ignore_stack = IgnoreStack::none();
let mut ignore_stack = if let Some(global_gitignore) = self.global_gitignore.clone() {
IgnoreStack::global(global_gitignore)
} else {
IgnoreStack::none()
};
ignore_stack.repo_root = repo_root;
for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
ignore_stack = IgnoreStack::all();
@@ -2949,9 +2944,15 @@ impl BackgroundScannerState {
.any(|p| entry.path.starts_with(p))
}
fn enqueue_scan_dir(&self, abs_path: Arc<Path>, entry: &Entry, scan_job_tx: &Sender<ScanJob>) {
fn enqueue_scan_dir(
&self,
abs_path: Arc<Path>,
entry: &Entry,
scan_job_tx: &Sender<ScanJob>,
fs: &dyn Fs,
) {
let path = entry.path.clone();
let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true);
let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true, fs);
let mut ancestor_inodes = self.snapshot.ancestor_inodes_for_path(&path);
if !ancestor_inodes.contains(&entry.inode) {
@@ -3860,14 +3861,36 @@ impl BackgroundScanner {
log::trace!("containing git repository: {containing_git_repository:?}");
let global_gitignore_path = paths::global_gitignore_path();
self.state.lock().snapshot.global_gitignore =
if let Some(global_gitignore_path) = global_gitignore_path.as_ref() {
build_gitignore(global_gitignore_path, self.fs.as_ref())
.await
.log_err()
.map(Arc::new)
} else {
None
};
let mut global_gitignore_events = if let Some(global_gitignore_path) = global_gitignore_path
{
self.fs
.watch(&global_gitignore_path, FS_WATCH_LATENCY)
.await
.0
} else {
Box::pin(futures::stream::empty())
};
let (scan_job_tx, scan_job_rx) = channel::unbounded();
{
let mut state = self.state.lock();
state.snapshot.scan_id += 1;
if let Some(mut root_entry) = state.snapshot.root_entry().cloned() {
let ignore_stack = state
.snapshot
.ignore_stack_for_abs_path(root_abs_path.as_path(), true);
let ignore_stack = state.snapshot.ignore_stack_for_abs_path(
root_abs_path.as_path(),
true,
self.fs.as_ref(),
);
if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) {
root_entry.is_ignored = true;
state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref());
@@ -3876,6 +3899,7 @@ impl BackgroundScanner {
SanitizedPath::cast_arc(root_abs_path),
&root_entry,
&scan_job_tx,
self.fs.as_ref(),
);
}
};
@@ -3946,6 +3970,15 @@ impl BackgroundScanner {
}
self.process_events(paths.into_iter().map(Into::into).collect()).await;
}
paths = global_gitignore_events.next().fuse() => {
match paths.as_deref() {
Some([event, ..]) => {
self.update_global_gitignore(&event.path).await;
}
_ => {},
}
}
}
}
}
@@ -4130,11 +4163,20 @@ impl BackgroundScanner {
)
.await;
self.update_ignore_statuses(scan_job_tx).await;
self.scan_dirs(false, scan_job_rx).await;
let affected_repo_roots = if !dot_git_abs_paths.is_empty() {
self.update_git_repositories(dot_git_abs_paths)
} else {
Vec::new()
};
if !dot_git_abs_paths.is_empty() {
self.update_git_repositories(dot_git_abs_paths);
{
let mut ignores_to_update = self.ignores_needing_update();
ignores_to_update.extend(affected_repo_roots);
let ignores_to_update = self.order_ignores(ignores_to_update);
let snapshot = self.state.lock().snapshot.clone();
self.update_ignore_statuses_for_paths(scan_job_tx, snapshot, ignores_to_update)
.await;
self.scan_dirs(false, scan_job_rx).await;
}
{
@@ -4147,6 +4189,32 @@ impl BackgroundScanner {
self.send_status_update(false, SmallVec::new());
}
async fn update_global_gitignore(&self, abs_path: &Path) {
let ignore = build_gitignore(abs_path, self.fs.as_ref())
.await
.log_err()
.map(Arc::new);
let (prev_snapshot, ignore_stack, abs_path) = {
let mut state = self.state.lock();
state.snapshot.global_gitignore = ignore;
let abs_path = state.snapshot.abs_path().clone();
let ignore_stack =
state
.snapshot
.ignore_stack_for_abs_path(&abs_path, true, self.fs.as_ref());
(state.snapshot.clone(), ignore_stack, abs_path)
};
let (scan_job_tx, scan_job_rx) = channel::unbounded();
self.update_ignore_statuses_for_paths(
scan_job_tx,
prev_snapshot,
vec![(abs_path, ignore_stack)].into_iter(),
)
.await;
self.scan_dirs(false, scan_job_rx).await;
self.send_status_update(false, SmallVec::new());
}
async fn forcibly_load_paths(&self, paths: &[Arc<Path>]) -> bool {
let (scan_job_tx, scan_job_rx) = channel::unbounded();
{
@@ -4158,7 +4226,12 @@ impl BackgroundScanner {
&& entry.kind == EntryKind::UnloadedDir
{
let abs_path = root_path.as_path().join(ancestor);
state.enqueue_scan_dir(abs_path.into(), entry, &scan_job_tx);
state.enqueue_scan_dir(
abs_path.into(),
entry,
&scan_job_tx,
self.fs.as_ref(),
);
state.paths_to_scan.insert(path.clone());
break;
}
@@ -4309,6 +4382,12 @@ impl BackgroundScanner {
swap_to_front(&mut child_paths, *GITIGNORE);
swap_to_front(&mut child_paths, *DOT_GIT);
if let Some(path) = child_paths.first()
&& path.ends_with(*DOT_GIT)
{
ignore_stack.repo_root = Some(job.abs_path.clone());
}
for child_abs_path in child_paths {
let child_abs_path: Arc<Path> = child_abs_path.into();
let child_name = child_abs_path.file_name().unwrap();
@@ -4534,9 +4613,11 @@ impl BackgroundScanner {
let abs_path: Arc<Path> = root_abs_path.as_path().join(path).into();
match metadata {
Ok(Some((metadata, canonical_path))) => {
let ignore_stack = state
.snapshot
.ignore_stack_for_abs_path(&abs_path, metadata.is_dir);
let ignore_stack = state.snapshot.ignore_stack_for_abs_path(
&abs_path,
metadata.is_dir,
self.fs.as_ref(),
);
let is_external = !canonical_path.starts_with(&root_canonical_path);
let mut fs_entry = Entry::new(
path.clone(),
@@ -4561,7 +4642,12 @@ impl BackgroundScanner {
|| (fs_entry.path.as_os_str().is_empty()
&& abs_path.file_name() == Some(*DOT_GIT))
{
state.enqueue_scan_dir(abs_path, &fs_entry, scan_queue_tx);
state.enqueue_scan_dir(
abs_path,
&fs_entry,
scan_queue_tx,
self.fs.as_ref(),
);
} else {
fs_entry.kind = EntryKind::UnloadedDir;
}
@@ -4616,43 +4702,15 @@ impl BackgroundScanner {
Some(())
}
async fn update_ignore_statuses(&self, scan_job_tx: Sender<ScanJob>) {
let mut ignores_to_update = Vec::new();
async fn update_ignore_statuses_for_paths(
&self,
scan_job_tx: Sender<ScanJob>,
prev_snapshot: LocalSnapshot,
mut ignores_to_update: impl Iterator<Item = (Arc<Path>, IgnoreStack)>,
) {
let (ignore_queue_tx, ignore_queue_rx) = channel::unbounded();
let prev_snapshot;
{
let snapshot = &mut self.state.lock().snapshot;
let abs_path = snapshot.abs_path.clone();
snapshot
.ignores_by_parent_abs_path
.retain(|parent_abs_path, (_, needs_update)| {
if let Ok(parent_path) = parent_abs_path.strip_prefix(abs_path.as_path()) {
if *needs_update {
*needs_update = false;
if snapshot.snapshot.entry_for_path(parent_path).is_some() {
ignores_to_update.push(parent_abs_path.clone());
}
}
let ignore_path = parent_path.join(*GITIGNORE);
if snapshot.snapshot.entry_for_path(ignore_path).is_none() {
return false;
}
}
true
});
ignores_to_update.sort_unstable();
let mut ignores_to_update = ignores_to_update.into_iter().peekable();
while let Some(parent_abs_path) = ignores_to_update.next() {
while ignores_to_update
.peek()
.is_some_and(|p| p.starts_with(&parent_abs_path))
{
ignores_to_update.next().unwrap();
}
let ignore_stack = snapshot.ignore_stack_for_abs_path(&parent_abs_path, true);
while let Some((parent_abs_path, ignore_stack)) = ignores_to_update.next() {
ignore_queue_tx
.send_blocking(UpdateIgnoreStatusJob {
abs_path: parent_abs_path,
@@ -4662,8 +4720,6 @@ impl BackgroundScanner {
})
.unwrap();
}
prev_snapshot = snapshot.clone();
}
drop(ignore_queue_tx);
@@ -4695,6 +4751,57 @@ impl BackgroundScanner {
.await;
}
fn ignores_needing_update(&self) -> Vec<Arc<Path>> {
let mut ignores_to_update = Vec::new();
{
let snapshot = &mut self.state.lock().snapshot;
let abs_path = snapshot.abs_path.clone();
snapshot
.ignores_by_parent_abs_path
.retain(|parent_abs_path, (_, needs_update)| {
if let Ok(parent_path) = parent_abs_path.strip_prefix(abs_path.as_path()) {
if *needs_update {
*needs_update = false;
if snapshot.snapshot.entry_for_path(parent_path).is_some() {
ignores_to_update.push(parent_abs_path.clone());
}
}
let ignore_path = parent_path.join(*GITIGNORE);
if snapshot.snapshot.entry_for_path(ignore_path).is_none() {
return false;
}
}
true
});
}
ignores_to_update
}
fn order_ignores(
&self,
mut ignores: Vec<Arc<Path>>,
) -> impl use<> + Iterator<Item = (Arc<Path>, IgnoreStack)> {
let fs = self.fs.clone();
let snapshot = self.state.lock().snapshot.clone();
ignores.sort_unstable();
let mut ignores_to_update = ignores.into_iter().peekable();
std::iter::from_fn(move || {
let parent_abs_path = ignores_to_update.next()?;
while ignores_to_update
.peek()
.map_or(false, |p| p.starts_with(&parent_abs_path))
{
ignores_to_update.next().unwrap();
}
let ignore_stack =
snapshot.ignore_stack_for_abs_path(&parent_abs_path, true, fs.as_ref());
Some((parent_abs_path, ignore_stack))
})
}
async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) {
log::trace!("update ignore status {:?}", job.abs_path);
@@ -4710,6 +4817,12 @@ impl BackgroundScanner {
.strip_prefix(snapshot.abs_path.as_path())
.unwrap();
if let Ok(Some(metadata)) = smol::block_on(self.fs.metadata(&job.abs_path.join(*DOT_GIT)))
&& metadata.is_dir
{
ignore_stack.repo_root = Some(job.abs_path.clone());
}
for mut entry in snapshot.child_entries(path).cloned() {
let was_ignored = entry.is_ignored;
let abs_path: Arc<Path> = snapshot.abs_path().join(&entry.path).into();
@@ -4726,7 +4839,12 @@ impl BackgroundScanner {
if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() {
let state = self.state.lock();
if state.should_scan_directory(&entry) {
state.enqueue_scan_dir(abs_path.clone(), &entry, &job.scan_queue);
state.enqueue_scan_dir(
abs_path.clone(),
&entry,
&job.scan_queue,
self.fs.as_ref(),
);
}
}
@@ -4766,10 +4884,11 @@ impl BackgroundScanner {
state.snapshot.entries_by_id.edit(entries_by_id_edits, &());
}
fn update_git_repositories(&self, dot_git_paths: Vec<PathBuf>) {
fn update_git_repositories(&self, dot_git_paths: Vec<PathBuf>) -> Vec<Arc<Path>> {
log::trace!("reloading repositories: {dot_git_paths:?}");
let mut state = self.state.lock();
let scan_id = state.snapshot.scan_id;
let mut affected_repo_roots = Vec::new();
for dot_git_dir in dot_git_paths {
let existing_repository_entry =
state
@@ -4791,8 +4910,12 @@ impl BackgroundScanner {
match existing_repository_entry {
None => {
let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else {
return;
debug_panic!(
"update_git_repositories called with .git directory outside the worktree root"
);
return Vec::new();
};
affected_repo_roots.push(dot_git_dir.parent().unwrap().into());
state.insert_git_repository(
relative.into(),
self.fs.as_ref(),
@@ -4830,7 +4953,15 @@ impl BackgroundScanner {
snapshot
.git_repositories
.retain(|work_directory_id, _| ids_to_preserve.contains(work_directory_id));
.retain(|work_directory_id, entry| {
let preserve = ids_to_preserve.contains(work_directory_id);
if !preserve {
affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into());
}
preserve
});
affected_repo_roots
}
async fn progress_timer(&self, running: bool) {
@@ -4870,7 +5001,7 @@ async fn discover_ancestor_git_repo(
let mut ignores = HashMap::default();
for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
if index != 0 {
if Some(ancestor) == fs.home_dir().as_deref() {
if ancestor == paths::home_dir() {
// Unless $HOME is itself the worktree root, don't consider it as a
// containing git repository---expensive and likely unwanted.
break;
@@ -5052,7 +5183,7 @@ fn char_bag_for_path(root_char_bag: CharBag, path: &Path) -> CharBag {
struct ScanJob {
abs_path: Arc<Path>,
path: Arc<Path>,
ignore_stack: Arc<IgnoreStack>,
ignore_stack: IgnoreStack,
scan_queue: Sender<ScanJob>,
ancestor_inodes: TreeSet<u64>,
is_external: bool,
@@ -5060,7 +5191,7 @@ struct ScanJob {
struct UpdateIgnoreStatusJob {
abs_path: Arc<Path>,
ignore_stack: Arc<IgnoreStack>,
ignore_stack: IgnoreStack,
ignore_queue: Sender<UpdateIgnoreStatusJob>,
scan_queue: Sender<ScanJob>,
}
+111 -1
View File
@@ -2120,7 +2120,6 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA
});
pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
eprintln!(">>>>>>>>>> touch");
fs.touch_path(path!("/root/subproject")).await;
worktree
.update(cx, |worktree, _| {
@@ -2141,6 +2140,117 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA
pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
}
#[gpui::test]
async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let home = paths::home_dir();
let fs = FakeFs::new(executor);
fs.insert_tree(
home,
json!({
".config": {
"git": {
"ignore": "foo\n/bar\nbaz\n"
}
},
"project": {
".git": {},
".gitignore": "!baz",
"foo": "",
"bar": "",
"sub": {
"bar": "",
},
"subrepo": {
".git": {},
"bar": ""
},
"baz": ""
}
}),
)
.await;
let worktree = Worktree::local(
home.join("project"),
true,
fs.clone(),
Arc::default(),
&mut cx.to_async(),
)
.await
.unwrap();
worktree
.update(cx, |worktree, _| {
worktree.as_local().unwrap().scan_complete()
})
.await;
cx.run_until_parked();
// .gitignore overrides excludesFile, and anchored paths in excludesFile are resolved
// relative to the nearest containing repository
worktree.update(cx, |worktree, _cx| {
check_worktree_entries(
worktree,
&[],
&["foo", "bar", "subrepo/bar"],
&["sub/bar", "baz"],
&[],
);
});
// Ignore statuses are updated when excludesFile changes
fs.write(
&home.join(".config").join("git").join("ignore"),
"/bar\nbaz\n".as_bytes(),
)
.await
.unwrap();
worktree
.update(cx, |worktree, _| {
worktree.as_local().unwrap().scan_complete()
})
.await;
cx.run_until_parked();
worktree.update(cx, |worktree, _cx| {
check_worktree_entries(
worktree,
&[],
&["bar", "subrepo/bar"],
&["foo", "sub/bar", "baz"],
&[],
);
});
// Statuses are updated when .git added/removed
fs.remove_dir(
&home.join("project").join("subrepo").join(".git"),
RemoveOptions {
recursive: true,
..Default::default()
},
)
.await
.unwrap();
worktree
.update(cx, |worktree, _| {
worktree.as_local().unwrap().scan_complete()
})
.await;
cx.run_until_parked();
worktree.update(cx, |worktree, _cx| {
check_worktree_entries(
worktree,
&[],
&["bar"],
&["foo", "sub/bar", "baz", "subrepo/bar"],
&[],
);
});
}
#[track_caller]
fn check_worktree_entries(
tree: &Worktree,