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
7 changes: 7 additions & 0 deletions packages/python/e2e-attestations/claude-1286-nested-repo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1791238629,
"exit_code": 0,
"commit": "68858295a0d429345af616a28e2cee3946f186cc",
"branch": "claude/1286-nested-repo"
}
3 changes: 3 additions & 0 deletions packages/rust/changelog.d/2026-10-05-nested-repo-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Fixed** Path-table scans stop applying an outer repository's `.gitignore`
rules inside a nested repository. A nested repo now starts a fresh ignore
boundary, matching Git; `'./**'` and narrower globs return the same files.
34 changes: 34 additions & 0 deletions packages/rust/migrations.d/2026-10-05-nested-repo-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
### Core: nested repositories reset `.gitignore` inheritance

#### Summary

Path-table scans no longer carry a parent repository's `.gitignore` rules
into a nested repository. This matches Git's ignore boundary behavior and can
make previously hidden files visible to path-table queries. No API changes.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

- When `outer` is a repository with `*.log` and `outer/repo` is also a
repository, `'./**'` now lists `repo/z.log`; the outer rule no longer
applies inside `repo`.

#### Verification

```bash
cd "$(mktemp -d)"
git init -q
printf '*.log\n' > .gitignore
mkdir repo
git -C repo init -q
touch repo/z.log
dirsql query "SELECT path FROM './**' ORDER BY path"
# expected: [{"path":"repo/z.log"}]
```
13 changes: 7 additions & 6 deletions packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,10 @@ impl Walk<'_> {
linked: bool,
visit: &mut dyn FnMut(PathBuf, &DirEntry),
) {
let entered_repo = self.gitignore && !self.in_repo && holds_git(dir);
if entered_repo {
self.in_repo = true;
}
let entered_repo = self.gitignore && holds_git(dir);
let was_in_repo = self.in_repo;
let inherited_frames = entered_repo.then(|| std::mem::take(&mut self.frames));
self.in_repo |= entered_repo;
let mut pushed = false;
if self.in_repo
&& let Some(matcher) = load_gitignore(dir)
Expand Down Expand Up @@ -398,9 +398,10 @@ impl Walk<'_> {
if pushed {
self.frames.pop();
}
if entered_repo {
self.in_repo = false;
if let Some(frames) = inherited_frames {
self.frames = frames;
}
self.in_repo = was_in_repo;
}

/// Whether the walk takes an entry at `depth`: the skip rules and the
Expand Down
28 changes: 28 additions & 0 deletions packages/rust/tests/cli_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,34 @@ fn help_flag_lists_the_subcommands_and_default_query_flags() {
.stdout(predicates::str::contains("init"));
}

#[test]
fn query_glob_does_not_apply_outer_gitignore_inside_nested_repo() {
let outer = TempDir::new().unwrap();
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(outer.path().join(".gitignore"), "*.log\n").unwrap();
let nested = outer.path().join("repo");
fs::create_dir(&nested).unwrap();
fs::create_dir(nested.join(".git")).unwrap();
fs::write(nested.join("z.log"), "nested").unwrap();

let output = std::process::Command::cargo_bin("dirsql")
.unwrap()
.args(["query", "SELECT path FROM './**'", "--format", "json"])
.current_dir(outer.path())
.output()
.unwrap();

assert!(
output.status.success(),
"query failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
serde_json::from_slice::<Vec<Value>>(&output.stdout).unwrap(),
vec![json!({"path": "repo/z.log"})]
);
}

#[test]
fn server_help_lists_the_bind_flags() {
// #662: `--host`/`--port` are server-local now, documented under
Expand Down
43 changes: 42 additions & 1 deletion packages/rust/tests/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use std::fs;

use dirsql::matcher::TableMatcher;
use dirsql::scanner::{scan_directory, scan_subtree};
use dirsql::scanner::{compile_glob, scan_directory, scan_glob, scan_subtree};
use tempfile::TempDir;

#[test]
Expand Down Expand Up @@ -177,3 +177,44 @@ fn scan_subtree_prunes_ignored_directories() {

assert_eq!(results, vec![(moved.join("keep.csv"), "t".to_string())]);
}

#[test]
fn scan_glob_stops_outer_gitignore_rules_at_nested_repo() {
let outer = TempDir::new().unwrap();
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(outer.path().join(".gitignore"), "repo/*.log\n").unwrap();
let nested = outer.path().join("repo");
fs::create_dir(&nested).unwrap();
fs::create_dir(nested.join(".git")).unwrap();
fs::write(nested.join("z.log"), "nested").unwrap();
let sibling = outer.path().join("zz-sibling");
fs::create_dir(&sibling).unwrap();
fs::write(sibling.join(".gitignore"), "*.log\n").unwrap();
fs::write(sibling.join("z.log"), "ignored").unwrap();

let ignore = TableMatcher::new(&[], &[]).unwrap();
let glob = compile_glob("**").unwrap();
let results = scan_glob(outer.path(), &glob, &ignore, true);

assert_eq!(results, vec![std::path::PathBuf::from("repo/z.log")]);
}

#[test]
fn scan_glob_applies_gitignore_only_inside_a_repo() {
let outer = TempDir::new().unwrap();
let nested = outer.path().join("repo");
fs::create_dir(&nested).unwrap();
fs::create_dir(nested.join(".git")).unwrap();
fs::write(nested.join(".gitignore"), "*.log\n").unwrap();
fs::write(nested.join("z.log"), "ignored").unwrap();
let sibling = outer.path().join("zz-sibling");
fs::create_dir(&sibling).unwrap();
fs::write(sibling.join(".gitignore"), "*.log\n").unwrap();
fs::write(sibling.join("z.log"), "visible").unwrap();

let ignore = TableMatcher::new(&[], &[]).unwrap();
let glob = compile_glob("**").unwrap();
let results = scan_glob(outer.path(), &glob, &ignore, true);

assert_eq!(results, vec![std::path::PathBuf::from("zz-sibling/z.log")]);
}
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-1286-nested-repo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791239115,
"exit_code": 0,
"commit": "0257c015584ee781df5b4cfd11b2dd3f35455f54",
"branch": "claude/1286-nested-repo"
}
8 changes: 4 additions & 4 deletions packages/ts/testing-conventions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
# start staling this package.
#
# The napi crate is in scope because the npm launcher runs the CLI in-process
# through it (#721), so every e2e case crosses it -- the whole crate dir plus its
# manifest at the package root, since Cargo.toml and build.rs decide what lands
# in the addon. packages/python/src is not: it cannot reach this suite.
# through it (#721), so every e2e case crosses it. The verifier accepts only
# directory scopes, so the package-root Cargo.toml cannot be listed here.
# packages/python/src is not: it cannot reach this suite.
[e2e]
extra_scope = ["packages/rust/src", "packages/ts/napi", "packages/ts/Cargo.toml"]
extra_scope = ["packages/rust/src", "packages/ts/napi"]

# The live TypeScript floors; the root file carries no `[typescript]` table.
[typescript.coverage]
Expand Down
6 changes: 3 additions & 3 deletions testing-conventions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,19 @@ features = ["cli"]
[[rust.exempt]]
path = "src/scanner.rs"
rules = ["mutation"]
lines = [72, 131, 330, 367, 445, 466]
lines = [72, 131, 330, 367, 446, 467]
reason = "scan_below, scan_glob, walk, descend, kind_of and sorted_entries read the filesystem; tests/scanner.rs (scan_finds_matching_files, scan_recurses_into_subdirectories), tests/vtab.rs (glob_scopes_the_walk, rowids_count_up_from_zero_in_scan_order) and tests/path_table_symlinks.rs assert what they return"

[[rust.exempt]]
path = "src/scanner.rs"
rules = ["mutation"]
lines = [384, 385, 388, 446, 449]
lines = [384, 385, 388, 447, 450]
reason = "whether a walked entry is a symlink, and whether the walk is below one, is known only from a real tree; tests/path_table_symlinks.rs asserts every outcome against bash, and the decisions themselves are unit-tested in classify, Walk::follows and PathGlob::step"

[[rust.exempt]]
path = "src/scanner.rs"
rules = ["mutation"]
lines = [368, 372, 401, 480, 493]
lines = [370, 372, 404, 481, 494]
reason = "whether a repo encloses the walk, and which .gitignore files are in force, is known only from a real tree holding .git; the choice of directories is unit-tested in enclosing_repo and dirs_above, and tests/path_table_gitignore.rs (with_no_repo_a_gitignore_does_not_apply_at_the_index_root, inside_a_repo_the_same_gitignore_hides_everything, a_repo_root_gitignore_above_the_index_root_applies) asserts every outcome"

[[rust.exempt]]
Expand Down
Loading