Skip to content

Commit c1bc58d

Browse files
committed
fileutils: add ShouldDescendExcludedDir for wildcard negation patterns
Signed-off-by: Jan Rodák <hony.com@seznam.cz>
1 parent b17b0a4 commit c1bc58d

3 files changed

Lines changed: 184 additions & 17 deletions

File tree

storage/pkg/archive/archive.go

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,24 +1012,9 @@ func tarWithOptionsTo(dest io.WriteCloser, srcPath string, options *TarOptions)
10121012
return nil
10131013
}
10141014

1015-
// No exceptions (!...) in patterns so just skip dir
1016-
if !pm.Exclusions() {
1017-
return filepath.SkipDir
1018-
}
1019-
1020-
dirSlash := relFilePath + string(filepath.Separator)
1021-
1022-
for _, pat := range pm.Patterns() {
1023-
if !pat.Exclusion() {
1024-
continue
1025-
}
1026-
if strings.HasPrefix(pat.String()+string(filepath.Separator), dirSlash) {
1027-
// found a match - so can't skip this dir
1028-
return nil
1029-
}
1015+
if fileutils.ShouldDescendExcludedDir(relFilePath, pm) {
1016+
return nil
10301017
}
1031-
1032-
// No matching exclusion dir so just skip dir
10331018
return filepath.SkipDir
10341019
}
10351020

storage/pkg/fileutils/fileutils.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,44 @@ func Matches(file string, patterns []string) (bool, error) {
299299
return pm.IsMatch(file)
300300
}
301301

302+
// ShouldDescendExcludedDir returns true if a directory that would otherwise be
303+
// skipped (excluded) should still be descended into because a negation pattern
304+
// (e.g. !cmd/main.go, !**/*.go) might re-include files underneath it.
305+
//
306+
// The check is intentionally conservative: it may return true for directories
307+
// that ultimately contain no re-included files, but actual file-level matching
308+
// via pm.IsMatch happens later and filters those out. Returning a false
309+
// positive only costs an extra directory walk; returning a false negative would
310+
// silently drop files the user asked to keep.
311+
func ShouldDescendExcludedDir(dirPath string, pm *PatternMatcher) bool {
312+
if pm == nil || !pm.Exclusions() {
313+
return false
314+
}
315+
dir := filepath.ToSlash(strings.Trim(dirPath, string(os.PathSeparator)))
316+
for _, pattern := range pm.Patterns() {
317+
if !pattern.Exclusion() {
318+
continue
319+
}
320+
slashPattern := filepath.ToSlash(strings.Trim(pattern.String(), string(os.PathSeparator)))
321+
if strings.HasPrefix(slashPattern, dir+"/") {
322+
return true
323+
}
324+
if firstWild := strings.IndexAny(slashPattern, "*?["); firstWild >= 0 {
325+
var literalPrefix string
326+
if idx := strings.LastIndex(slashPattern[:firstWild], "/"); idx >= 0 {
327+
literalPrefix = slashPattern[:idx]
328+
}
329+
if literalPrefix == "" {
330+
return true
331+
}
332+
if dir == literalPrefix || strings.HasPrefix(dir, literalPrefix+"/") {
333+
return true
334+
}
335+
}
336+
}
337+
return false
338+
}
339+
302340
// CopyFile copies from src to dst until either EOF is reached
303341
// on src or an error occurs. It verifies src exists and removes
304342
// the dst if it exists.

