Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
912a8b8
test: config table respects .gitignore
thekevinbot Oct 6, 2026
cc3dcd4
test: live config table skips a gitignored file
thekevinbot Oct 6, 2026
2827efe
Config tables respect .gitignore by default
thekevinbot Oct 6, 2026
8e3291f
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
9cab8c9
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
9d854d4
Cover the gitignore plumbing with unit tests and exempt the effectful…
thekevinbot Oct 6, 2026
14205c6
Merge branch 'claude/1258-one-pattern' into claude/1258-config-gitignore
thekevinbot Oct 6, 2026
cdce477
Remove the filtered Python e2e receipt
thekevinbot Oct 6, 2026
d1a8694
Merge remote-tracking branch 'origin/main' into claude/1258-config-gi…
thekevinbot Oct 6, 2026
1e9cfd2
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
5a70769
Merge remote-tracking branch 'origin/main' into claude/1258-config-gi…
thekevinbot Oct 6, 2026
9add9ad
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
7fd3c17
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
87c165a
style(rust): cargo fmt scanner
thekevinbot Oct 6, 2026
82856ce
test(rust): cover is_gitignored_path
thekevinbot Oct 6, 2026
f828a81
Merge origin/main into claude/1258-config-gitignore
thekevinbot Oct 6, 2026
8331606
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
5824da8
e2e attestation for claude/1258-config-gitignore
thekevinbot Oct 6, 2026
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
3 changes: 3 additions & 0 deletions packages/python/changelog.d/2026-10-06-config-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Changed** Declared `[[table]]` tables respect `.gitignore` by default, as
path-tables do: inside a git repo, a file a `.gitignore` ignores is no longer a
row, at startup or while watching. `--no-ignore` turns this off for them too.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/glob_speed_gitignore_test.py tests/e2e/glob_speed_dotfiles_test.py tests/e2e/glob_speed_recursive_test.py tests/e2e/glob_speed_matchers_test.py tests/e2e/query_contract_test.py -x -q",
"ran_at": 1791329772,
"exit_code": 0,
"commit": "f828a8181c981ab9c5c775117cb60edf3ccd9dcc",
"branch": "claude/1258-config-gitignore"
}
45 changes: 45 additions & 0 deletions packages/python/migrations.d/2026-10-06-config-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
### Config tables respect .gitignore

#### Summary

A declared `[[table]]` used to read every file its glob matched, whatever
`.gitignore` said. It now skips gitignored files, as a path-table does. This
breaks a config that relied on indexing an ignored file; pass `--no-ignore`
(or `no_ignore` in the SDKs) to restore the old behavior.

#### Required changes

| Before | After |
| --- | --- |
| a table indexed gitignored files | pass `--no-ignore`, or stop ignoring the file |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- Inside a git repo (a directory holding `.git`), files a `.gitignore` ignores
are not rows of a `[[table]]`, at startup or while watching.
- Outside a repo nothing changes.

#### Verification

```bash
cd "$(mktemp -d)"
git init -q
printf 'skip.json\n' > .gitignore
echo '[{"p":"keep"}]' > keep.json
echo '[{"p":"skip"}]' > skip.json
cat > .dirsql.toml <<'TOML'
[[table]]
name = "t"
glob = "*.json"
ddl = "CREATE TABLE t (p TEXT)"
on-file = "jq -sc add"
TOML
dirsql query "SELECT p FROM t" -c .dirsql.toml
# expected: [{"p":"keep"}]
dirsql query "SELECT p FROM t ORDER BY p" -c .dirsql.toml --no-ignore
# expected: [{"p":"keep"},{"p":"skip"}]
```
3 changes: 3 additions & 0 deletions packages/rust/changelog.d/2026-10-06-config-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Changed** Declared `[[table]]` tables respect `.gitignore` by default, as
path-tables do: inside a git repo, a file a `.gitignore` ignores is no longer a
row, at startup or while watching. `--no-ignore` turns this off for them too.
45 changes: 45 additions & 0 deletions packages/rust/migrations.d/2026-10-06-config-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
### Config tables respect .gitignore

#### Summary

A declared `[[table]]` used to read every file its glob matched, whatever
`.gitignore` said. It now skips gitignored files, as a path-table does. This
breaks a config that relied on indexing an ignored file; pass `--no-ignore`
(or `no_ignore` in the SDKs) to restore the old behavior.

#### Required changes

| Before | After |
| --- | --- |
| a table indexed gitignored files | pass `--no-ignore`, or stop ignoring the file |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- Inside a git repo (a directory holding `.git`), files a `.gitignore` ignores
are not rows of a `[[table]]`, at startup or while watching.
- Outside a repo nothing changes.

#### Verification

```bash
cd "$(mktemp -d)"
git init -q
printf 'skip.json\n' > .gitignore
echo '[{"p":"keep"}]' > keep.json
echo '[{"p":"skip"}]' > skip.json
cat > .dirsql.toml <<'TOML'
[[table]]
name = "t"
glob = "*.json"
ddl = "CREATE TABLE t (p TEXT)"
on-file = "jq -sc add"
TOML
dirsql query "SELECT p FROM t" -c .dirsql.toml
# expected: [{"p":"keep"}]
dirsql query "SELECT p FROM t ORDER BY p" -c .dirsql.toml --no-ignore
# expected: [{"p":"keep"},{"p":"skip"}]
```
32 changes: 32 additions & 0 deletions packages/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,14 @@ impl DirSQL {
if group.matcher.is_ignored(&rel_path_buf) {
continue;
}
if group.matcher.respects_gitignore()
&& scanner::is_gitignored_path(
&abs_path,
self.inner.fs.is_dir(&abs_path).unwrap_or(false),
)
{
continue;
}

// A directory that appears whole (`mkdir`, or a populated directory
// renamed into the tree) arrives as one event naming the directory and
Expand Down Expand Up @@ -1110,6 +1118,13 @@ impl DirSQL {
} = resolved;

let (groups, anchors, table_names) = compile_groups(&tables, &ignore, &root)?;
let groups = groups
.into_iter()
.map(|group| AnchorGroup {
matcher: group.matcher.with_gitignore(!no_ignore),
..group
})
.collect::<Vec<_>>();

// Resolve the persistent context before scanning, so the scan can
// consult the cached file index.
Expand Down Expand Up @@ -2935,6 +2950,23 @@ mod internal_tests {
assert_eq!(found, vec!["a.txt"]);
}

/// The startup walk honors `.gitignore` unless the build asked not to.
#[test]
fn prepare_hands_the_walk_a_matcher_that_honors_gitignore_unless_no_ignore() {
let dir = TempDir::new().unwrap();
for no_ignore in [false, true] {
let honored = Cell::new(None);
let mut resolved = resolved_over(dir.path(), vec![txt_table()], false);
resolved.no_ignore = no_ignore;
DirSQL::prepare_resolved_with(resolved, &|_, matcher, _| {
honored.set(Some(matcher.respects_gitignore()));
Vec::new()
})
.unwrap();
assert_eq!(honored.get(), Some(!no_ignore));
}
}

/// Skipping the walk skips only the walk: with persist on, the cache is
/// still opened and reconciled against the (empty) scan.
#[test]
Expand Down
19 changes: 19 additions & 0 deletions packages/rust/src/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub struct TableMatcher {
entries: Vec<PatternEntry>,
ignore_set: Vec<Pattern>,
ignore_dir_set: Vec<Pattern>,
gitignore: bool,
}

/// Byte spans of the `{name}` placeholders in `pattern`, in order: `(start,
Expand Down Expand Up @@ -182,9 +183,20 @@ impl TableMatcher {
entries,
ignore_set,
ignore_dir_set,
gitignore: false,
})
}

/// Whether a scan under this matcher honors `.gitignore` files.
pub fn with_gitignore(mut self, gitignore: bool) -> Self {
self.gitignore = gitignore;
self
}

