fix(walk): include AI-tool dot-dirs in @-completion despite gitignore

Files inside .deepseek/, .cursor/, .claude/, and .agents/ were invisible
to @-mention Tab-completion when those directories were listed in
.gitignore (the common case). The walk_for_completions and
build_file_index paths now walk these directories separately with
gitignore disabled, merging results into the main candidate list.

Also excludes .deepseek/snapshots/ from the dot-dir walk so the snapshot
side repo (which can reach hundreds of GB, see #1112) is never indexed.

The fix applies to:
  - @-mention Tab-completion (walk_for_completions)
  - Fuzzy file resolution (build_file_index)
  - Ctrl+P file picker (collect_candidates)
This commit is contained in:
Hunter Bown
2026-05-09 00:11:40 -05:00
parent 8dcb467bf5
commit 408708f13e
2 changed files with 299 additions and 11 deletions
+38
View File
@@ -434,6 +434,44 @@ fn collect_candidates(root: &Path) -> Vec<String> {
break;
}
}
// Whitelist AI-tool dot-directories so they're discoverable even when
// gitignored. Walk each one separately with gitignore disabled.
for dir in [".deepseek", ".cursor", ".claude", ".agents"] {
let dot_dir = root.join(dir);
if !dot_dir.is_dir() {
continue;
}
let mut dot_builder = WalkBuilder::new(&dot_dir);
dot_builder
.hidden(true)
.follow_links(false)
.git_ignore(false)
.ignore(false)
.max_depth(Some(WALK_DEPTH.saturating_sub(1)));
for entry in dot_builder.build().flatten() {
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
if entry.path().starts_with(root.join(".deepseek/snapshots")) {
continue;
}
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path);
if rel.as_os_str().is_empty() {
continue;
}
let display = path_to_workspace_string(rel);
if !display.is_empty() {
out.push(display);
}
if out.len() >= MAX_CANDIDATES {
break;
}
}
}
out.sort();
out
}
+261 -11
View File
@@ -93,11 +93,7 @@ impl Workspace {
fn build_file_index(&self) -> HashMap<String, Vec<PathBuf>> {
let mut index: HashMap<String, Vec<PathBuf>> = HashMap::new();
let mut builder = WalkBuilder::new(&self.root);
builder.hidden(true).follow_links(false).max_depth(Some(6));
// Honor `.deepseekignore` in addition to the defaults the `ignore` crate
// already respects (`.gitignore`, `.git/info/exclude`, `.ignore`).
let _ = builder.add_custom_ignore_filename(".deepseekignore");
let builder = discovery_walk_builder(&self.root, Some(6));
for entry in builder.build().flatten() {
if entry
@@ -111,6 +107,37 @@ impl Workspace {
.push(entry.path().to_path_buf());
}
}
// Also index AI-tool dot-directories with gitignore disabled.
for dir_name in DISCOVERY_ALWAYS_DIRS {
let dot_dir = self.root.join(dir_name);
if !dot_dir.is_dir() {
continue;
}
let mut dot_builder = WalkBuilder::new(&dot_dir);
dot_builder
.hidden(true)
.follow_links(false)
.git_ignore(false)
.ignore(false)
.max_depth(Some(5));
for entry in dot_builder.build().flatten() {
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
if path_is_excluded_from_discovery(&self.root, entry.path()) {
continue;
}
if entry
.file_type()
.is_some_and(|ft| ft.is_file() || ft.is_dir())
{
let name = entry.file_name().to_string_lossy().to_lowercase();
index
.entry(name)
.or_default()
.push(entry.path().to_path_buf());
}
}
}
index
}
@@ -180,6 +207,111 @@ impl Workspace {
/// monorepos.
const COMPLETIONS_WALK_DEPTH: usize = 6;
/// Directories that must remain discoverable for `@`-mention completion and
/// fuzzy file resolution even when excluded by `.gitignore`. AI-tool
/// convention directories (`.deepseek/`, `.cursor/`, `.claude/`, `.agents/`)
/// are routinely gitignored, but users need to `@`-mention files inside them.
const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
/// Subdirectories under `DISCOVERY_ALWAYS_DIRS` that must NOT be indexed
/// even when the parent dir is walked with gitignore disabled. These are
/// large, machine-generated, or sensitive paths that would blow up the
/// walker (e.g. `.deepseek/snapshots/` — the snapshot side repo that
/// #1112 caps at 500 MB; indexing it would trigger the same OOM/hang
/// the cap was built to prevent).
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] = &[".deepseek/snapshots"];
/// Check whether a path resolved against `walk_root` falls inside any
/// `DISCOVERY_EXCLUDED_SUBDIRS` entry. Used to keep the snapshot side
/// repo (`.deepseek/snapshots/`) out of the completion/index walk.
fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
for excluded in DISCOVERY_EXCLUDED_SUBDIRS {
if path.starts_with(walk_root.join(excluded)) {
return true;
}
}
false
}
/// Configure a `WalkBuilder` for workspace discovery: hidden files, no
/// symlink following, depth-limited, custom `.deepseekignore` honored,
/// and gitignore overrides for AI-tool dot-directories so `@`-completion
/// finds them even when they're gitignored.
fn discovery_walk_builder(root: &Path, max_depth: Option<usize>) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
builder.hidden(true).follow_links(false);
if let Some(depth) = max_depth {
builder.max_depth(Some(depth));
}
let _ = builder.add_custom_ignore_filename(".deepseekignore");
builder
}
/// Walk the AI-tool dot-directories (`.deepseek/`, `.cursor/`, `.claude/`,
/// `.agents/`) with gitignore disabled so their contents are discoverable
/// even when the project's `.gitignore` / `.ignore` excludes them.
#[allow(clippy::too_many_arguments)]
fn walk_always_discoverable_dirs(
walk_root: &Path,
display_root: &Path,
needle: &str,
limit: usize,
prefix_hits: &mut Vec<String>,
substring_hits: &mut Vec<String>,
seen: &mut HashSet<PathBuf>,
max_depth: Option<usize>,
) {
for dir_name in DISCOVERY_ALWAYS_DIRS {
let dot_dir = walk_root.join(dir_name);
if !dot_dir.is_dir() {
continue;
}
let mut builder = WalkBuilder::new(&dot_dir);
builder
.hidden(true)
.follow_links(false)
.git_ignore(false)
.ignore(false);
if let Some(depth) = max_depth {
builder.max_depth(Some(depth.saturating_sub(1)));
}
for entry in builder.build().flatten() {
if prefix_hits.len() + substring_hits.len() >= limit {
break;
}
let path = entry.path();
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/)
// even though gitignore is disabled for this walk.
if path_is_excluded_from_discovery(walk_root, path) {
continue;
}
let Ok(rel) = path.strip_prefix(display_root) else {
continue;
};
let rel_str = rel.to_string_lossy().replace('\\', "/");
if rel_str.is_empty() {
continue;
}
let abs = path.to_path_buf();
if !seen.insert(abs) {
continue;
}
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let candidate = if is_dir {
format!("{rel_str}/")
} else {
rel_str.clone()
};
let lower = candidate.to_lowercase();
if needle.is_empty() || lower.starts_with(needle) {
prefix_hits.push(candidate);
} else if lower.contains(needle) {
substring_hits.push(candidate);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn walk_for_completions(
walk_root: &Path,
@@ -190,12 +322,7 @@ fn walk_for_completions(
substring_hits: &mut Vec<String>,
seen: &mut HashSet<PathBuf>,
) {
let mut builder = WalkBuilder::new(walk_root);
builder
.hidden(true)
.follow_links(false)
.max_depth(Some(COMPLETIONS_WALK_DEPTH));
let _ = builder.add_custom_ignore_filename(".deepseekignore");
let builder = discovery_walk_builder(walk_root, Some(COMPLETIONS_WALK_DEPTH));
for entry in builder.build().flatten() {
if prefix_hits.len() + substring_hits.len() >= limit {
@@ -228,6 +355,19 @@ fn walk_for_completions(
substring_hits.push(candidate);
}
}
// Also walk the AI-tool dot-directories with gitignore disabled so
// `.deepseek/`, `.cursor/`, etc. are always discoverable.
walk_always_discoverable_dirs(
walk_root,
display_root,
needle,
limit,
prefix_hits,
substring_hits,
seen,
Some(COMPLETIONS_WALK_DEPTH),
);
}
impl Clone for Workspace {
@@ -1195,4 +1335,114 @@ mod tests {
// Index was populated exactly once (subsequent lookups reuse it).
assert!(ws.file_index.get().is_some());
}
/// Regression: `@`-mention completion must discover files inside
/// `.deepseek/`, `.cursor/`, `.claude/`, `.agents/` even when
/// those directories are excluded by `.gitignore` (or `.ignore`).
/// The `discovery_walk_builder` override un-ignores them.
#[test]
fn completions_discovers_files_inside_gitignored_dot_dirs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
// `.ignore` works even outside a git repo; use it to simulate
// a project that gitignores its AI-tool dot-directories.
std::fs::write(
root.join(".ignore"),
".deepseek/\n.cursor/\n.claude/\n.agents/\n",
)
.unwrap();
// Create files inside each dot-dir.
std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
std::fs::create_dir_all(root.join(".cursor/commands")).unwrap();
std::fs::write(root.join(".cursor/commands/run.md"), "run cmd").unwrap();
std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
std::fs::write(root.join(".claude/commands/test.md"), "test cmd").unwrap();
std::fs::create_dir_all(root.join(".agents/skills/example")).unwrap();
std::fs::write(
root.join(".agents/skills/example/SKILL.md"),
"name: example\n",
)
.unwrap();
let ws = Workspace::with_cwd(root.to_path_buf(), None);
// Completions should find entries inside the dot-dirs.
{
let entries = ws.completions("build", 16);
assert!(
entries.iter().any(|e| e.contains("build.md")),
"expected build.md in completions although .deepseek/ is ignored; got: {entries:?}"
);
}
{
let entries = ws.completions("run", 16);
assert!(
entries.iter().any(|e| e.contains("run.md")),
"expected run.md from .cursor/; got: {entries:?}"
);
}
{
let entries = ws.completions("test", 16);
assert!(
entries.iter().any(|e| e.contains("test.md")),
"expected test.md from .claude/; got: {entries:?}"
);
}
// Fuzzy resolution should also work.
let f = ws.resolve("build.md").unwrap();
assert!(f.ends_with("build.md"));
let f2 = ws.resolve("SKILL.md").unwrap();
assert!(f2.ends_with("SKILL.md"));
}
/// Regression: the dot-dir walk must NOT index `.deepseek/snapshots/`,
/// which is the snapshot side repo that can grow to hundreds of GB.
/// Indexing it would re-create the same OOM/hang that #1112 was built
/// to prevent.
#[test]
fn dot_dir_walk_excludes_snapshot_side_repo() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
// Create a snapshot-like directory tree.
std::fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects"))
.unwrap();
std::fs::write(
root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects/snapshot.pack"),
b"fake pack data",
)
.unwrap();
// Also create a legitimate file in .deepseek/ that should be found.
std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
let ws = Workspace::with_cwd(root.to_path_buf(), None);
// Searching for "build" must find build.md.
let entries = ws.completions("build", 16);
assert!(
entries.iter().any(|e| e.contains("build.md")),
"build.md must still be found; got: {entries:?}"
);
// Searching for "snapshot" must NOT return snapshot files.
let snap_entries = ws.completions("snapshot", 16);
assert!(
!snap_entries.iter().any(|e| e.contains("snapshot")),
"snapshot files must NOT appear in completions; got: {snap_entries:?}"
);
// Fuzzy index must also exclude snapshots.
let f = ws.resolve("build.md").unwrap();
assert!(f.ends_with("build.md"));
// snapshot.pack should NOT resolve.
let result = ws.resolve("snapshot.pack");
assert!(
result.is_err(),
"snapshot.pack must not resolve via fuzzy index"
);
}
}