From 9206625729c7b0efba8ec706046e08ba45d4d965 Mon Sep 17 00:00:00 2001 From: Sondre Batalden Date: Wed, 1 Apr 2026 09:18:20 +0200 Subject: [PATCH 1/2] Enhancement: add support for path rewriting in snapshots with `--path-from` and `--path-to` --- changelog/unreleased/issue-5723 | 8 ++ cmd/restic/cmd_rewrite.go | 47 ++++++++- cmd/restic/cmd_rewrite_integration_test.go | 111 +++++++++++++++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 changelog/unreleased/issue-5723 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 f5826c79f..505bcefef 100644 --- a/cmd/restic/cmd_rewrite.go +++ b/cmd/restic/cmd_rewrite.go @@ -33,6 +33,10 @@ 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 --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. @@ -76,15 +80,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) { @@ -100,7 +108,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. @@ -120,6 +128,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) @@ -250,6 +260,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 } @@ -274,6 +292,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 { @@ -294,7 +333,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) From a01938bdf30754c76e4b57d307c6b8d5bc3433a3 Mon Sep 17 00:00:00 2001 From: Sondre Batalden Date: Wed, 1 Apr 2026 09:22:53 +0200 Subject: [PATCH 2/2] update docs for rewriting paths --- doc/045_working_with_repos.rst | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/doc/045_working_with_repos.rst b/doc/045_working_with_repos.rst index f03674383..1160e2e88 100644 --- a/doc/045_working_with_repos.rst +++ b/doc/045_working_with_repos.rst @@ -378,8 +378,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 @@ -395,6 +404,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: