diff --git a/changelog/unreleased/issue-5723 b/changelog/unreleased/issue-5723 new file mode 100644 index 000000000..2fdee806e --- /dev/null +++ b/changelog/unreleased/issue-5723 @@ -0,0 +1,8 @@ +Enhancement: Allow rewriting snapshot paths with `rewrite --path-to` + +The `rewrite` command now supports `--path-from` and `--path-to` options +to replace paths stored in a snapshot. When both options are used, only +the matching path is replaced. When only `--path-to` is specified, all +paths are replaced with the new path. + +https://github.com/restic/restic/issues/5723 diff --git a/cmd/restic/cmd_rewrite.go b/cmd/restic/cmd_rewrite.go index 976fe4f73..db21f7dbf 100644 --- a/cmd/restic/cmd_rewrite.go +++ b/cmd/restic/cmd_rewrite.go @@ -31,6 +31,10 @@ exclude or include filters to control which files are included in the new snapshots. Unless --new-host or --new-time is specified, metadata (time, host, tags) is preserved. +The --path-to option replaces the stored paths of the snapshot. When used +together with --path-from, only the matching path is replaced. When used +alone, all paths are replaced with the new path. + 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 these options nor a snapshot ID will cause the command to rewrite all snapshots. @@ -74,15 +78,19 @@ Exit status is 12 if the password is incorrect. type snapshotMetadata struct { Hostname string Time *time.Time + PathFrom string + PathTo string } type snapshotMetadataArgs struct { Hostname string Time string + PathFrom string + PathTo string } func (sma snapshotMetadataArgs) empty() bool { - return sma.Hostname == "" && sma.Time == "" + return sma.Hostname == "" && sma.Time == "" && sma.PathTo == "" } func (sma snapshotMetadataArgs) convert() (*snapshotMetadata, error) { @@ -98,7 +106,7 @@ func (sma snapshotMetadataArgs) convert() (*snapshotMetadata, error) { } timeStamp = &t } - return &snapshotMetadata{Hostname: sma.Hostname, Time: timeStamp}, nil + return &snapshotMetadata{Hostname: sma.Hostname, Time: timeStamp, PathFrom: sma.PathFrom, PathTo: sma.PathTo}, nil } // RewriteOptions collects all options for the rewrite command. @@ -118,6 +126,8 @@ func (opts *RewriteOptions) AddFlags(f *pflag.FlagSet) { f.BoolVarP(&opts.DryRun, "dry-run", "n", false, "do not do anything, just print what would be done") f.StringVar(&opts.Metadata.Hostname, "new-host", "", "replace hostname") f.StringVar(&opts.Metadata.Time, "new-time", "", "replace time of the backup") + f.StringVar(&opts.Metadata.PathFrom, "path-from", "", "path to replace in the snapshot (used with --path-to, default: all paths)") + f.StringVar(&opts.Metadata.PathTo, "path-to", "", "new path to set in the snapshot") f.BoolVarP(&opts.SnapshotSummary, "snapshot-summary", "s", false, "create snapshot summary record if it does not exist") initMultiSnapshotFilter(f, &opts.SnapshotFilter, true) @@ -248,6 +258,14 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *d printer.P("would set hostname to %s", newMetadata.Hostname) } + if newMetadata != nil && newMetadata.PathTo != "" { + if newMetadata.PathFrom != "" { + printer.P("would replace path %q with %q", newMetadata.PathFrom, newMetadata.PathTo) + } else { + printer.P("would set all paths to %q", newMetadata.PathTo) + } + } + return true, nil } @@ -272,6 +290,27 @@ func filterAndReplaceSnapshot(ctx context.Context, repo restic.Repository, sn *d sn.Hostname = newMetadata.Hostname } + if newMetadata != nil && newMetadata.PathTo != "" { + if newMetadata.PathFrom != "" { + replaced := false + for i, p := range sn.Paths { + if p == newMetadata.PathFrom { + sn.Paths[i] = newMetadata.PathTo + replaced = true + break + } + } + if replaced { + printer.P("replacing path %q with %q", newMetadata.PathFrom, newMetadata.PathTo) + } else { + printer.P("path %q not found in snapshot, skipping path replacement", newMetadata.PathFrom) + } + } else { + printer.P("setting all paths to %q", newMetadata.PathTo) + sn.Paths = []string{newMetadata.PathTo} + } + } + // Save the new snapshot. id, err := data.SaveSnapshot(ctx, repo, sn) if err != nil { @@ -292,7 +331,9 @@ 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 { hasExcludes := !opts.ExcludePatternOptions.Empty() hasIncludes := !opts.IncludePatternOptions.Empty() - if !opts.SnapshotSummary && !hasExcludes && !hasIncludes && opts.Metadata.empty() { + if opts.Metadata.PathFrom != "" && opts.Metadata.PathTo == "" { + return errors.Fatal("--path-from requires --path-to") + } else 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") diff --git a/cmd/restic/cmd_rewrite_integration_test.go b/cmd/restic/cmd_rewrite_integration_test.go index acef0dd4d..da4dc817d 100644 --- a/cmd/restic/cmd_rewrite_integration_test.go +++ b/cmd/restic/cmd_rewrite_integration_test.go @@ -330,6 +330,117 @@ func TestRewriteIncludeEmptyDirectory(t *testing.T) { testLsOutputContainsCount(t, env.gopts, LsOptions{}, []string{"latest"}, "empty-directory", 1) } +func TestRewritePathTo(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + createBasicRewriteRepo(t, env) + + // --path-to without --path-from replaces all paths + err := testRunRewriteWithOpts(t, + RewriteOptions{ + Forget: true, + Metadata: snapshotMetadataArgs{PathTo: "/new/path"}, + }, + env.gopts, + []string{"latest"}) + rtest.OK(t, err) + + newSnapshots := testListSnapshots(t, env.gopts, 1) + sn := getSnapshot(t, newSnapshots[0], env) + rtest.Assert(t, len(sn.Paths) == 1, "expected one path, got %v", sn.Paths) + rtest.Equals(t, "/new/path", sn.Paths[0]) +} + +func TestRewritePathFromTo(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + createBasicRewriteRepo(t, env) + + // get the original path from the snapshot + snapshots := testListSnapshots(t, env.gopts, 1) + origSn := getSnapshot(t, snapshots[0], env) + origPath := origSn.Paths[0] + + // --path-from with --path-to replaces only the matching path + err := testRunRewriteWithOpts(t, + RewriteOptions{ + Forget: true, + Metadata: snapshotMetadataArgs{PathFrom: origPath, PathTo: "/replaced/path"}, + }, + env.gopts, + []string{"latest"}) + rtest.OK(t, err) + + newSnapshots := testListSnapshots(t, env.gopts, 1) + sn := getSnapshot(t, newSnapshots[0], env) + rtest.Assert(t, len(sn.Paths) == 1, "expected one path, got %v", sn.Paths) + rtest.Equals(t, "/replaced/path", sn.Paths[0]) +} + +func TestRewritePathFromNoMatch(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + createBasicRewriteRepo(t, env) + + // --path-from that doesn't match any path still creates a new snapshot + // (metadata was requested to change) but paths stay the same + snapshots := testListSnapshots(t, env.gopts, 1) + origSn := getSnapshot(t, snapshots[0], env) + + err := testRunRewriteWithOpts(t, + RewriteOptions{ + Forget: true, + Metadata: snapshotMetadataArgs{PathFrom: "/nonexistent/path", PathTo: "/new/path"}, + }, + env.gopts, + []string{"latest"}) + rtest.OK(t, err) + + newSnapshots := testListSnapshots(t, env.gopts, 1) + newSn := getSnapshot(t, newSnapshots[0], env) + rtest.Equals(t, origSn.Paths, newSn.Paths) +} + +func TestRewritePathFromWithoutPathTo(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + testRunInit(t, env.gopts) + + // --path-from without --path-to should fail + err := withTermStatus(t, env.gopts, func(ctx context.Context, gopts global.Options) error { + return runRewrite(ctx, + RewriteOptions{ + Metadata: snapshotMetadataArgs{PathFrom: "/some/path"}, + }, + gopts, []string{"latest"}, env.gopts.Term) + }) + rtest.Assert(t, err != nil && strings.Contains(err.Error(), "--path-from requires --path-to"), + `expected error containing "--path-from requires --path-to", got: %v`, err) +} + +func TestRewritePathToWithExclude(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + createBasicRewriteRepo(t, env) + + err := testRunRewriteWithOpts(t, + RewriteOptions{ + Forget: true, + Metadata: snapshotMetadataArgs{ + PathTo: "/rewritten/path", + }, + ExcludePatternOptions: filter.ExcludePatternOptions{Excludes: []string{"*.txt"}}, + }, + env.gopts, + []string{"latest"}) + rtest.OK(t, err) + + newSnapshots := testListSnapshots(t, env.gopts, 1) + sn := getSnapshot(t, newSnapshots[0], env) + rtest.Equals(t, "/rewritten/path", sn.Paths[0]) + testLsOutputContainsCount(t, env.gopts, LsOptions{}, []string{"latest"}, ".txt", 0) +} + // TestRewriteIncludeNothing makes sure when nothing is included, the original snapshot stays untouched func TestRewriteIncludeNothing(t *testing.T) { env, cleanup := withTestEnvironment(t) diff --git a/doc/045_working_with_repos.rst b/doc/045_working_with_repos.rst index 9aff3ab0f..2f648d35b 100644 --- a/doc/045_working_with_repos.rst +++ b/doc/045_working_with_repos.rst @@ -387,8 +387,17 @@ Modifying metadata of snapshots =============================== Sometimes it may be desirable to change the metadata of an existing snapshot. -Currently, rewriting the hostname and the time of the backup is supported. -This is possible using the ``rewrite`` command with the option ``--new-host`` followed by the desired new hostname or the option ``--new-time`` followed by the desired new timestamp. +Currently, rewriting the hostname, the time of the backup, and the stored +paths is supported. + +This is possible using the ``rewrite`` command with the option ``--new-host`` +followed by the desired new hostname, the option ``--new-time`` followed by +the desired new timestamp, or the option ``--path-to`` to replace the stored +paths. + +When ``--path-to`` is used alone, all paths in the snapshot are replaced with +the new path. When combined with ``--path-from``, only the matching path is +replaced, which is useful for snapshots that contain multiple paths. .. code-block:: console @@ -404,6 +413,18 @@ This is possible using the ``rewrite`` command with the option ``--new-host`` fo modified 1 snapshots +To replace the stored path of a snapshot: + +.. code-block:: console + + $ restic rewrite --path-to /new/path --forget latest + +To replace only a specific path in a multi-path snapshot: + +.. code-block:: console + + $ restic rewrite --path-from /old/path --path-to /new/path --forget latest + .. _checking-integrity: