fix(tui): skip hidden worktrees in discovery walks
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user