Skip to content

Commit 0e78e78

Browse files
Hmbowndonglovejava
authored andcommitted
fix(tui): skip hidden worktrees in discovery walks
1 parent 54151a4 commit 0e78e78

4 files changed

Lines changed: 194 additions & 48 deletions

File tree

crates/tui/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ mod tui;
7878
mod utils;
7979
mod vision;
8080
mod working_set;
81+
mod workspace_discovery;
8182
mod workspace_trust;
8283

8384
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};

crates/tui/src/tui/file_picker.rs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use ratatui::{
2424

2525
use crate::palette;
2626
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
27+
use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery};
2728

2829
/// Maximum number of candidates collected from the initial walk. Keeps memory
2930
/// bounded for very large monorepos; matches the limits codex-rs uses for the
@@ -437,7 +438,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
437438

438439
// Whitelist AI-tool dot-directories so they're discoverable even when
439440
// gitignored. Walk each one separately with gitignore disabled.
440-
for dir in [".deepseek", ".cursor", ".claude", ".agents"] {
441+
for dir in DISCOVERY_ALWAYS_DIRS {
441442
let dot_dir = root.join(dir);
442443
if !dot_dir.is_dir() {
443444
continue;
@@ -451,7 +452,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
451452
.max_depth(Some(WALK_DEPTH.saturating_sub(1)));
452453
for entry in dot_builder.build().flatten() {
453454
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
454-
if entry.path().starts_with(root.join(".deepseek/snapshots")) {
455+
if path_is_excluded_from_discovery(root, entry.path()) {
455456
continue;
456457
}
457458
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
@@ -733,4 +734,58 @@ mod tests {
733734
"skipme.txt should be filtered by .ignore: {visible:?}"
734735
);
735736
}
737+
738+
#[test]
739+
fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
740+
let dir = TempDir::new().expect("tempdir");
741+
let root = dir.path();
742+
fs::create_dir_all(root.join("src")).unwrap();
743+
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
744+
745+
fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
746+
fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
747+
fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
748+
fs::write(
749+
root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
750+
"pack",
751+
)
752+
.unwrap();
753+
754+
fs::create_dir_all(root.join(".claude/commands")).unwrap();
755+
fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
756+
fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
757+
fs::write(
758+
root.join(".claude/worktrees/agent/src/agent-only.md"),
759+
"agent",
760+
)
761+
.unwrap();
762+
763+
let candidates = collect_candidates(root);
764+
765+
assert!(candidates.iter().any(|path| path == "src/main.rs"));
766+
assert!(
767+
candidates
768+
.iter()
769+
.any(|path| path == ".deepseek/commands/build.md"),
770+
"normal .deepseek command files should stay discoverable: {candidates:?}",
771+
);
772+
assert!(
773+
candidates
774+
.iter()
775+
.any(|path| path == ".claude/commands/test.md"),
776+
"normal .claude command files should stay discoverable: {candidates:?}",
777+
);
778+
assert!(
779+
candidates
780+
.iter()
781+
.all(|path| !path.starts_with(".deepseek/snapshots/")),
782+
"snapshot side repo files must not enter picker candidates: {candidates:?}",
783+
);
784+
assert!(
785+
candidates
786+
.iter()
787+
.all(|path| !path.starts_with(".claude/worktrees/")),
788+
".claude worktree files must not enter picker candidates: {candidates:?}",
789+
);
790+
}
736791
}

crates/tui/src/working_set.rs

Lines changed: 83 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
//! - pinned message indices that compaction should preserve
88
99
use crate::models::{ContentBlock, Message};
10+
use crate::workspace_discovery::{
11+
DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery, should_skip_unignored_discovery_entry,
12+
};
1013
use ignore::WalkBuilder;
1114
use regex::Regex;
1215
use serde::{Deserialize, Serialize};
@@ -269,32 +272,6 @@ const COMPLETIONS_WALK_DEPTH: usize = 6;
269272
/// above the actual entry count and the cap is a no-op.
270273
const FILE_INDEX_MAX_ENTRIES: usize = 50_000;
271274

272-
/// Directories that must remain discoverable for `@`-mention completion and
273-
/// fuzzy file resolution even when excluded by `.gitignore`. AI-tool
274-
/// convention directories (`.deepseek/`, `.cursor/`, `.claude/`, `.agents/`)
275-
/// are routinely gitignored, but users need to `@`-mention files inside them.
276-
const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
277-
278-
/// Subdirectories under `DISCOVERY_ALWAYS_DIRS` that must NOT be indexed
279-
/// even when the parent dir is walked with gitignore disabled. These are
280-
/// large, machine-generated, or sensitive paths that would blow up the
281-
/// walker (e.g. `.deepseek/snapshots/` — the snapshot side repo that
282-
/// #1112 caps at 500 MB; indexing it would trigger the same OOM/hang
283-
/// the cap was built to prevent).
284-
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] = &[".deepseek/snapshots"];
285-
286-
/// Check whether a path resolved against `walk_root` falls inside any
287-
/// `DISCOVERY_EXCLUDED_SUBDIRS` entry. Used to keep the snapshot side
288-
/// repo (`.deepseek/snapshots/`) out of the completion/index walk.
289-
fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
290-
for excluded in DISCOVERY_EXCLUDED_SUBDIRS {
291-
if path.starts_with(walk_root.join(excluded)) {
292-
return true;
293-
}
294-
}
295-
false
296-
}
297-
298275
/// Configure a `WalkBuilder` for workspace discovery: hidden files, no
299276
/// symlink following, depth-limited, custom `.deepseekignore` honored,
300277
/// and gitignore overrides for AI-tool dot-directories so `@`-completion
@@ -494,7 +471,10 @@ fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
494471
.git_global(false)
495472
.git_exclude(false);
496473
let _ = builder.add_custom_ignore_filename(".deepseekignore");
497-
builder.filter_entry(|entry| !should_skip_local_reference_dir(entry.path()));
474+
let root_for_filter = root.to_path_buf();
475+
builder.filter_entry(move |entry| {
476+
!should_skip_unignored_discovery_entry(&root_for_filter, entry.path())
477+
});
498478

499479
for entry in builder.build().flatten() {
500480
if out.len() >= limit {
@@ -514,25 +494,6 @@ fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
514494
out
515495
}
516496

517-
fn should_skip_local_reference_dir(path: &Path) -> bool {
518-
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
519-
return false;
520-
};
521-
matches!(
522-
name,
523-
".git"
524-
| "target"
525-
| "node_modules"
526-
| ".venv"
527-
| "venv"
528-
| "env"
529-
| "dist"
530-
| "build"
531-
| "__pycache__"
532-
| ".ruff_cache"
533-
)
534-
}
535-
536497
impl Clone for Workspace {
537498
fn clone(&self) -> Self {
538499
// Don't carry the cached file_index — clones get a fresh OnceLock so
@@ -1523,6 +1484,82 @@ mod tests {
15231484
);
15241485
}
15251486

1487+
#[test]
1488+
fn workspace_completions_skip_hidden_worktrees_and_build_bulk() {
1489+
let tmp = TempDir::new().unwrap();
1490+
let root = tmp.path();
1491+
std::fs::write(root.join(".gitignore"), ".worktrees/\n.generated/\n").unwrap();
1492+
1493+
std::fs::create_dir_all(root.join(".worktrees/release/src")).unwrap();
1494+
std::fs::write(
1495+
root.join(".worktrees/release/src/worktree-only.rs"),
1496+
"fn main() {}",
1497+
)
1498+
.unwrap();
1499+
std::fs::create_dir_all(root.join(".worktrees/release/target/debug")).unwrap();
1500+
std::fs::write(
1501+
root.join(".worktrees/release/target/debug/generated.o"),
1502+
"object",
1503+
)
1504+
.unwrap();
1505+
1506+
std::fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
1507+
std::fs::write(
1508+
root.join(".claude/worktrees/agent/src/agent-only.md"),
1509+
"agent note",
1510+
)
1511+
.unwrap();
1512+
std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
1513+
std::fs::write(root.join(".claude/commands/keep.md"), "command").unwrap();
1514+
1515+
std::fs::create_dir_all(root.join(".generated/specs")).unwrap();
1516+
std::fs::write(root.join(".generated/specs/device-layout.md"), "layout").unwrap();
1517+
1518+
let ws = Workspace::with_cwd(root.to_path_buf(), Some(root.to_path_buf()));
1519+
1520+
let worktree_entries = ws.completions(".worktrees", 32);
1521+
assert!(
1522+
worktree_entries
1523+
.iter()
1524+
.all(|entry| !entry.starts_with(".worktrees/")),
1525+
"hidden release worktrees must stay out of completions: {worktree_entries:?}",
1526+
);
1527+
1528+
let claude_worktree_entries = ws.completions(".claude/worktrees", 32);
1529+
assert!(
1530+
claude_worktree_entries
1531+
.iter()
1532+
.all(|entry| !entry.starts_with(".claude/worktrees/")),
1533+
".claude/worktrees must stay out of completions: {claude_worktree_entries:?}",
1534+
);
1535+
1536+
let generated_entries = ws.completions(".generated/specs", 32);
1537+
assert!(
1538+
generated_entries
1539+
.iter()
1540+
.any(|entry| entry == ".generated/specs/device-layout.md"),
1541+
"explicit user-generated hidden folders should still complete: {generated_entries:?}",
1542+
);
1543+
1544+
let command_entries = ws.completions(".claude/commands", 32);
1545+
assert!(
1546+
command_entries
1547+
.iter()
1548+
.any(|entry| entry == ".claude/commands/keep.md"),
1549+
"normal .claude command files should still complete: {command_entries:?}",
1550+
);
1551+
1552+
assert!(
1553+
ws.resolve("worktree-only.rs").is_err(),
1554+
"fuzzy resolution must not index files from hidden release worktrees"
1555+
);
1556+
assert!(
1557+
ws.resolve("agent-only.md").is_err(),
1558+
"fuzzy resolution must not index files from .claude/worktrees"
1559+
);
1560+
assert!(ws.resolve("keep.md").is_ok());
1561+
}
1562+
15261563
#[test]
15271564
fn fuzzy_index_resolves_hidden_and_ignored_files_except_deepseekignored() {
15281565
let tmp = TempDir::new().unwrap();
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//! Shared workspace discovery filters for UI path pickers and mentions.
2+
3+
use std::path::Path;
4+
5+
/// Directories that must remain discoverable for `@`-mention completion and
6+
/// fuzzy file resolution even when excluded by `.gitignore`.
7+
pub(crate) const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
8+
9+
/// Root-relative directories that are too large or generated to discover
10+
/// with gitignore disabled. Exact user-specified paths may still resolve.
11+
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] =
12+
&[".deepseek/snapshots", ".worktrees", ".claude/worktrees"];
13+
14+
/// Directory basenames that should not be traversed by fallback discovery
15+
/// walks that deliberately disable gitignore.
16+
const DISCOVERY_EXCLUDED_DIR_NAMES: &[&str] = &[
17+
".git",
18+
"target",
19+
"node_modules",
20+
".venv",
21+
"venv",
22+
"env",
23+
"dist",
24+
"build",
25+
".next",
26+
".turbo",
27+
"coverage",
28+
"__pycache__",
29+
".pytest_cache",
30+
".ruff_cache",
31+
];
32+
33+
/// Check whether `path` is under a root-relative excluded discovery subtree.
34+
pub(crate) fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
35+
DISCOVERY_EXCLUDED_SUBDIRS
36+
.iter()
37+
.any(|excluded| path.starts_with(walk_root.join(excluded)))
38+
}
39+
40+
/// Filter for walks that turn off gitignore to surface explicit hidden paths.
41+
pub(crate) fn should_skip_unignored_discovery_entry(walk_root: &Path, path: &Path) -> bool {
42+
if path == walk_root {
43+
return false;
44+
}
45+
46+
if path_is_excluded_from_discovery(walk_root, path) {
47+
return true;
48+
}
49+
50+
path.file_name()
51+
.and_then(|name| name.to_str())
52+
.is_some_and(|name| DISCOVERY_EXCLUDED_DIR_NAMES.contains(&name))
53+
}

0 commit comments

Comments
 (0)