From 27f3820d30bf1105d8c7b8514eb6162a637ba5e6 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 01:42:14 +0000 Subject: [PATCH] Make a leading **/ in an exclude pattern match at the top level (closes #303) An unanchored exclude pattern such as **/node_modules or **/*.pyc needed a / before the name, so it excluded sub/node_modules and sub/y.pyc but backed up node_modules/ and x.pyc directly under the snapshot path. An unanchored pattern already matches at every depth, so the leading **/ is now dropped when the pattern is compiled and, as in gitignore, the pattern matches the top level too. Judgement call: an anchored /**/x is left as it was; the issue covers patterns that start with **/. Model: opus-5-5 --- TODO.md | 9 ++++++ internal/snapshot/exclude_test.go | 47 +++++++++++++++++++++++++++++++ internal/snapshot/scanner.go | 8 ++++++ 3 files changed, 64 insertions(+) diff --git a/TODO.md b/TODO.md index 018c80c..ab96770 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,15 @@ the tag exists and is exercised; what is left is merging `next` to # Completed Steps +- 2026-10-13: Made an exclude pattern starting with `**/` also match + directly under the snapshot path + ([issue #303](https://git.eeqj.de/sneak/vaultik/issues/303)). + `**/node_modules` and `**/*.pyc`, the patterns `config.example.yml` + uses, excluded `sub/node_modules` and `sub/y.pyc` but backed up + `node_modules/` and `x.pyc` at the top of the path, since the pattern + required a `/` before the name. As in gitignore, such a pattern now + matches at every depth, the top included. + - 2026-10-12: Rejected a retention duration longer than about 292 years ([issue #289](https://git.eeqj.de/sneak/vaultik/issues/289)). The number given to `--older-than` or `--keep-newer-than` was multiplied diff --git a/internal/snapshot/exclude_test.go b/internal/snapshot/exclude_test.go index 9870018..48deb70 100644 --- a/internal/snapshot/exclude_test.go +++ b/internal/snapshot/exclude_test.go @@ -531,3 +531,50 @@ func TestExcludePatterns_UnanchoredPatternFile(t *testing.T) { require.Equal(t, 1, result.FilesScanned, "Unanchored pattern for file should match anywhere") } + +func TestExcludePatterns_DoubleStarDirectoryAtEveryDepth(t *testing.T) { + t.Parallel() + + // As in gitignore, a leading **/ 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, + "**/ directory pattern should match at the top level and nested") +} + +func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) { + t.Parallel() + + // As in gitignore, a leading **/ also matches directly under the source dir + fs := setupAnchoredTestFS(t) + + scanner, repos, cleanup := createTestScanner(t, fs, []string{"**/*.txt"}) + defer cleanup() + + ctx := context.Background() + createSnapshotRecord(ctx, t, repos, "test-snapshot") + + result, err := scanner.Scan(ctx, "/backup", "test-snapshot") + require.NoError(t, err) + + // **/*.txt should exclude ALL .txt files: + // - /backup/file.txt + // - /backup/projectname/file.txt + // - /backup/otherproject/projectname/file.txt + // Total files: 4, excluded: 3 + require.Equal(t, 1, result.FilesScanned, + "**/ file pattern should match at the top level and nested") +} diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index c810278..9ee4937 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -2013,6 +2013,14 @@ func compileExcludePatterns(patterns []string) []compiledPattern { // Remove trailing slash if present (directory indicator) 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, "**/") + } + // 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