fix(tui): skip hidden worktrees in discovery walks

This commit is contained in:
Hunter Bown
2026-05-27 05:43:05 -05:00
parent da590e5528
commit cb7614a918
4 changed files with 194 additions and 48 deletions
+1
View File
@@ -78,6 +78,7 @@ mod tui;
mod utils;
mod vision;
mod working_set;
mod workspace_discovery;
mod workspace_trust;
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
+57 -2
View File
@@ -24,6 +24,7 @@ use ratatui::{
use crate::palette;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery};
/// Maximum number of candidates collected from the initial walk. Keeps memory
/// bounded for very large monorepos; matches the limits codex-rs uses for the
@@ -437,7 +438,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
// 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"] {
for dir in DISCOVERY_ALWAYS_DIRS {
let dot_dir = root.join(dir);
if !dot_dir.is_dir() {
continue;
@@ -451,7 +452,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
.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")) {
if path_is_excluded_from_discovery(root, entry.path()) {
continue;
}
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
@@ -733,4 +734,58 @@ mod tests {
"skipme.txt should be filtered by .ignore: {visible:?}"
);
}
#[test]
fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
fs::write(
root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
"pack",
)
.unwrap();
fs::create_dir_all(root.join(".claude/commands")).unwrap();
fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
fs::write(
root.join(".claude/worktrees/agent/src/agent-only.md"),
"agent",
)
.unwrap();
let candidates = collect_candidates(root);
assert!(candidates.iter().any(|path| path == "src/main.rs"));
assert!(
candidates
.iter()
.any(|path| path == ".deepseek/commands/build.md"),
"normal .deepseek command files should stay discoverable: {candidates:?}",
);
assert!(
candidates
.iter()
.any(|path| path == ".claude/commands/test.md"),
"normal .claude command files should stay discoverable: {candidates:?}",
);
assert!(
candidates
.iter()
.all(|path| !path.starts_with(".deepseek/snapshots/")),
"snapshot side repo files must not enter picker candidates: {candidates:?}",
);
assert!(
candidates
.iter()
.all(|path| !path.starts_with(".claude/worktrees/")),
".claude worktree files must not enter picker candidates: {candidates:?}",
);
}
}
+83 -46
View File
@@ -7,6 +7,9 @@
//! - pinned message indices that compaction should preserve
use crate::models::{ContentBlock, Message};
use crate::workspace_discovery::{
DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery, should_skip_unignored_discovery_entry,
};
use ignore::WalkBuilder;
use regex::Regex;
use serde::{Deserialize, Serialize};
@@ -269,32 +272,6 @@ const COMPLETIONS_WALK_DEPTH: usize = 6;
/// above the actual entry count and the cap is a no-op.
const FILE_INDEX_MAX_ENTRIES: usize = 50_000;
/// 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
@@ -494,7 +471,10 @@ fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
.git_global(false)
.git_exclude(false);
let _ = builder.add_custom_ignore_filename(".deepseekignore");
builder.filter_entry(|entry| !should_skip_local_reference_dir(entry.path()));
let root_for_filter = root.to_path_buf();
builder.filter_entry(move |entry| {
!should_skip_unignored_discovery_entry(&root_for_filter, entry.path())
});
for entry in builder.build().flatten() {
if out.len() >= limit {
@@ -514,25 +494,6 @@ fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
out
}
fn should_skip_local_reference_dir(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
matches!(
name,
".git"
| "target"
| "node_modules"
| ".venv"
| "venv"
| "env"
| "dist"
| "build"
| "__pycache__"
| ".ruff_cache"
)
}
impl Clone for Workspace {
fn clone(&self) -> Self {
// Don't carry the cached file_index — clones get a fresh OnceLock so
@@ -1523,6 +1484,82 @@ mod tests {
);
}
#[test]
fn workspace_completions_skip_hidden_worktrees_and_build_bulk() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join(".gitignore"), ".worktrees/\n.generated/\n").unwrap();
std::fs::create_dir_all(root.join(".worktrees/release/src")).unwrap();
std::fs::write(
root.join(".worktrees/release/src/worktree-only.rs"),
"fn main() {}",
)
.unwrap();
std::fs::create_dir_all(root.join(".worktrees/release/target/debug")).unwrap();
std::fs::write(
root.join(".worktrees/release/target/debug/generated.o"),
"object",
)
.unwrap();
std::fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
std::fs::write(
root.join(".claude/worktrees/agent/src/agent-only.md"),
"agent note",
)
.unwrap();
std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
std::fs::write(root.join(".claude/commands/keep.md"), "command").unwrap();
std::fs::create_dir_all(root.join(".generated/specs")).unwrap();
std::fs::write(root.join(".generated/specs/device-layout.md"), "layout").unwrap();
let ws = Workspace::with_cwd(root.to_path_buf(), Some(root.to_path_buf()));
let worktree_entries = ws.completions(".worktrees", 32);
assert!(
worktree_entries
.iter()
.all(|entry| !entry.starts_with(".worktrees/")),
"hidden release worktrees must stay out of completions: {worktree_entries:?}",
);
let claude_worktree_entries = ws.completions(".claude/worktrees", 32);
assert!(
claude_worktree_entries
.iter()
.all(|entry| !entry.starts_with(".claude/worktrees/")),
".claude/worktrees must stay out of completions: {claude_worktree_entries:?}",
);
let generated_entries = ws.completions(".generated/specs", 32);
assert!(
generated_entries
.iter()
.any(|entry| entry == ".generated/specs/device-layout.md"),
"explicit user-generated hidden folders should still complete: {generated_entries:?}",
);
let command_entries = ws.completions(".claude/commands", 32);
assert!(
command_entries
.iter()
.any(|entry| entry == ".claude/commands/keep.md"),
"normal .claude command files should still complete: {command_entries:?}",
);
assert!(
ws.resolve("worktree-only.rs").is_err(),
"fuzzy resolution must not index files from hidden release worktrees"
);
assert!(
ws.resolve("agent-only.md").is_err(),
"fuzzy resolution must not index files from .claude/worktrees"
);
assert!(ws.resolve("keep.md").is_ok());
}
#[test]
fn fuzzy_index_resolves_hidden_and_ignored_files_except_deepseekignored() {
let tmp = TempDir::new().unwrap();
+53
View File
@@ -0,0 +1,53 @@
//! Shared workspace discovery filters for UI path pickers and mentions.
use std::path::Path;
/// Directories that must remain discoverable for `@`-mention completion and
/// fuzzy file resolution even when excluded by `.gitignore`.
pub(crate) const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
/// Root-relative directories that are too large or generated to discover
/// with gitignore disabled. Exact user-specified paths may still resolve.
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] =
&[".deepseek/snapshots", ".worktrees", ".claude/worktrees"];
/// Directory basenames that should not be traversed by fallback discovery
/// walks that deliberately disable gitignore.
const DISCOVERY_EXCLUDED_DIR_NAMES: &[&str] = &[
".git",
"target",
"node_modules",
".venv",
"venv",
"env",
"dist",
"build",
".next",
".turbo",
"coverage",
"__pycache__",
".pytest_cache",
".ruff_cache",
];
/// Check whether `path` is under a root-relative excluded discovery subtree.
pub(crate) fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
DISCOVERY_EXCLUDED_SUBDIRS
.iter()
.any(|excluded| path.starts_with(walk_root.join(excluded)))
}
/// Filter for walks that turn off gitignore to surface explicit hidden paths.
pub(crate) fn should_skip_unignored_discovery_entry(walk_root: &Path, path: &Path) -> bool {
if path == walk_root {
return false;
}
if path_is_excluded_from_discovery(walk_root, path) {
return true;
}
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| DISCOVERY_EXCLUDED_DIR_NAMES.contains(&name))
}