mirror of
https://github.com/restic/restic.git
synced 2026-02-17 06:23:56 +00:00
Merge pull request #5191 from wplapper/cmd_rewrite_include
This commit is contained in:
12
changelog/unreleased/issue-4278
Normal file
12
changelog/unreleased/issue-4278
Normal 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
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}]}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user