pub(crate) fn respects_gitignore(&self) -> bool {
self.gitignore
}

/// Returns one [`MatchResult`] per matching pattern, in declaration order.
/// A file matching N patterns yields N results (fan-out). Empty when
/// nothing matches.
Expand Down Expand Up @@ -230,6 +242,13 @@ mod tests {
assert_eq!(names(&matcher, "report.csv"), vec!["data"]);
}

#[test]
fn a_matcher_honors_gitignore_only_once_asked_to() {
let matcher = TableMatcher::new(&[], &[]).unwrap();
assert!(!matcher.respects_gitignore());
assert!(matcher.with_gitignore(true).respects_gitignore());
}

#[test]
fn match_all_returns_empty_for_no_match() {
let matcher = TableMatcher::new(&[("*.csv", "data")], &[]).unwrap();
Expand Down
44 changes: 41 additions & 3 deletions packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,23 @@ fn scan_below(
) -> Vec<(PathBuf, String)> {
let mut results = Vec::new();
let mut seen: u64 = 0;
let gitignore = matcher.respects_gitignore();
let repo = if gitignore {
enclosing_repo(start, &holds_git)
} else {
None
};

// Match against relative path so globs like "comments/**/*.jsonl" work
// regardless of the absolute root directory.
let walker = Walk {
ignore: matcher,
glob: None,
gitignore: false,
in_repo: false,
frames: Vec::new(),
gitignore,
in_repo: repo.is_some(),
frames: repo.map_or_else(Vec::new, |top| {
gitignores_above(start, top, &load_gitignore)
}),
dirs: false,
};
walk(
Expand Down Expand Up @@ -741,6 +749,26 @@ fn load_gitignore(dir: &Path) -> Option<Gitignore> {
builder.build().ok()
}

/// Whether the `.gitignore` files of the repo enclosing `path` ignore it, or
/// a directory above it, as the walk would have: for a path a watcher reports
/// without having walked to it.
pub(crate) fn is_gitignored_path(path: &Path, is_dir: bool) -> bool {
let Some(top) = path
.parent()
.and_then(|dir| enclosing_repo(dir, &holds_git))
else {
return false;
};
for frame in gitignores_above(path, top, &load_gitignore).iter().rev() {
match frame.matched_path_or_any_parents(path, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
}
false
}

/// Whether the `.gitignore` files in force mark `path` ignored. Deeper files
/// take precedence (git's rule), and a whitelisting `!pattern` un-ignores.
fn is_gitignored(frames: &[Arc<Gitignore>], path: &Path, is_dir: bool) -> bool {
Expand Down Expand Up @@ -1664,4 +1692,14 @@ mod tests {
let frames = [frame("", &["!keep.log"]), frame("sub", &["*.log"])];
assert!(is_gitignored(&frames, Path::new("sub/keep.log"), false));
}

#[test]
fn is_gitignored_path_ignores_a_path_the_repo_gitignore_matches() {
let repo = tempfile::tempdir().unwrap();
fs::create_dir(repo.path().join(".git")).unwrap();
fs::write(repo.path().join(".gitignore"), "*.log\n").unwrap();

assert!(is_gitignored_path(&repo.path().join("debug.log"), false));
assert!(!is_gitignored_path(&repo.path().join("app.js"), false));
}
}
50 changes: 50 additions & 0 deletions packages/rust/tests/config_gitignore_watch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! A live config table skips a gitignored file the way its startup scan does.
//! Real `notify` watcher, real filesystem, real SQLite, SDK public API.

use std::collections::HashMap;
use std::time::{Duration, Instant};
use std::{fs, thread};

use dirsql::{DirSQL, RowEvent, Table, Value};
use tempfile::TempDir;

fn names(db: &DirSQL) -> Vec<String> {
let mut out: Vec<String> = db
.query("SELECT name FROM t")
.unwrap()
.iter()
.map(|r| match r.get("name") {
Some(Value::Text(s)) => s.clone(),
other => panic!("name was not text: {other:?}"),
})
.collect();
out.sort();
out
}

#[test]
fn a_gitignored_file_created_while_watching_is_not_indexed() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "skip.md\n").unwrap();
let table = Table::new("t", "CREATE TABLE t (name TEXT)", "*.md", |path| {
let name = path.rsplit(['/', '\\']).next().unwrap().to_string();
vec![HashMap::from([("name".into(), Value::Text(name))])]
});
let db = DirSQL::new(dir.path(), vec![table]).unwrap();
db.start_watching().unwrap();
thread::sleep(Duration::from_millis(250));

fs::write(dir.path().join("skip.md"), "x").unwrap();
fs::write(dir.path().join("keep.md"), "x").unwrap();

let deadline = Instant::now() + Duration::from_secs(5);
let mut seen = Vec::new();
while Instant::now() < deadline && !seen.iter().any(|e| matches!(e, RowEvent::Insert { .. })) {
seen.extend(db.poll_events(Duration::from_millis(200)).unwrap());
}
thread::sleep(Duration::from_millis(500));
seen.extend(db.poll_events(Duration::from_millis(200)).unwrap());

assert_eq!(names(&db), vec!["keep.md"], "events: {seen:?}");
}
35 changes: 34 additions & 1 deletion packages/rust/tests/config_glob_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ fn tree(files: &[&str]) -> TempDir {
}

fn config_paths(root: &Path, glob: &str) -> Vec<String> {
config_paths_with(root, glob, false)
}

fn config_paths_with(root: &Path, glob: &str, no_ignore: bool) -> Vec<String> {
let base = root.to_path_buf();
let table = Table::new("t", "CREATE TABLE t (p TEXT)", glob, move |path| {
let rel = Path::new(path)
Expand All @@ -30,7 +34,12 @@ fn config_paths(root: &Path, glob: &str) -> Vec<String> {
Value::Text(rel.to_string_lossy().replace('\\', "/")),
)])]
});
let db = DirSQL::new(root, vec![table]).unwrap();
let db = DirSQL::builder()
.root(root)
.table(table)
.no_ignore(no_ignore)
.build()
.unwrap();
texts(&db, "SELECT p FROM t", "p")
}

