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:
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user