storage/pkg/fileutils/fileutils_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,147 @@ func TestMatchesAmount(t *testing.T) {
617617
assert.Equal(t, testCase.isMatch, isMatch, desc)
618618
}
619619
}
620+
621+
func TestShouldDescendExcludedDir(t *testing.T) {
622+
tests := []struct {
623+
name string
624+
dir string
625+
patterns []string
626+
expected bool
627+
}{
628+
{
629+
name: "nil PatternMatcher",
630+
dir: "cmd",
631+
patterns: nil,
632+
expected: false,
633+
},
634+
{
635+
name: "no exclusions",
636+
dir: "cmd",
637+
patterns: []string{"*.go"},
638+
expected: false,
639+
},
640+
{
641+
name: "literal prefix match",
642+
dir: "cmd",
643+
patterns: []string{"*.go", "!cmd/main.go"},
644+
expected: true,
645+
},
646+
{
647+
name: "literal prefix nested",
648+
dir: "cmd/sub",
649+
patterns: []string{"*.go", "!cmd/sub/main.go"},
650+
expected: true,
651+
},
652+
{
653+
name: "literal prefix partial non-match",
654+
dir: "cmds",
655+
patterns: []string{"*.go", "!cmd/main.go"},
656+
expected: false,
657+
},
658+
{
659+
name: "double-star wildcard at root",
660+
dir: "cmd",
661+
patterns: []string{"*.go", "!**/*.go"},
662+
expected: true,
663+
},
664+
{
665+
name: "double-star wildcard deeply nested",
666+
dir: "a/b/c",
667+
patterns: []string{"*", "!**/*.txt"},
668+
expected: true,
669+
},
670+
{
671+
name: "single-star wildcard",
672+
dir: "cmd",
673+
patterns: []string{"*", "!*/*.go"},
674+
expected: true,
675+
},
676+
{
677+
name: "question-mark wildcard",
678+
dir: "cmd",
679+
patterns: []string{"*", "!cm?/main.go"},
680+
expected: true,
681+
},
682+
{
683+
name: "bracket expression wildcard",
684+
dir: "cmd",
685+
patterns: []string{"*", "!cm[d]/main.go"},
686+
expected: true,
687+
},
688+
{
689+
name: "mid-segment wildcard",
690+
dir: "cmd",
691+
patterns: []string{"*", "!cmd/image*/main.go"},
692+
expected: true,
693+
},
694+
{
695+
name: "mid-segment wildcard nested dir",
696+
dir: "cmd/imager",
697+
patterns: []string{"*", "!cmd/image*/main.go"},
698+
expected: true,
699+
},
700+
{
701+
name: "leading slash stripped",
702+
dir: "cmd",
703+
patterns: []string{"*", "!/cmd/main.go"},
704+
expected: true,
705+
},
706+
{
707+
name: "dir matches literal prefix exactly",
708+
dir: "vendor",
709+
patterns: []string{"*", "!vendor/github.com/foo"},
710+
expected: true,
711+
},
712+
{
713+
name: "dir is child of literal prefix",
714+
dir: "vendor/github.com",
715+
patterns: []string{"*", "!vendor/github.com/foo"},
716+
expected: true,
717+
},
718+
{
719+
name: "dir is parent of literal prefix",
720+
dir: "vendor",
721+
patterns: []string{"*", "!vendor/github.com/foo"},
722+
expected: true,
723+
},
724+
{
725+
name: "no matching exclusion pattern",
726+
dir: "docs",
727+
patterns: []string{"*", "!cmd/main.go"},
728+
expected: false,
729+
},
730+
{
731+
name: "unrelated wildcard pattern",
732+
dir: "docs",
733+
patterns: []string{"*", "!cmd/*/*.go"},
734+
expected: false,
735+
},
736+
{
737+
name: "root-level glob matches any dir",
738+
dir: "anything",
739+
patterns: []string{"*", "!*.go"},
740+
expected: true,
741+
},
742+
{
743+
name: "trailing slash on dir path",
744+
dir: "cmd/",
745+
patterns: []string{"*", "!cmd/main.go"},
746+
expected: true,
747+
},
748+
}
749+
750+
for _, tc := range tests {
751+
t.Run(tc.name, func(t *testing.T) {
752+
if tc.patterns == nil {
753+
result := ShouldDescendExcludedDir(tc.dir, nil)
754+
assert.Equal(t, tc.expected, result)
755+
return
756+
}
757+
pm, err := NewPatternMatcher(tc.patterns)
758+
require.NoError(t, err)
759+
result := ShouldDescendExcludedDir(tc.dir, pm)
760+
assert.Equal(t, tc.expected, result, "dir=%q patterns=%v", tc.dir, tc.patterns)
761+
})
762+
}
763+
}

0 commit comments

Comments
 (0)