Expand Down Expand Up @@ -61,6 +70,30 @@ fn a_brace_range_expands_as_it_does_in_a_path_table() {
assert_eq!(config_paths(root.path(), "f{1..3}.md"), expected);
}

fn repo(files: &[&str], gitignore: &str) -> TempDir {
let root = tree(files);
fs::create_dir(root.path().join(".git")).unwrap();
fs::write(root.path().join(".gitignore"), gitignore).unwrap();
root
}

#[test]
fn a_gitignored_file_is_not_a_row_of_a_config_table() {
let root = repo(&["keep.md", "skip.md", "dist/out.md"], "skip.md\ndist/\n");
let expected = vec!["keep.md"];
assert_eq!(path_table_paths(root.path(), "**/*.md"), expected);
assert_eq!(config_paths(root.path(), "**/*.md"), expected);
}

#[test]
fn no_ignore_lists_a_gitignored_file_in_a_config_table() {
let root = repo(&["keep.md", "skip.md"], "skip.md\n");
assert_eq!(
config_paths_with(root.path(), "*.md", true),
vec!["keep.md", "skip.md"]
);
}

#[test]
fn an_unbalanced_brace_or_bracket_is_literal_as_it_is_in_a_path_table() {
let root = tree(&["{a.md", "a}.md", "[a.md", "a.md"]);
Expand Down
3 changes: 3 additions & 0 deletions packages/ts/changelog.d/2026-10-06-config-gitignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Changed** Declared `[[table]]` tables respect `.gitignore` by default, as
path-tables do: inside a git repo, a file a `.gitignore` ignores is no longer a
row, at startup or while watching. `--no-ignore` turns this off for them too.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791330205,
"exit_code": 0,
"commit": "83316069645de387cebc224a9b9540fdd517ec84",
"branch": "claude/1258-config-gitignore"
}
Loading
Loading