Make **/ in an exclude pattern match zero directories (closes #338)
check / check (push) Waiting to run

The glob library needs a slash on both sides of **, so the anchored
pattern /**/x missed an x directly under the snapshot path, and
a/**/*.log missed a/x.log. As in gitignore, a leading **/ now makes any
pattern, anchored or not, match at every depth, and a /**/ inside a
pattern also matches a single slash.

Judgement call: the test for a/**/b uses a/**/*.log, because with a
plain name after the ** the glob library already matched a/b.

Model: opus-5-5
This commit is contained in:
2026-10-13 15:05:02 +00:00
parent e48bafccef
commit 910db05ef1
2 changed files with 60 additions and 4 deletions
+49
View File
@@ -578,3 +578,52 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
require.Equal(t, 1, result.FilesScanned,
"**/ file pattern should match at the top level and nested")
}
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
t.Parallel()
// As in gitignore, /**/ also matches directly under the source dir
fs := setupAnchoredTestFS(t)
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
defer cleanup()
ctx := context.Background()
createSnapshotRecord(ctx, t, repos, "test-snapshot")
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
require.NoError(t, err)
// /**/projectname should exclude BOTH:
// - /backup/projectname/file.txt
// - /backup/otherproject/projectname/file.txt
// Total files: 4, excluded: 2
require.Equal(t, 2, result.FilesScanned,
"/**/ pattern should match at the top level and nested")
}
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
t.Parallel()
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
// is a glob: with a plain name the glob library already matched a/b.
fs := &afero.MemMapFs{}
for _, path := range []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"} {
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
}
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
defer cleanup()
ctx := context.Background()
createSnapshotRecord(ctx, t, repos, "test-snapshot")
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
require.NoError(t, err)
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
// Total files: 3, excluded: 2
require.Equal(t, 1, result.FilesScanned,
"a/**/*.log should match a/x.log as well as a/c/y.log")
}
+11 -4
View File
@@ -2023,13 +2023,20 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
pattern = strings.TrimSuffix(pattern, "/")
// A leading "**/" demands a "/" before the name and so misses an
// entry directly under the source path. An unanchored pattern
// already matches at every depth, so drop the prefix: as in
// gitignore, "**/x" then also matches a top-level "x".
if !anchored {
// entry directly under the source path. It means "at every depth",
// which is what an unanchored pattern already does, so drop the
// prefix and match unanchored: as in gitignore, "**/x" and
// "/**/x" then also match a top-level "x".
if strings.HasPrefix(pattern, "**/") {
pattern = strings.TrimPrefix(pattern, "**/")
anchored = false
}
// Likewise "/**/" inside a pattern demands two slashes, so
// "a/**/b" misses "a/b". As in gitignore it stands for zero or
// more directories, so let it match a single "/" too.
pattern = strings.ReplaceAll(pattern, "/**/", "{/,/**/}")
// Compile the glob pattern
// For patterns without path separators, we need to match them as components
// e.g., ".git" should match ".git" anywhere in the path