Skip to content
Merged
3 changes: 3 additions & 0 deletions packages/python/changelog.d/2026-10-06-config-dotnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Changed** A declared `[[table]] glob` no longer matches a dot-named file or
directory unless the glob spells it: `**/*.md` skips `.env.md` and
`.hid/z.md`, while `.hid/*.md` lists `.hid/z.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest 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": 1791335904,
"exit_code": 0,
"commit": "dd146cf1e0f60982d186b8393e96ca484fb4b0b8",
"branch": "claude/1258-config-dotnames"
}
41 changes: 41 additions & 0 deletions packages/python/migrations.d/2026-10-06-config-dotnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
### A config glob hides dot-named entries unless it spells them

#### Summary

`glob = "**/*.md"` used to match `.env.md` and everything under `.hid/`. It
now matches neither, as a path-table and bash (with `dotglob` off) do. A config
that read dot-named files without naming them loses those rows.

#### Required changes

| Before | After |
| --- | --- |
| `glob = "**/*.md"` (included `.hid/z.md`) | `glob = ".hid/*.md"` or `glob = "{*,.hid}/*.md"` |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- A dot-named component matches only where the glob spells a dot (`.hid`,
`.*`).
- A glob that names the dot directory (`.hid/*.md`) is unchanged.

#### Verification

```bash
cd "$(mktemp -d)"
mkdir .hid
echo '[{"p":"a"}]' > a.json
echo '[{"p":"h"}]' > .hid/h.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":"a"}]
```
3 changes: 3 additions & 0 deletions packages/rust/changelog.d/2026-10-06-config-dotnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Changed** A declared `[[table]] glob` no longer matches a dot-named file or
directory unless the glob spells it: `**/*.md` skips `.env.md` and
`.hid/z.md`, while `.hid/*.md` lists `.hid/z.md`.
41 changes: 41 additions & 0 deletions packages/rust/migrations.d/2026-10-06-config-dotnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
### A config glob hides dot-named entries unless it spells them

#### Summary

`glob = "**/*.md"` used to match `.env.md` and everything under `.hid/`. It
now matches neither, as a path-table and bash (with `dotglob` off) do. A config
that read dot-named files without naming them loses those rows.

#### Required changes

| Before | After |
| --- | --- |
| `glob = "**/*.md"` (included `.hid/z.md`) | `glob = ".hid/*.md"` or `glob = "{*,.hid}/*.md"` |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- A dot-named component matches only where the glob spells a dot (`.hid`,
`.*`).
- A glob that names the dot directory (`.hid/*.md`) is unchanged.

#### Verification

