Merge pull request #5191 from wplapper/cmd_rewrite_include

This commit is contained in:
Michael Eischer
2026-02-01 11:53:05 +01:00
committed by GitHub
9 changed files with 383 additions and 30 deletions

View File

@@ -0,0 +1,12 @@
Enhancement: Support include filters in `rewrite` command
The enhancement enables the standard include filter options
--iinclude pattern same as --include pattern but ignores the casing of filenames
--iinclude-file file same as --include-file but ignores casing of filenames in patterns
-i, --include pattern include a pattern (can be specified multiple times)
--include-file file read include patterns from a file (can be specified multiple times)
The exclusion or inclusion of filter parameters is exclusive, as in other commands.
https://github.com/restic/restic/issues/4278
https://github.com/restic/restic/pull/5191

View File

@@ -151,7 +151,7 @@ func runRepairSnapshots(ctx context.Context, gopts global.Options, opts RepairOp
func(ctx context.Context, sn *data.Snapshot, uploader restic.BlobSaver) (restic.ID, *data.SnapshotSummary, error) { func(ctx context.Context, sn *data.Snapshot, uploader restic.BlobSaver) (restic.ID, *data.SnapshotSummary, error) {
id, err := rewriter.RewriteTree(ctx, repo, uploader, "/", *sn.Tree) id, err := rewriter.RewriteTree(ctx, repo, uploader, "/", *sn.Tree)
return id, nil, err return id, nil, err
}, opts.DryRun, opts.Forget, nil, "repaired", printer) }, opts.DryRun, opts.Forget, nil, "repaired", printer, false)
if err != nil { if err != nil {
return errors.Fatalf("unable to rewrite snapshot ID %q: %v", sn.ID().Str(), err) return errors.Fatalf("unable to rewrite snapshot ID %q: %v", sn.ID().Str(), err)
} }

View File

