Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .alfonso/release-notes/next/skip-nested-worktrees.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AFT no longer indexes linked worktrees nested inside a project folder a second time, so search and inspect stay fast and return no duplicate hits (#746, thanks @moynzzz).
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"slice_class": "engine_unwired",
"targeted_mechanism": "none",
"kind": "harness",
"fixtures": [
"crates/aft/tests/integration/tool_call_parity_test.rs",
"crates/aft/src/search_index.rs",
"crates/aft/src/commands/semantic_search/mod.rs",
"crates/aft/src/lib.rs"
],
"slices": [
"skip-nested-worktrees"
],
"note": "The ranking fence hits are search_index.rs and semantic_search/mod.rs, whose project walks now prune linked worktrees of the same repository nested under the root, and lib.rs, which registers the new test module. No benchmark corpus contains such a worktree, so ranked rows must remain byte-identical to the committed reference; scoring, admission and routing are unchanged."
}
4 changes: 4 additions & 0 deletions crates/aft/src/callgraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3961,6 +3961,9 @@ fn walk_project_paths_filtered_while_with_builder(
configure_builder(&mut builder);
let filter_keep_walking = std::sync::Arc::new(keep_walking);
let filter_budget = std::sync::Arc::clone(&filter_keep_walking);
// Skip nested linked worktrees of this repository: their files are indexed
// under their own roots. The walk root itself is always admitted (depth 0).
let worktrees = crate::project_content::NestedWorktreeBoundary::for_root(root);
let mut walker = crate::context::apply_project_ignore_rules(&mut builder, root)
.filter_entry(move |entry| {
#[cfg(test)]
Expand All @@ -3982,6 +3985,7 @@ fn walk_project_paths_filtered_while_with_builder(
let file_type = entry.file_type();
if crate::project_content::is_excluded_with_file_type(entry.path(), file_type.as_ref())
|| !include(entry.path())
|| crate::search_index::is_nested_worktree_entry(entry, &worktrees)
{
return false;
}
Expand Down
38 changes: 38 additions & 0 deletions crates/aft/src/commands/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3044,6 +3044,44 @@ fn walk_semantic_project_files_bounded(
walk_project_files_bounded_matching(root, &filters, max_files, is_semantic_indexed_extension)
}

#[cfg(test)]
mod nested_worktree_semantic_tests {
use super::*;

#[test]
fn semantic_enumeration_skips_nested_linked_worktrees() {
let dir = tempfile::tempdir().unwrap();
let fixture =
crate::git_probe::init_repo_with_nested_worktree_for_test(dir.path(), "semantic");
let files = walk_semantic_project_files_bounded(&fixture.root, 1000)
.expect("fixture walk must complete")
.into_iter()
.map(|path| {
path.strip_prefix(&fixture.root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect::<std::collections::BTreeSet<_>>();
assert!(
files.contains("src/main.rs"),
"missing main file: {files:?}"
);
assert!(
files.contains("vendor/independent/inner.rs"),
"nested independent repository must still be walked: {files:?}"
);
assert!(
files.contains("submod/code.rs"),
"submodule-style checkout must still be walked: {files:?}"
);
assert!(
!files.iter().any(|path| path.starts_with("linked/")),
"nested linked worktree files were enumerated: {files:?}"
);
}
}

#[cfg(debug_assertions)]
fn delay_search_rebuild_publish_for_debug() {
let Some(delay_ms) = std::env::var("AFT_TEST_SEARCH_REBUILD_PUBLISH_DELAY_MS")
Expand Down
6 changes: 5 additions & 1 deletion crates/aft/src/commands/semantic_search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7164,6 +7164,9 @@ fn collect_degraded_grep_files(
// abort the daemon if a disappearing child mount reports ENXIO.
let skipped_foreign_mounts = Arc::new(AtomicUsize::new(0));
let boundary = crate::walk_boundary::DeviceBoundary::for_root(project_root).ok();
// Skip nested linked worktrees of this repository; the walk root itself is
// always admitted (depth 0).
let worktrees = crate::project_content::NestedWorktreeBoundary::for_root(project_root);
let mut builder = ignore::WalkBuilder::new(project_root);
builder.same_file_system(true).hidden(false);
let walker = crate::context::apply_project_ignore_rules(&mut builder, project_root)
Expand All @@ -7186,7 +7189,8 @@ fn collect_degraded_grep_files(
if crate::project_content::is_excluded_with_file_type(
entry.path(),
file_type.as_ref(),
) {
) || crate::search_index::is_nested_worktree_entry(entry, &worktrees)
{
return false;
}
let name = entry.file_name().to_string_lossy();
Expand Down
81 changes: 81 additions & 0 deletions crates/aft/src/git_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,87 @@ pub(crate) fn init_repository_with_corrupt_head_for_test(path: &Path) {
std::fs::write(path.join(".git").join("HEAD"), "not a ref\n").unwrap();
}

/// A real repository with every nested-checkout shape below its root:
/// - `linked/`: a linked worktree of this repository (`git worktree add`), to
/// be skipped by project walks;
/// - `vendor/independent/`: a nested independent repository (its own `.git`
/// directory), still walked;
/// - `submod/`: a submodule-style checkout (a `.git` file naming
/// `<root>/.git/modules/submod`), still walked.
#[cfg(test)]
pub(crate) struct NestedWorktreeFixture {
pub root: PathBuf,
pub worktree: PathBuf,
pub independent: PathBuf,
pub submodule: PathBuf,
}

/// Test fixture: `root` becomes the repository above, with `marker` written
/// into one committed source file. Asserts Git runs and every step succeeds.
#[cfg(test)]
pub(crate) fn init_repo_with_nested_worktree_for_test(
root: &Path,
marker: &str,
) -> NestedWorktreeFixture {
assert!(
crate::developer_tools::git_usable(),
"nested-worktree fixtures need git"
);
let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let git = |dir: &Path, args: &[&str]| {
let output = command()
.args([
"-c",
"user.name=AFT Tests",
"-c",
"user.email=aft-tests@example.com",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_COUNT", "0")
.output()
.expect("git runs");
assert!(output.status.success(), "git {args:?}: {output:?}");
};
let source = format!("pub fn probe_marker() {{ let _ = \"{marker}\"; }}\n");
for relative in ["src/main.rs", "lib/util.rs"] {
let path = root.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, &source).unwrap();
}
git(&root, &["init", "--quiet"]);
git(&root, &["add", "."]);
git(&root, &["commit", "--quiet", "-m", "initial"]);
// A linked worktree of this repository, nested in a subfolder of the main
// checkout: the worktree's `.git` file names its admin dir under the
// main checkout's `.git/worktrees/`.
git(&root, &["worktree", "add", "--quiet", "linked"]);
let worktree = root.join("linked");
std::fs::write(worktree.join("extra.rs"), &source).unwrap();
// A nested independent repository: its own `.git` directory.
let independent = root.join("vendor").join("independent");
std::fs::create_dir_all(&independent).unwrap();
git(&independent, &["init", "--quiet"]);
std::fs::write(independent.join("inner.rs"), &source).unwrap();
// A submodule-style checkout: a `.git` file whose gitdir lives under
// `.git/modules/`, not `worktrees/`.
let submodule = root.join("submod");
std::fs::create_dir_all(&submodule).unwrap();
std::fs::create_dir_all(root.join(".git").join("modules").join("submod")).unwrap();
std::fs::write(submodule.join(".git"), "gitdir: ../.git/modules/submod\n").unwrap();
std::fs::write(submodule.join("code.rs"), &source).unwrap();
NestedWorktreeFixture {
root,
worktree,
independent,
submodule,
}
}

/// Test fixture: a `.git` folder holding only `info/exclude` and no HEAD, which
/// Git itself does not treat as a repository.
#[cfg(test)]
Expand Down
10 changes: 10 additions & 0 deletions crates/aft/src/grep_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,12 @@ fn target_walk_builder(
// A disappearing child mount can make ReadDir::drop panic on ENXIO and abort
// the daemon, so never open directories outside this walk root's filesystem.
builder.same_file_system(true).hidden(false);
// Skip nested linked worktrees of this repository: their files are indexed
// under their own roots, and walking them here duplicates every file.
// The named starting path itself is always admitted (depth 0), so an
// explicitly named target inside a worktree still walks, exactly like an
// explicitly named ignored directory.
let worktrees = crate::project_content::NestedWorktreeBoundary::for_root(search_root);
crate::context::apply_project_ignore_rules(&mut builder, search_root)
.parents(!crate::context::confinement::active())
.filter_entry(move |entry| {
Expand All @@ -1224,6 +1230,10 @@ fn target_walk_builder(
floor_disclosure.record_source_exclusion(entry.path());
return false;
}
if crate::search_index::is_nested_worktree_entry(entry, &worktrees) {
floor_disclosure.record_source_exclusion(entry.path());
return false;
}
if !crate::bash_background::registry::safe_task_entry(entry.path()) {
return false;
}
Expand Down
36 changes: 35 additions & 1 deletion crates/aft/src/inspect/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2572,8 +2572,14 @@ fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
// Prevent a disappearing child mount from making ReadDir::drop abort on ENXIO.
let mut builder = ignore::WalkBuilder::new(project_root);
builder.same_file_system(true).hidden(true);
// Resolver configs inside a nested linked worktree of this repository
// belong to that checkout's own scope, not this one.
let worktrees = crate::project_content::NestedWorktreeBoundary::for_root(project_root);
let walker = crate::context::apply_project_ignore_rules(&mut builder, project_root)
.filter_entry(|entry| {
.filter_entry(move |entry| {
if crate::search_index::is_nested_worktree_entry(entry, &worktrees) {
return false;
}
let name = entry.file_name().to_string_lossy();
if entry
.file_type()
Expand Down Expand Up @@ -2866,6 +2872,34 @@ fn now_nanos() -> u128 {
pub(crate) mod tests {
use super::*;

#[test]
fn resolver_configs_skip_nested_linked_worktrees() {
// tsconfig files inside a nested linked worktree belong to that
// checkout's own scope, not this one's.
let dir = tempfile::tempdir().unwrap();
let fixture =
crate::git_probe::init_repo_with_nested_worktree_for_test(dir.path(), "resolver");
for config_dir in [&fixture.root, &fixture.worktree, &fixture.independent] {
std::fs::write(config_dir.join("tsconfig.json"), r#"{"files": []}"#).unwrap();
}
let configs = walk_resolver_config_files(&fixture.root);
assert!(
configs.contains(&canonical_file_path(fixture.root.join("tsconfig.json")).unwrap()),
"missing main config: {configs:?}"
);
assert!(
configs
.contains(&canonical_file_path(fixture.independent.join("tsconfig.json")).unwrap()),
"nested independent repository must still be walked: {configs:?}"
);
assert!(
!configs
.iter()
.any(|path| path.starts_with(&fixture.worktree)),
"nested linked worktree configs were collected: {configs:?}"
);
}

fn cache_epoch_fixture(
marker: Option<u32>,
) -> (tempfile::TempDir, PathBuf, PathBuf, JobKey, String) {
Expand Down
39 changes: 38 additions & 1 deletion crates/aft/src/inspect/entry_points.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ fn collect_manifests(project_root: &Path, manifests: &mut ManifestPaths) {
}

fn entry_point_walk_files(project_root: &Path) -> Vec<PathBuf> {
// Skip nested linked worktrees of this repository; the walk root itself
// is always admitted (depth 0).
let worktrees = crate::project_content::NestedWorktreeBoundary::for_root(project_root);
let mut builder = ignore::WalkBuilder::new(project_root);
// Prevent a disappearing child mount from making ReadDir::drop abort on ENXIO.
builder
Expand All @@ -164,7 +167,12 @@ fn entry_point_walk_files(project_root: &Path) -> Vec<PathBuf> {
.git_exclude(true)
.require_git(false)
.add_custom_ignore_filename(".aftignore")
.filter_entry(|entry| {
.filter_entry(move |entry| {
// Manifests inside a nested linked worktree of this repository
// belong to that checkout's own scope, not this one.
if crate::search_index::is_nested_worktree_entry(entry, &worktrees) {
return false;
}
!entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
Expand Down Expand Up @@ -581,6 +589,9 @@ fn collect_framework_route_files(

fn framework_route_walk_files(package_dir: &Path) -> Vec<PathBuf> {
let package_dir = snapshot_path(package_dir);
// Skip nested linked worktrees of this repository; the walk root itself
// is always admitted (depth 0).
let worktrees = crate::project_content::NestedWorktreeBoundary::for_root(&package_dir);
// A vanished framework mount could otherwise make ReadDir::drop abort on ENXIO.
let mut builder = ignore::WalkBuilder::new(&package_dir);
let package_root = package_dir.clone();
Expand All @@ -593,6 +604,9 @@ fn framework_route_walk_files(package_dir: &Path) -> Vec<PathBuf> {
.require_git(false)
.add_custom_ignore_filename(".aftignore")
.filter_entry(move |entry| {
if crate::search_index::is_nested_worktree_entry(entry, &worktrees) {
return false;
}
if !entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
Expand Down Expand Up @@ -1129,6 +1143,29 @@ pub(crate) fn top_preview_symbols(items: &[Value]) -> Vec<Value> {
mod tests {
use super::*;

#[test]
fn entry_point_manifests_skip_nested_linked_worktrees() {
// package.json files inside a nested linked worktree belong to that
// checkout's own scope, not this one's.
let dir = tempfile::tempdir().unwrap();
let fixture =
crate::git_probe::init_repo_with_nested_worktree_for_test(dir.path(), "entry_points");
for manifest_dir in [&fixture.root, &fixture.worktree] {
std::fs::write(manifest_dir.join("package.json"), r#"{"name": "fixture"}"#).unwrap();
}
let manifests = collect_entry_point_manifests(&fixture.root);
assert!(
manifests.contains(&snapshot_path(&fixture.root.join("package.json"))),
"missing main manifest: {manifests:?}"
);
assert!(
!manifests
.iter()
.any(|path| path.starts_with(snapshot_path(&fixture.worktree))),
"nested linked worktree manifests were collected: {manifests:?}"
);
}

fn published_api_fixture(wildcard: bool, index: &str, default_live: bool) {
use crate::inspect::oxc_engine::{
analyze_files_with_cache, AnalyzeOptions, LivenessVerdict, OxcFactsCache,
Expand Down
2 changes: 2 additions & 0 deletions crates/aft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ pub mod memory;
pub mod migrate_storage;
pub mod migration;
pub mod ndjson_text;
#[cfg(test)]
mod nested_worktree_tests;
pub mod ort_lifecycle;
pub mod ort_pin;
pub(crate) mod os_metadata;
Expand Down
Loading
Loading