From b0642d2763b8e296784bf244324911ca33a6a755 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 15:05:02 +0000 Subject: [PATCH] Make **/ in an exclude pattern match zero directories (closes #338) 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 pattern holding /**/ is also compiled with each /**/ made a single slash. The library's {a,b} alternatives cannot express this: with ** inside them in mid-pattern they miss paths they should match. 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 --- internal/snapshot/exclude_test.go | 51 +++++++++++++++++++++++++ internal/snapshot/scanner.go | 63 ++++++++++++++++++++++--------- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/internal/snapshot/exclude_test.go b/internal/snapshot/exclude_test.go index 48deb70..c23d03e 100644 --- a/internal/snapshot/exclude_test.go +++ b/internal/snapshot/exclude_test.go @@ -578,3 +578,54 @@ 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{} + files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"} + + for _, path := range files { + 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") +} diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index ee8a954..093a472 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -2035,33 +2035,60 @@ 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 { - pattern = strings.TrimPrefix(pattern, "**/") + // 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 rest, found := strings.CutPrefix(pattern, "**/"); found { + pattern = rest + anchored = false } - // 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 - g, err := glob.Compile(pattern, '/') - if err != nil { - log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err) + // Likewise "/**/" inside a pattern demands two slashes, so + // "a/**/*.log" misses "a/x.log". As in gitignore it stands for + // zero or more directories, so also compile the pattern with it + // made a single "/". The glob library's {a,b} is no help: with a + // "**" inside it in mid-pattern, it misses paths it should match. + for _, variant := range doubleStarVariants(pattern) { + // For patterns without path separators, we need to match them as components + // e.g., ".git" should match ".git" anywhere in the path + g, err := glob.Compile(variant, '/') + if err != nil { + log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err) - continue + break + } + + compiled = append(compiled, compiledPattern{ + pattern: g, + anchored: anchored, + original: p, + }) } - - compiled = append(compiled, compiledPattern{ - pattern: g, - anchored: anchored, - original: p, - }) } return compiled } +// doubleStarVariants returns pattern once for every way of keeping each +// "/**/" in it or making it a single "/". +func doubleStarVariants(pattern string) []string { + const doubleStar = "/**/" + + before, after, found := strings.Cut(pattern, doubleStar) + if !found { + return []string{pattern} + } + + var variants []string + + for _, rest := range doubleStarVariants(after) { + variants = append(variants, before+doubleStar+rest, before+"/"+rest) + } + + return variants +} + // shouldExclude checks if a path should be excluded based on exclude patterns // filePath is the full path to the file // rootPath is the root of the backup source directory