This commit is contained in:
hcrypt 2026-05-25 12:26:22 +00:00 committed by GitHub
commit 581abb1336
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 309 additions and 1 deletions

View file

@ -0,0 +1,208 @@
package main
import (
"bytes"
"context"
"io"
"strings"
"sync"
"testing"
"github.com/restic/restic/internal/global"
"github.com/restic/restic/internal/ui/termstatus"
)
func newTestTerm() (*termstatus.Terminal, func()) {
ctx, cancel := context.WithCancel(context.Background())
term := termstatus.New(io.NopCloser(bytes.NewReader(nil)), io.Discard, io.Discard, true)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
term.Run(ctx)
}()
return term, func() {
cancel()
wg.Wait()
}
}
func TestTestPatternMatch(t *testing.T) {
for _, test := range []struct {
name string
pattern string
path string
caseInsensitive bool
wantMatch bool
wantErr bool
}{
{
name: "simple wildcard match",
pattern: "*.go",
path: "/home/user/main.go",
wantMatch: true,
},
{
name: "simple wildcard no match",
pattern: "*.txt",
path: "/home/user/main.go",
wantMatch: false,
},
{
name: "exact filename match",
pattern: "main.go",
path: "main.go",
wantMatch: true,
},
{
name: "case insensitive match",
pattern: "*.GO",
path: "/home/user/main.go",
caseInsensitive: true,
wantMatch: true,
},
{
name: "case insensitive no match",
pattern: "*.txt",
path: "/home/user/main.go",
caseInsensitive: true,
wantMatch: false,
},
{
name: "recursive wildcard match",
pattern: "**/.git/**",
path: "/home/user/project/.git/config",
wantMatch: true,
},
{
name: "recursive wildcard child match",
pattern: "**/.git/**",
path: "/home/user/project/.git",
wantMatch: true,
},
{
name: "absolute pattern match",
pattern: "/home/user/*.txt",
path: "/home/user/readme.txt",
wantMatch: true,
},
{
name: "absolute pattern no match different dir",
pattern: "/home/user/*.txt",
path: "/home/other/readme.txt",
wantMatch: false,
},
{
name: "single character wildcard",
pattern: "file.?o",
path: "/path/file.go",
wantMatch: true,
},
{
name: "question mark no match",
pattern: "file.?o",
path: "/path/file.py",
wantMatch: false,
},
{
name: "character class match",
pattern: "file.[gx]o",
path: "/path/file.go",
wantMatch: true,
},
{
name: "character class no match",
pattern: "file.[ab]o",
path: "/path/file.go",
wantMatch: false,
},
{
name: "directory wildcard match",
pattern: "/home/*/main.go",
path: "/home/user/main.go",
wantMatch: true,
},
{
name: "directory wildcard no match subdir",
pattern: "/home/*/main.go",
path: "/home/user/sub/main.go",
wantMatch: false,
},
{
name: "directory wildcard multi-level",
pattern: "/home/*/*/main.go",
path: "/home/user/sub/main.go",
wantMatch: true,
},
{
name: "invalid pattern",
pattern: "[",
path: "/home/user/main.go",
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
term, cleanup := newTestTerm()
defer cleanup()
gopts := global.Options{
Term: term,
}
opts := TestPatternOptions{
CaseInsensitive: test.caseInsensitive,
}
args := []string{test.pattern, test.path}
err := runTestPattern(opts, gopts, args)
if test.wantErr {
if err == nil {
t.Fatal("expected error but got none")
}
return
}
if test.wantMatch {
if err != nil {
t.Fatalf("expected match but got error: %v", err)
}
} else {
if err == nil {
t.Fatal("expected no match but got error")
}
if !strings.Contains(err.Error(), "pattern did not match") {
t.Fatalf("expected 'pattern did not match' error, got: %v", err)
}
}
})
}
}
func TestTestPatternWrongArgCount(t *testing.T) {
term, cleanup := newTestTerm()
defer cleanup()
gopts := global.Options{
Term: term,
}
opts := TestPatternOptions{}
err := runTestPattern(opts, gopts, []string{})
if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") {
t.Fatalf("expected 'wrong number of arguments' error, got: %v", err)
}
err = runTestPattern(opts, gopts, []string{"*.go"})
if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") {
t.Fatalf("expected 'wrong number of arguments' error, got: %v", err)
}
err = runTestPattern(opts, gopts, []string{"*.go", "/path", "extra"})
if err == nil || !strings.Contains(err.Error(), "wrong number of arguments") {
t.Fatalf("expected 'wrong number of arguments' error, got: %v", err)
}
}