```bash
cd "$(mktemp -d)"
mkdir .hid
echo '[{"p":"a"}]' > a.json
echo '[{"p":"h"}]' > .hid/h.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":"a"}]
```
10 changes: 9 additions & 1 deletion packages/rust/src/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::posix_class::expand_posix_classes;
use crate::scanner::{PathGlob, compile_glob};
use globset::GlobBuilder;
use regex::{Regex, RegexBuilder};
use std::ffi::OsStr;
use std::path::Path;

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -142,13 +143,20 @@ impl TableMatcher {
pub fn match_all(&self, path: &Path) -> Vec<MatchResult> {
self.entries
.iter()
.filter(|entry| entry.pattern.is_match(path))
.filter(|entry| entry.pattern.is_match_unhidden(path))
.map(|entry| MatchResult {
table_name: entry.table_name.clone(),
})
.collect()
}

/// Whether any table's glob spells the dot-named `name`.
pub(crate) fn spells_dot_name(&self, name: &OsStr) -> bool {
self.entries
.iter()
.any(|entry| entry.pattern.spells_dot_name(name))
}

/// Returns true if the path matches any ignore pattern.
pub fn is_ignored(&self, path: &Path) -> bool {
self.ignore_set.iter().any(|p| p.is_match(path))
Expand Down
47 changes: 38 additions & 9 deletions packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ enum Kind {
}

impl PathGlob {
fn spells_dot_name(&self, name: &OsStr) -> bool {
pub(crate) fn spells_dot_name(&self, name: &OsStr) -> bool {
self.spelled_dot_names
.iter()
.any(|p| p.is_match(Path::new(name)))
Expand All @@ -246,6 +246,17 @@ impl PathGlob {
self.files.iter().any(|p| p.is_match(rel_path))
}

/// [`is_match`](Self::is_match), and every dot-named component of the path
/// is one the glob spells.
pub(crate) fn is_match_unhidden(&self, rel_path: &Path) -> bool {
self.is_match(rel_path)
&& rel_path
.components()
.map(|c| c.as_os_str())
.filter(|name| is_dot_named(name))
.all(|name| self.spells_dot_name(name))
}

fn has_components(&self) -> bool {
!self.components.is_empty()
}
Expand Down Expand Up @@ -584,10 +595,11 @@ impl Walk<'_> {
}

fn admits_name(&self, name: &OsStr) -> bool {
match self.glob {
Some(glob) => !is_dot_named(name) || glob.spells_dot_name(name),
None => true,
}
!is_dot_named(name)
|| match self.glob {
Some(glob) => glob.spells_dot_name(name),
None => self.ignore.spells_dot_name(name),
}
}

fn next_states(&self, states: &[usize], name: &OsStr, kind: Kind) -> Vec<usize> {
Expand Down Expand Up @@ -1018,7 +1030,7 @@ mod tests {
fs::create_dir_all(root.path().join("a").join("node_modules").join("pkg")).unwrap();
fs::create_dir_all(root.path().join("a").join(RESERVED_DIR)).unwrap();
fs::write(root.path().join("a").join("f.md"), "").unwrap();
let ignore = TableMatcher::new(&[], &["**/node_modules/**"]).unwrap();
let ignore = TableMatcher::new(&[(".dirsql/*", "t")], &["**/node_modules/**"]).unwrap();
let a = root.path().join("a");
let mut dirs = scan_dirs(root.path(), &a, &ignore);
dirs.sort();
Expand All @@ -1040,7 +1052,7 @@ mod tests {
fn explore_enters_a_dirsql_directory_below_the_top_level() {
let root = tempfile::tempdir().unwrap();
fs::create_dir_all(root.path().join("a").join(RESERVED_DIR)).unwrap();
let ignore = TableMatcher::new(&[], &[]).unwrap();
let ignore = TableMatcher::new(&[("**/.dirsql/*", "t")], &[]).unwrap();
let top = Place {
walk: walk_with(&ignore, None, Vec::new()),
dir: root.path().to_path_buf(),
Expand Down Expand Up @@ -1393,8 +1405,8 @@ mod tests {
}

#[test]
fn admits_every_dot_named_entry_when_no_glob_governs_the_walk() {
let ignore = TableMatcher::new(&[], &[]).unwrap();
fn a_table_walk_admits_a_dot_name_only_a_table_glob_spells() {
let ignore = TableMatcher::new(&[(".env", "t")], &[]).unwrap();
let walk = walk_with(&ignore, None, Vec::new());
assert!(walk.admits(
1,
Expand All @@ -1403,6 +1415,23 @@ mod tests {
Path::new("/r/.env"),
Path::new(".env")
));
assert!(!walk.admits(
1,
false,
OsStr::new(".other"),
Path::new("/r/.other"),
Path::new(".other")
));
}

#[test]
fn a_match_with_an_unspelled_dot_component_is_hidden() {
let glob = compile_glob("**/*.md").unwrap();
assert!(glob.is_match_unhidden(Path::new("sub/b.md")));
assert!(!glob.is_match_unhidden(Path::new(".env.md")));
assert!(!glob.is_match_unhidden(Path::new(".hid/z.md")));
let spelled = compile_glob(".hid/*.md").unwrap();
assert!(spelled.is_match_unhidden(Path::new(".hid/z.md")));
}

#[test]
Expand Down
16 changes: 16 additions & 0 deletions packages/rust/tests/config_glob_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,19 @@ fn a_brace_alternation_still_matches_each_option() {
assert_eq!(path_table_paths(root.path(), "{a,b}.md"), expected);
assert_eq!(config_paths(root.path(), "{a,b}.md"), expected);
}

#[test]
fn dot_named_entries_are_hidden_unless_the_glob_spells_them() {
let root = tree(&["a.md", ".env.md", ".hid/z.md", "sub/b.md"]);
let expected = vec!["a.md", "sub/b.md"];
assert_eq!(path_table_paths(root.path(), "**/*.md"), expected);
assert_eq!(config_paths(root.path(), "**/*.md"), expected);
}

#[test]
fn a_spelled_dot_directory_is_listed() {
let root = tree(&["a.md", ".hid/z.md"]);
let expected = vec![".hid/z.md"];
assert_eq!(path_table_paths(root.path(), ".hid/*.md"), expected);
assert_eq!(config_paths(root.path(), ".hid/*.md"), expected);
}
7 changes: 4 additions & 3 deletions packages/rust/tests/scan_progress_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ fn forced_progress_reports_the_walk_phase_on_stderr() {
err.contains("dirsql: scanning"),
"the directory walk draws a labeled progress line: {err:?}"
);
// Four: the three fixture files plus the `.dirsql.toml` the walk also
// visits. The walk counts files it reached, not rows it matched.
// Three: the fixture files. The dot-named `.dirsql.toml` is not spelled by
// the glob, so the walk skips it. The walk counts files it reached, not
// rows it matched.
assert!(
err.contains("dirsql: scanned 4 files in "),
err.contains("dirsql: scanned 3 files in "),
"the walk summarizes the files it actually reached: {err:?}"
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/ts/changelog.d/2026-10-06-config-dotnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Changed** A declared `[[table]] glob` no longer matches a dot-named file or
directory unless the glob spells it: `**/*.md` skips `.env.md` and
`.hid/z.md`, while `.hid/*.md` lists `.hid/z.md`.
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-1258-config-dotnames.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791335939,
"exit_code": 0,
"commit": "f1dac5535ee37b35fc9f204f8a64ba0927c21f05",
"branch": "claude/1258-config-dotnames"
}
41 changes: 41 additions & 0 deletions packages/ts/migrations.d/2026-10-06-config-dotnames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
### A config glob hides dot-named entries unless it spells them

#### Summary

`glob = "**/*.md"` used to match `.env.md` and everything under `.hid/`. It
now matches neither, as a path-table and bash (with `dotglob` off) do. A config
that read dot-named files without naming them loses those rows.

#### Required changes

| Before | After |
| --- | --- |
| `glob = "**/*.md"` (included `.hid/z.md`) | `glob = ".hid/*.md"` or `glob = "{*,.hid}/*.md"` |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- A dot-named component matches only where the glob spells a dot (`.hid`,
`.*`).
- A glob that names the dot directory (`.hid/*.md`) is unchanged.

#### Verification

```bash
cd "$(mktemp -d)"
mkdir .hid
echo '[{"p":"a"}]' > a.json
echo '[{"p":"h"}]' > .hid/h.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":"a"}]
```
Loading