This commit is contained in:
Sondre Batalden 2026-05-21 11:55:58 +08:00 committed by GitHub
commit 29fbbf0f14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 186 additions and 5 deletions

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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: