fix(tui): skip hidden worktrees in workspace discovery to prevent TUI saturation#2329
Conversation
There was a problem hiding this comment.
Code Review
This pull request centralizes workspace discovery logic and constants into a new workspace_discovery module, while also adding .worktrees and .claude/worktrees to the excluded directories to prevent unnecessary indexing. Feedback on the changes highlights an optimization opportunity in path_is_excluded_from_discovery to avoid heap allocations inside a hot loop by stripping the root prefix before comparison.
| 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))) | ||
| } |
There was a problem hiding this comment.
Efficiency & Correctness: Unnecessary Heap Allocations in Hot Loop
Using walk_root.join(excluded) inside any(...) allocates a new PathBuf on the heap for every excluded subdirectory on every single file/directory entry visited during workspace discovery. In large repositories with tens of thousands of files, this results in hundreds of thousands of unnecessary heap allocations, which can significantly degrade TUI responsiveness and tab-completion performance.
Additionally, comparing absolute and relative paths directly using starts_with can be fragile if there are differences in prefix representation (e.g., ./.worktrees vs .worktrees).
Solution
We can resolve both issues by stripping the walk_root prefix from path first using strip_prefix. This returns a borrowed &Path slice (zero heap allocations) and allows us to perform a clean, robust relative path comparison.
pub(crate) fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
if let Ok(rel_path) = path.strip_prefix(walk_root) {
DISCOVERY_EXCLUDED_SUBDIRS
.iter()
.any(|excluded| rel_path.starts_with(excluded))
} else {
false
}
}|
hmm good point.... let me think about how to do this as sub agents working in a single repo is becoming more of the shape for future releases and using side git trees to harvest best use. so thank you very much for raising this so I can solve this before pushing it further |
Summary
When sub-agents fan out during release work, each agent independently triggers workspace discovery walks. If hidden git worktrees (e.g.
.claude/worktrees/,.worktrees/) exist inside the repository, these walks traverse thousands of generated files, consuming excessive disk I/O and saturating the TUI event loop — causing visible freeze/hang.Two fixes:
Cherry-pick
4ec2df9a— skip.worktrees/,.claude/worktrees/,.deepseek/snapshots/in the discovery walk builder (workspace_discovery.rs), and skip them in the file-index builder (working_set.rs) and file picker (file_picker.rs).Add
.worktreestoPACK_IGNORED_DIRSinproject_context.rs— prevents the directory structure packer from enumerating hidden worktree contents when generating the project context block.Changes
workspace_discovery.rsDISCOVERY_ALWAYS_DIRS,DISCOVERY_EXCLUDED_SUBDIRS,path_is_excluded_from_discovery,should_skip_unignored_discovery_entry)working_set.rscompletions()walktui/file_picker.rscollect_candidates()project_context.rs.worktreestoPACK_IGNORED_DIRSmain.rsworkspace_discoverymoduleVerification
cargo check -p codewhale-tui— passesRelated
Closes the release-blocker worktree saturation issue.
Greptile Summary
This PR introduces a shared
workspace_discoverymodule with centralized exclusion filters to prevent hidden git worktrees (.claude/worktrees/,.worktrees/) from saturating the TUI event loop during workspace discovery walks. It also adds.worktreestoPACK_IGNORED_DIRSin the project context packer.workspace_discovery.rscentralisesDISCOVERY_EXCLUDED_SUBDIRS,DISCOVERY_EXCLUDED_DIR_NAMES, and two predicate functions;local_reference_pathsis correctly updated to usefilter_entryfor subtree pruning.build_file_index(working_set.rs) andcollect_candidates(file_picker.rs) both still use a per-entrycontinueguard instead offilter_entry, meaning those walks descend fully into excluded worktree subtrees before discarding entries — the same saturation pattern the PR intends to fix.Confidence Score: 4/5
The fix is partially applied:
local_reference_pathsgains proper subtree pruning, butbuild_file_indexand the file-picker's dot-directory walk still traverse excluded worktree directories in full before discarding entries.local_reference_pathsis correctly fixed withfilter_entry, but two other hot paths —build_file_index(called on first@-mention) andcollect_candidatesin the file picker — retain the old per-entrycontinuepattern. On a repo with several agent worktrees these walks will still fully descend into.claude/worktrees/and discard thousands of entries one-by-one, reproducing the I/O saturation the PR set out to eliminate.crates/tui/src/working_set.rs(build_file_indexandwalk_always_discoverable_dirs) andcrates/tui/src/tui/file_picker.rs(collect_candidates) both needfilter_entryon their dot-directoryWalkBuilderinstances.Important Files Changed
should_skip_unignored_discovery_entryis correct and correctly used viafilter_entryinlocal_reference_paths.local_reference_pathsis fixed withfilter_entry, butbuild_file_indexandwalk_always_discoverable_dirsstill use per-entrycontinue, which traverses excluded worktree subtrees without pruning them.collect_candidatesimports and callspath_is_excluded_from_discoverywith a plaincontinue, so the walk still descends fully into.claude/worktrees/and.deepseek/snapshots/before discarding entries..worktreestoPACK_IGNORED_DIRS; straightforward and correct addition.workspace_discovery; no issues.Comments Outside Diff (1)
crates/tui/src/working_set.rs, line 317-326 (link)path_is_excluded_from_discoverydoesn't prune the walk, only skips individual entrieswalk_always_discoverable_dirsuses a plaincontinueto discard excluded entries, but theWalkBuilderiterator has already descended into the excluded directory and queued all its children. For a.claude/worktreesdirectory containing several agent checkouts — each a full project tree — the iterator will generate and discard thousands of entries before the exclusion check has any effect. The limit guard (>= limit) only fires once enough non-excluded hits accumulate, so a huge worktree subtree is walked completely on every@-completion query.local_reference_pathson the same PR correctly solves this withfilter_entry, which prunes entire subtrees at directory boundaries. Applying the samefilter_entry(move |entry| !should_skip_unignored_discovery_entry(...))pattern towalk_always_discoverable_dirs(and the analogous loop infile_picker::collect_candidates) would actually stop the walk from descending into.claude/worktrees/rather than just discarding its contents.Reviews (2): Last reviewed commit: "fix: trailing newline in project_context..." | Re-trigger Greptile