View file

@ -101,6 +101,7 @@ The full documentation can be found at https://restic.readthedocs.io/ .
newSnapshotsCommand(globalOptions),
newStatsCommand(globalOptions),
newTagCommand(globalOptions),
newTestPatternCommand(globalOptions),
newUnlockCommand(globalOptions),
newVersionCommand(globalOptions),
)
@ -118,7 +119,7 @@ The full documentation can be found at https://restic.readthedocs.io/ .
// user for authentication).
func needsPassword(cmd string) bool {
switch cmd {
case "cache", "generate", "help", "options", "self-update", "version", "__complete", "__completeNoDesc":
case "cache", "generate", "help", "options", "self-update", "test-pattern", "version", "__complete", "__completeNoDesc":
return false
default:
return true

99
cmd_test_pattern.go Normal file
View file

@ -0,0 +1,99 @@
package main
import (
"strings"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/filter"
"github.com/restic/restic/internal/global"
"github.com/restic/restic/internal/ui"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func newTestPatternCommand(globalOptions *global.Options) *cobra.Command {
var opts TestPatternOptions
cmd := &cobra.Command{
Use: "test-pattern [flags] PATTERN PATH",
Short: "Test a pattern against a path",
Long: `
The "test-pattern" command tests whether a given pattern matches a specific
path. It uses the same pattern matching logic as the "find", "backup", and
"restore" commands, supporting the '**' recursive wildcard in addition to
filepath.Match patterns.
This is useful for verifying exclude/include patterns before running a backup.
EXIT STATUS
===========
Exit status is 0 if the pattern matched the path.
Exit status is 1 if the pattern did not match.
`,
Example: `restic test-pattern '*.go' /home/user/main.go
restic test-pattern --ignore-case '*.GO' /home/user/main.go
restic test-pattern '**/.git/**' /home/user/project/.git/config
restic test-pattern '/home/user/*.txt' /home/user/readme.txt`,
DisableAutoGenTag: true,
GroupID: cmdGroupDefault,
RunE: func(_ *cobra.Command, args []string) error {
return runTestPattern(opts, *globalOptions, args)
},
}
opts.AddFlags(cmd.Flags())
return cmd
}
// TestPatternOptions collects all options for the test-pattern command.
type TestPatternOptions struct {
CaseInsensitive bool
}
func (opts *TestPatternOptions) AddFlags(f *pflag.FlagSet) {
f.BoolVarP(&opts.CaseInsensitive, "ignore-case", "i", false, "ignore case for pattern")
}
func runTestPattern(opts TestPatternOptions, gopts global.Options, args []string) error {
if len(args) != 2 {
return errors.Fatal("wrong number of arguments, expecting: test-pattern [flags] PATTERN PATH")
}
pattern := args[0]
path := args[1]
normalizedPattern := pattern
normalizedPath := path
if opts.CaseInsensitive {
normalizedPattern = strings.ToLower(pattern)
normalizedPath = strings.ToLower(path)
}
matched, err := filter.Match(normalizedPattern, normalizedPath)
if err != nil {
return errors.Fatalf("error matching pattern: %v", err)
}
childMayMatch, err := filter.ChildMatch(normalizedPattern, normalizedPath)
if err != nil {
return errors.Fatalf("error testing child match: %v", err)
}
printer := ui.NewProgressPrinter(gopts.JSON, gopts.Verbosity, gopts.Term)
if opts.CaseInsensitive {
printer.S("pattern : %s (case-insensitive)\n", pattern)
} else {
printer.S("pattern : %s\n", pattern)
}
printer.S("path : %s\n", path)
printer.S("match : %v\n", matched)
printer.S("child match: %v\n", childMayMatch)
if !matched {
return errors.Fatal("pattern did not match")
}
return nil
}