@@ -30,6 +30,9 @@ The "rewrite" command excludes files from existing snapshots. It creates new
snapshots containing the same data as the original ones, but without the files snapshots containing the same data as the original ones, but without the files
you specify to exclude. All metadata (time, host, tags) will be preserved. you specify to exclude. All metadata (time, host, tags) will be preserved.
Alternatively you can use one of the --include variants to only include files
in the new snapshot which you want to preserve.
The snapshots to rewrite are specified using the --host, --tag and --path options, The snapshots to rewrite are specified using the --host, --tag and --path options,
or by providing a list of snapshot IDs. Please note that specifying neither any of or by providing a list of snapshot IDs. Please note that specifying neither any of
these options nor a snapshot ID will cause the command to rewrite all snapshots. these options nor a snapshot ID will cause the command to rewrite all snapshots.
@@ -46,8 +49,8 @@ When rewrite is used with the --snapshot-summary option, a new snapshot is
created containing statistics summary data. Only two fields in the summary will created containing statistics summary data. Only two fields in the summary will
be non-zero: TotalFilesProcessed and TotalBytesProcessed. be non-zero: TotalFilesProcessed and TotalBytesProcessed.
When rewrite is called with one of the --exclude options, TotalFilesProcessed When rewrite is called with one of the --exclude or --include options,
and TotalBytesProcessed will be updated in the snapshot summary. TotalFilesProcessed and TotalBytesProcessed will be updated in the snapshot summary.
EXIT STATUS EXIT STATUS
=========== ===========
@@ -109,6 +112,7 @@ type RewriteOptions struct {
Metadata snapshotMetadataArgs Metadata snapshotMetadataArgs
data.SnapshotFilter data.SnapshotFilter
filter.ExcludePatternOptions filter.ExcludePatternOptions
filter.IncludePatternOptions
} }
func (opts *RewriteOptions) AddFlags(f *pflag.FlagSet) { func (opts *RewriteOptions) AddFlags(f *pflag.FlagSet) {
@@ -120,6 +124,7 @@ func (opts *RewriteOptions) AddFlags(f *pflag.FlagSet) {
initMultiSnapshotFilter(f, &opts.SnapshotFilter, true) initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
opts.ExcludePatternOptions.Add(f) opts.ExcludePatternOptions.Add(f)
opts.IncludePatternOptions.Add(f)
} }
// rewriteFilterFunc returns the filtered tree ID or an error. If a snapshot summary is returned, the snapshot will // rewriteFilterFunc returns the filtered tree ID or an error. If a snapshot summary is returned, the snapshot will
@@ -136,33 +141,31 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *data.
return false, err return false, err
} }
includeByNameFuncs, err := opts.IncludePatternOptions.CollectPatterns(printer.E)
if err != nil {
return false, err
}
metadata, err := opts.Metadata.convert() metadata, err := opts.Metadata.convert()
if err != nil { if err != nil {
return false, err return false, err
} }
condInclude := len(includeByNameFuncs) > 0
condExclude := len(rejectByNameFuncs) > 0
var filter rewriteFilterFunc var filter rewriteFilterFunc
if len(rejectByNameFuncs) > 0 || opts.SnapshotSummary { if condInclude || condExclude || opts.SnapshotSummary {
selectByName := func(nodepath string) bool { var rewriteNode walker.NodeRewriteFunc
for _, reject := range rejectByNameFuncs { var keepEmptyDirectoryFunc walker.NodeKeepEmptyDirectoryFunc
if reject(nodepath) { if condInclude {
return false rewriteNode, keepEmptyDirectoryFunc = gatherIncludeFilters(includeByNameFuncs, printer)
} } else {
} rewriteNode = gatherExcludeFilters(rejectByNameFuncs, printer)
return true
} }
rewriteNode := func(node *data.Node, path string) *data.Node { rewriter, querySize := walker.NewSnapshotSizeRewriter(rewriteNode, keepEmptyDirectoryFunc)
if selectByName(path) {
return node
}
printer.P("excluding %s", path)
return nil
}
rewriter, querySize := walker.NewSnapshotSizeRewriter(rewriteNode)
filter = func(ctx context.Context, sn *data.Snapshot, uploader restic.BlobSaver) (restic.ID, *data.SnapshotSummary, error) { filter = func(ctx context.Context, sn *data.Snapshot, uploader restic.BlobSaver) (restic.ID, *data.SnapshotSummary, error) {
id, err := rewriter.RewriteTree(ctx, repo, uploader, "/", *sn.Tree) id, err := rewriter.RewriteTree(ctx, repo, uploader, "/", *sn.Tree)
@@ -186,11 +189,12 @@ func rewriteSnapshot(ctx context.Context, repo *repository.Repository, sn *data.
} }
return filterAndReplaceSnapshot(ctx, repo, sn, return filterAndReplaceSnapshot(ctx, repo, sn,
filter, opts.DryRun, opts.Forget, metadata, "rewrite", printer) filter, opts.DryRun, opts.Forget, metadata, "rewrite", printer, len(includeByNameFuncs) > 0)
} }
func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *data.Snapshot, func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *data.Snapshot,
filter rewriteFilterFunc, dryRun bool, forget bool, newMetadata *snapshotMetadata, addTag string, printer progress.Printer) (bool, error) { filter rewriteFilterFunc, dryRun bool, forget bool, newMetadata *snapshotMetadata, addTag string, printer progress.Printer,
keepEmptySnapshot bool) (bool, error) {
var filteredTree restic.ID var filteredTree restic.ID
var summary *data.SnapshotSummary var summary *data.SnapshotSummary
@@ -204,6 +208,10 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *d
} }
if filteredTree.IsNull() { if filteredTree.IsNull() {
if keepEmptySnapshot {
debug.Log("Snapshot %v not modified", sn)
return false, nil
}
if dryRun { if dryRun {
printer.P("would delete empty snapshot") printer.P("would delete empty snapshot")
} else { } else {
@@ -284,8 +292,12 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *d
} }
func runRewrite(ctx context.Context, opts RewriteOptions, gopts global.Options, args []string, term ui.Terminal) error { func runRewrite(ctx context.Context, opts RewriteOptions, gopts global.Options, args []string, term ui.Terminal) error {
if !opts.SnapshotSummary && opts.ExcludePatternOptions.Empty() && opts.Metadata.empty() { hasExcludes := !opts.ExcludePatternOptions.Empty()
return errors.Fatal("Nothing to do: no excludes provided and no new metadata provided") hasIncludes := !opts.IncludePatternOptions.Empty()
if !opts.SnapshotSummary && !hasExcludes && !hasIncludes && opts.Metadata.empty() {
return errors.Fatal("Nothing to do: no excludes/includes provided and no new metadata provided")
} else if hasExcludes && hasIncludes {
return errors.Fatal("exclude and include patterns are mutually exclusive")
} }
printer := ui.NewProgressPrinter(false, gopts.Verbosity, term) printer := ui.NewProgressPrinter(false, gopts.Verbosity, term)
@@ -348,3 +360,72 @@ func runRewrite(ctx context.Context, opts RewriteOptions, gopts global.Options,
return nil return nil
} }
func gatherIncludeFilters(includeByNameFuncs []filter.IncludeByNameFunc, printer progress.Printer) (rewriteNode walker.NodeRewriteFunc, keepEmptyDirectory walker.NodeKeepEmptyDirectoryFunc) {
inSelectByName := func(nodepath string, node *data.Node) bool {
for _, include := range includeByNameFuncs {
matched, childMayMatch := include(nodepath)
if node.Type == data.NodeTypeDir {
// include directories if they or some of their children may be included
if matched || childMayMatch {
return true
}
} else if matched {
return true
}
}
return false
}
rewriteNode = func(node *data.Node, path string) *data.Node {
if inSelectByName(path, node) {
if node.Type != data.NodeTypeDir {
printer.VV("including %q\n", path)
}
return node
}
return nil
}
inSelectByNameDir := func(nodepath string) bool {
for _, include := range includeByNameFuncs {
matched, _ := include(nodepath)
if matched {
return matched
}
}
return false
}
keepEmptyDirectory = func(path string) bool {
keep := inSelectByNameDir(path)
if keep {
printer.VV("including directory %q\n", path)
}
return keep
}
return rewriteNode, keepEmptyDirectory
}
func gatherExcludeFilters(excludeByNameFuncs []filter.RejectByNameFunc, printer progress.Printer) (rewriteNode walker.NodeRewriteFunc) {
exSelectByName := func(nodepath string) bool {
for _, reject := range excludeByNameFuncs {
if reject(nodepath) {
return false
}
}
return true
}
rewriteNode = func(node *data.Node, path string) *data.Node {
if exSelectByName(path) {
return node
}
printer.VV("excluding %q\n", path)
return nil
}
return rewriteNode
}

View File

@@ -2,7 +2,9 @@ package main
import ( import (
"context" "context"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/restic/restic/internal/data" "github.com/restic/restic/internal/data"
@@ -27,6 +29,27 @@ func testRunRewriteExclude(t testing.TB, gopts global.Options, excludes []string
})) }))
} }
func testRunRewriteWithOpts(t testing.TB, opts RewriteOptions, gopts global.Options, args []string) error {
rtest.OK(t, withTermStatus(t, gopts, func(ctx context.Context, gopts global.Options) error {
return runRewrite(context.TODO(), opts, gopts, args, gopts.Term)
}))
return nil
}
// testLsOutputContainsCount runs restic ls with the given options and asserts that
// exactly expectedCount lines of the output contain substring.
func testLsOutputContainsCount(t testing.TB, gopts global.Options, lsOpts LsOptions, lsArgs []string, substring string, expectedCount int) {
t.Helper()
out := testRunLsWithOpts(t, gopts, lsOpts, lsArgs)
count := 0
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, substring) {
count++
}
}
rtest.Assert(t, count == expectedCount, "expected %d lines containing %q, but got %d", expectedCount, substring, count)
}
func createBasicRewriteRepo(t testing.TB, env *testEnvironment) restic.ID { func createBasicRewriteRepo(t testing.TB, env *testEnvironment) restic.ID {
testSetupBackupData(t, env) testSetupBackupData(t, env)
@@ -39,6 +62,20 @@ func createBasicRewriteRepo(t testing.TB, env *testEnvironment) restic.ID {
return snapshotIDs[0] return snapshotIDs[0]
} }
func createBasicRewriteRepoWithEmptyDirectory(t testing.TB, env *testEnvironment) restic.ID {
testSetupBackupData(t, env)
// make an empty directory named "empty-directory"
rtest.OK(t, os.Mkdir(filepath.Join(env.testdata, "/0/tests", "empty-directory"), 0755))
// create backup
testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, BackupOptions{}, env.gopts)
snapshotIDs := testRunList(t, env.gopts, "snapshots")
rtest.Assert(t, len(snapshotIDs) == 1, "expected one snapshot, got %v", snapshotIDs)
return snapshotIDs[0]
}
func getSnapshot(t testing.TB, snapshotID restic.ID, env *testEnvironment) *data.Snapshot { func getSnapshot(t testing.TB, snapshotID restic.ID, env *testEnvironment) *data.Snapshot {
t.Helper() t.Helper()
@@ -195,3 +232,122 @@ func TestRewriteSnaphotSummary(t *testing.T) {
rtest.Equals(t, oldSummary.TotalBytesProcessed, newSn.Summary.TotalBytesProcessed, "unexpected TotalBytesProcessed value") rtest.Equals(t, oldSummary.TotalBytesProcessed, newSn.Summary.TotalBytesProcessed, "unexpected TotalBytesProcessed value")
rtest.Equals(t, oldSummary.TotalFilesProcessed, newSn.Summary.TotalFilesProcessed, "unexpected TotalFilesProcessed value") rtest.Equals(t, oldSummary.TotalFilesProcessed, newSn.Summary.TotalFilesProcessed, "unexpected TotalFilesProcessed value")
} }
func TestRewriteInclude(t *testing.T) {
for _, tc := range []struct {
name string
opts RewriteOptions
lsSubstring string
lsExpectedCount int
summaryFilesExpected uint
}{
{"relative", RewriteOptions{
Forget: true,
IncludePatternOptions: filter.IncludePatternOptions{Includes: []string{"*.txt"}},
}, ".txt", 2, 2},
{"absolute", RewriteOptions{
Forget: true,
// test that childMatches are working by only matching a subdirectory
IncludePatternOptions: filter.IncludePatternOptions{Includes: []string{"/testdata/0/for_cmd_ls"}},
}, "/testdata/0", 5, 3},
} {
t.Run(tc.name, func(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
createBasicRewriteRepo(t, env)
snapshots := testListSnapshots(t, env.gopts, 1)
rtest.OK(t, testRunRewriteWithOpts(t, tc.opts, env.gopts, []string{"latest"}))
newSnapshots := testListSnapshots(t, env.gopts, 1)
rtest.Assert(t, snapshots[0] != newSnapshots[0], "snapshot id should have changed")
testLsOutputContainsCount(t, env.gopts, LsOptions{}, []string{"latest"}, tc.lsSubstring, tc.lsExpectedCount)
sn := testLoadSnapshot(t, env.gopts, newSnapshots[0])
rtest.Assert(t, sn.Summary != nil, "snapshot should have a summary attached")
rtest.Assert(t, sn.Summary.TotalFilesProcessed == tc.summaryFilesExpected,
"there should be %d files in the snapshot, but there are %d files", tc.summaryFilesExpected, sn.Summary.TotalFilesProcessed)
})
}
}
func TestRewriteExcludeFiles(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
createBasicRewriteRepo(t, env)
snapshots := testListSnapshots(t, env.gopts, 1)
// exclude txt files
err := testRunRewriteWithOpts(t,
RewriteOptions{
Forget: true,
ExcludePatternOptions: filter.ExcludePatternOptions{Excludes: []string{"*.txt"}},
},
env.gopts,
[]string{"latest"})
rtest.OK(t, err)
newSnapshots := testListSnapshots(t, env.gopts, 1)
rtest.Assert(t, snapshots[0] != newSnapshots[0], "snapshot id should have changed")
testLsOutputContainsCount(t, env.gopts, LsOptions{}, []string{"latest"}, ".txt", 0)
}
func TestRewriteExcludeIncludeContradiction(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
testRunInit(t, env.gopts)
// test contradiction
err := withTermStatus(t, env.gopts, func(ctx context.Context, gopts global.Options) error {
return runRewrite(ctx,
RewriteOptions{
ExcludePatternOptions: filter.ExcludePatternOptions{Excludes: []string{"nonsense"}},
IncludePatternOptions: filter.IncludePatternOptions{Includes: []string{"not allowed"}},
},
gopts, []string{"quack"}, env.gopts.Term)
})
rtest.Assert(t, err != nil && strings.Contains(err.Error(), "exclude and include patterns are mutually exclusive"), `expected to fail command with message "exclude and include patterns are mutually exclusive"`)
}
func TestRewriteIncludeEmptyDirectory(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
snapIDEmpty := createBasicRewriteRepoWithEmptyDirectory(t, env)
// restic rewrite <snapshots[0]> -i empty-directory --forget
// exclude txt files
err := testRunRewriteWithOpts(t,
RewriteOptions{
Forget: true,
IncludePatternOptions: filter.IncludePatternOptions{Includes: []string{"empty-directory"}},
},
env.gopts,
[]string{"latest"})
rtest.OK(t, err)
newSnapshots := testListSnapshots(t, env.gopts, 1)
rtest.Assert(t, snapIDEmpty != newSnapshots[0], "snapshot id should have changed")
testLsOutputContainsCount(t, env.gopts, LsOptions{}, []string{"latest"}, "empty-directory", 1)
}
// TestRewriteIncludeNothing makes sure when nothing is included, the original snapshot stays untouched
func TestRewriteIncludeNothing(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
createBasicRewriteRepo(t, env)
snapsBefore := testListSnapshots(t, env.gopts, 1)
// restic rewrite latest -i nothing-whatsoever --forget
err := testRunRewriteWithOpts(t,
RewriteOptions{
Forget: true,
IncludePatternOptions: filter.IncludePatternOptions{Includes: []string{"nothing-whatsoever"}},
},
env.gopts,
[]string{"latest"})
rtest.OK(t, err)
snapsAfter := testListSnapshots(t, env.gopts, 1)
rtest.Assert(t, snapsBefore[0] == snapsAfter[0], "snapshots should be identical but are %s and %s",
snapsBefore[0].Str(), snapsAfter[0].Str())
}

View File

@@ -336,6 +336,13 @@ The options ``--exclude``, ``--exclude-file``, ``--iexclude`` and
``--iexclude-file`` are supported. They behave the same way as for the backup ``--iexclude-file`` are supported. They behave the same way as for the backup
command, see :ref:`backup-excluding-files` for details. command, see :ref:`backup-excluding-files` for details.
The options ``--include``, ``--include-file``, ``--iinclude`` and
``--iinclude-file`` are supported as well.
The ``--include`` variants allow you to reduce an existing snapshot or a set of snapshots
to those files that you are really interested in. An example could be all pictures
from a snapshot:
``restic rewrite -r ... --iinclude "*.jpg" --iinclude "*.jpeg" --iinclude "*.png"``.
It is possible to rewrite only a subset of snapshots by filtering them the same It is possible to rewrite only a subset of snapshots by filtering them the same
way as for the ``copy`` command, see :ref:`copy-filtering-snapshots`. way as for the ``copy`` command, see :ref:`copy-filtering-snapshots`.

View File

@@ -223,6 +223,11 @@ func (t *TreeWriter) Finalize(ctx context.Context) (restic.ID, error) {
return id, err return id, err
} }
// Count returns the number of nodes in the tree
func (t *TreeWriter) Count() int {
return t.builder.Count()
}
func SaveTree(ctx context.Context, saver restic.BlobSaver, nodes TreeNodeIterator) (restic.ID, error) { func SaveTree(ctx context.Context, saver restic.BlobSaver, nodes TreeNodeIterator) (restic.ID, error) {
treeWriter := NewTreeWriter(saver) treeWriter := NewTreeWriter(saver)
for item := range nodes { for item := range nodes {
@@ -238,8 +243,9 @@ func SaveTree(ctx context.Context, saver restic.BlobSaver, nodes TreeNodeIterato
} }
type TreeJSONBuilder struct { type TreeJSONBuilder struct {
buf bytes.Buffer buf bytes.Buffer
lastName string lastName string
countNodes int
} }
func NewTreeJSONBuilder() *TreeJSONBuilder { func NewTreeJSONBuilder() *TreeJSONBuilder {
@@ -262,6 +268,7 @@ func (builder *TreeJSONBuilder) AddNode(node *Node) error {
return err return err
} }
_, _ = builder.buf.Write(val) _, _ = builder.buf.Write(val)
builder.countNodes++
return nil return nil
} }
@@ -275,6 +282,11 @@ func (builder *TreeJSONBuilder) Finalize() ([]byte, error) {
return buf, nil return buf, nil
} }
// Count returns the number of nodes in the tree
func (builder *TreeJSONBuilder) Count() int {
return builder.countNodes
}
func FindTreeDirectory(ctx context.Context, repo restic.BlobLoader, id *restic.ID, dir string) (*restic.ID, error) { func FindTreeDirectory(ctx context.Context, repo restic.BlobLoader, id *restic.ID, dir string) (*restic.ID, error) {
if id == nil { if id == nil {
return nil, errors.New("tree id is null") return nil, errors.New("tree id is null")

View File

@@ -25,6 +25,10 @@ func (opts *IncludePatternOptions) Add(f *pflag.FlagSet) {
f.StringArrayVar(&opts.InsensitiveIncludeFiles, "iinclude-file", nil, "same as --include-file but ignores casing of `file`names in patterns") f.StringArrayVar(&opts.InsensitiveIncludeFiles, "iinclude-file", nil, "same as --include-file but ignores casing of `file`names in patterns")
} }
func (opts *IncludePatternOptions) Empty() bool {
return len(opts.Includes) == 0 && len(opts.InsensitiveIncludes) == 0 && len(opts.IncludeFiles) == 0 && len(opts.InsensitiveIncludeFiles) == 0
}
func (opts IncludePatternOptions) CollectPatterns(warnf func(msg string, args ...interface{})) ([]IncludeByNameFunc, error) { func (opts IncludePatternOptions) CollectPatterns(warnf func(msg string, args ...interface{})) ([]IncludeByNameFunc, error) {
var fs []IncludeByNameFunc var fs []IncludeByNameFunc
if len(opts.IncludeFiles) > 0 { if len(opts.IncludeFiles) > 0 {

View File

@@ -13,6 +13,7 @@ import (
type NodeRewriteFunc func(node *data.Node, path string) *data.Node type NodeRewriteFunc func(node *data.Node, path string) *data.Node
type FailedTreeRewriteFunc func(nodeID restic.ID, path string, err error) (data.TreeNodeIterator, error) type FailedTreeRewriteFunc func(nodeID restic.ID, path string, err error) (data.TreeNodeIterator, error)
type QueryRewrittenSizeFunc func() SnapshotSize type QueryRewrittenSizeFunc func() SnapshotSize
type NodeKeepEmptyDirectoryFunc func(path string) bool
type SnapshotSize struct { type SnapshotSize struct {
FileCount uint FileCount uint
@@ -21,7 +22,8 @@ type SnapshotSize struct {
type RewriteOpts struct { type RewriteOpts struct {
// return nil to remove the node // return nil to remove the node
RewriteNode NodeRewriteFunc RewriteNode NodeRewriteFunc
KeepEmptyDirectory NodeKeepEmptyDirectoryFunc
// decide what to do with a tree that could not be loaded. Return nil to remove the node. By default the load error is returned which causes the operation to fail. // decide what to do with a tree that could not be loaded. Return nil to remove the node. By default the load error is returned which causes the operation to fail.
RewriteFailedTree FailedTreeRewriteFunc RewriteFailedTree FailedTreeRewriteFunc
@@ -56,10 +58,15 @@ func NewTreeRewriter(opts RewriteOpts) *TreeRewriter {
return nil, err return nil, err
} }
} }
if rw.opts.KeepEmptyDirectory == nil {
rw.opts.KeepEmptyDirectory = func(_ string) bool {
return true
}
}
return rw return rw
} }
func NewSnapshotSizeRewriter(rewriteNode NodeRewriteFunc) (*TreeRewriter, QueryRewrittenSizeFunc) { func NewSnapshotSizeRewriter(rewriteNode NodeRewriteFunc, keepEmptyDirecoryFilter NodeKeepEmptyDirectoryFunc) (*TreeRewriter, QueryRewrittenSizeFunc) {
var count uint var count uint
var size uint64 var size uint64
@@ -72,7 +79,8 @@ func NewSnapshotSizeRewriter(rewriteNode NodeRewriteFunc) (*TreeRewriter, QueryR
} }
return node return node
}, },
DisableNodeCache: true, DisableNodeCache: true,
KeepEmptyDirectory: keepEmptyDirecoryFilter,
}) })
ss := func() SnapshotSize { ss := func() SnapshotSize {
@@ -159,6 +167,8 @@ func (t *TreeRewriter) RewriteTree(ctx context.Context, loader restic.BlobLoader
newID, err := t.RewriteTree(ctx, loader, saver, path, subtree) newID, err := t.RewriteTree(ctx, loader, saver, path, subtree)
if err != nil { if err != nil {
return restic.ID{}, err return restic.ID{}, err
} else if err == nil && newID.IsNull() {
continue
} }
node.Subtree = &newID node.Subtree = &newID
err = tb.AddNode(node) err = tb.AddNode(node)
@@ -171,6 +181,9 @@ func (t *TreeRewriter) RewriteTree(ctx context.Context, loader restic.BlobLoader
if err != nil { if err != nil {
return restic.ID{}, err return restic.ID{}, err
} }
if tb.Count() == 0 && !t.opts.KeepEmptyDirectory(nodepath) {
return restic.ID{}, nil
}
if t.replaces != nil { if t.replaces != nil {
t.replaces[nodeID] = newTreeID t.replaces[nodeID] = newTreeID

View File

@@ -306,7 +306,7 @@ func TestSnapshotSizeQuery(t *testing.T) {
} }
return node return node
} }
rewriter, querySize := NewSnapshotSizeRewriter(rewriteNode) rewriter, querySize := NewSnapshotSizeRewriter(rewriteNode, nil)
newRoot, err := rewriter.RewriteTree(ctx, modrepo, modrepo, "/", root) newRoot, err := rewriter.RewriteTree(ctx, modrepo, modrepo, "/", root)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
@@ -329,6 +329,74 @@ func TestSnapshotSizeQuery(t *testing.T) {
} }
func TestRewriterKeepEmptyDirectory(t *testing.T) {
var paths []string
tests := []struct {
name string
keepEmpty NodeKeepEmptyDirectoryFunc
assert func(t *testing.T, newRoot restic.ID)
}{
{
name: "Keep",
keepEmpty: func(string) bool { return true },
assert: func(t *testing.T, newRoot restic.ID) {
_, expRoot := BuildTreeMap(TestTree{"empty": TestTree{}})
test.Assert(t, newRoot == expRoot, "expected empty dir kept")
},
},
{
name: "Drop subdir only",
keepEmpty: func(p string) bool { return p != "/empty" },
assert: func(t *testing.T, newRoot restic.ID) {
_, expRoot := BuildTreeMap(TestTree{})
test.Assert(t, newRoot == expRoot, "expected empty root")
},
},
{
name: "Drop all",
keepEmpty: func(string) bool { return false },
assert: func(t *testing.T, newRoot restic.ID) {
test.Assert(t, newRoot.IsNull(), "expected null root")
},
},
{
name: "Paths",
keepEmpty: func(p string) bool {
paths = append(paths, p)
return p != "/empty"
},
assert: func(t *testing.T, newRoot restic.ID) {
test.Assert(t, len(paths) >= 2, "expected at least two KeepEmptyDirectory calls")
var hasRoot, hasEmpty bool
for _, p := range paths {
if p == "/" {
hasRoot = true
}
if p == "/empty" {
hasEmpty = true
}
}
test.Assert(t, hasRoot && hasEmpty, "expected paths \"/\" and \"/empty\", got %v", paths)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
repo, root := BuildTreeMap(TestTree{"empty": TestTree{}})
modrepo := data.TestWritableTreeMap{TestTreeMap: repo}
rw := NewTreeRewriter(RewriteOpts{KeepEmptyDirectory: tc.keepEmpty})
newRoot, err := rw.RewriteTree(ctx, modrepo, modrepo, "/", root)
test.OK(t, err)
tc.assert(t, newRoot)
})
}
}
func TestRewriterFailOnUnknownFields(t *testing.T) { func TestRewriterFailOnUnknownFields(t *testing.T) {
tm := data.TestWritableTreeMap{TestTreeMap: data.TestTreeMap{}} tm := data.TestWritableTreeMap{TestTreeMap: data.TestTreeMap{}}
node := []byte(`{"nodes":[{"name":"subfile","type":"file","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","uid":0,"gid":0,"content":null,"unknown_field":42}]}`) node := []byte(`{"nodes":[{"name":"subfile","type":"file","mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","uid":0,"gid":0,"content":null,"unknown_field":42}]}`)