diff --git a/internal/snapshot/exclude_test.go b/internal/snapshot/exclude_test.go index 48deb70..b194972 100644 --- a/internal/snapshot/exclude_test.go +++ b/internal/snapshot/exclude_test.go @@ -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") +} diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 32441cf..ab117de 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -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