Merge pull request #21875 from MichaelEischer/fix-snapshots-latest-group-by

Fix default grouping of `snapshots --latest <n>`
This commit is contained in:
Michael Eischer
2026-06-21 15:46:49 +02:00
committed by GitHub
4 changed files with 119 additions and 29 deletions
+11
View File
@@ -0,0 +1,11 @@
Bugfix: Restore old behavior of `snapshots --latest <n>` without `--group-by`
Restic 0.19.0 changed the behavior of `snapshots --latest <n>` to no longer
group snapshots by default.
`snapshots --latest <n>` has been reverted to the old behavior if `--group-by`
is not specified. When specifying `--group-by`, the output is still grouped as
requested like in restic 0.19.0.
https://github.com/restic/restic/issues/21869
https://github.com/restic/restic/pull/21875
+20 -16
View File
@@ -53,22 +53,7 @@ Exit status is 11 if the repository is already locked.
Exit status is 12 if the password is incorrect.
`,
PreRunE: func(_ *cobra.Command, _ []string) error {
if envVal := os.Getenv("RESTIC_READ_CONCURRENCY"); envVal != "" && !opts.readConcurrencyFlag.Changed {
n, err := strconv.ParseUint(envVal, 10, 32)
if err != nil {
return errors.Fatalf("invalid value for RESTIC_READ_CONCURRENCY %q: %v", envVal, err)
}
opts.ReadConcurrency = uint(n)
}
if opts.Host == "" {
hostname, err := os.Hostname()
if err != nil {
debug.Log("os.Hostname() returned err: %v", err)
return nil
}
opts.Host = hostname
}
return nil
return opts.Finalize()
},
GroupID: cmdGroupDefault,
DisableAutoGenTag: true,
@@ -163,6 +148,25 @@ func (opts *BackupOptions) AddFlags(f *pflag.FlagSet) {
}
}
func (opts *BackupOptions) Finalize() error {
if envVal := os.Getenv("RESTIC_READ_CONCURRENCY"); envVal != "" && !opts.readConcurrencyFlag.Changed {
n, err := strconv.ParseUint(envVal, 10, 32)
if err != nil {
return errors.Fatalf("invalid value for RESTIC_READ_CONCURRENCY %q: %v", envVal, err)
}
opts.ReadConcurrency = uint(n)
}
if opts.Host == "" {
hostname, err := os.Hostname()
if err != nil {
debug.Log("os.Hostname() returned err: %v", err)
return nil
}
opts.Host = hostname
}
return nil
}
var backupFSTestHook func(fs fs.FS) fs.FS
// ErrInvalidSourceData is used to report an incomplete backup
+55 -8
View File
@@ -39,6 +39,9 @@ Exit status is 12 if the password is incorrect.
`,
GroupID: cmdGroupDefault,
DisableAutoGenTag: true,
PreRunE: func(_ *cobra.Command, _ []string) error {
return opts.Finalize()
},
RunE: func(cmd *cobra.Command, args []string) error {
finalizeSnapshotFilter(&opts.SnapshotFilter)
return runSnapshots(cmd.Context(), opts, *globalOptions, args, globalOptions.Term)
@@ -53,7 +56,7 @@ Exit status is 12 if the password is incorrect.
type SnapshotOptions struct {
data.SnapshotFilter
Compact bool
Last bool // This option should be removed in favour of Latest.
last bool // Deprecated in favour of Latest.
Latest int
GroupBy data.SnapshotGroupByOptions
}
@@ -61,7 +64,7 @@ type SnapshotOptions struct {
func (opts *SnapshotOptions) AddFlags(f *pflag.FlagSet) {
initMultiSnapshotFilter(f, &opts.SnapshotFilter, true)
f.BoolVarP(&opts.Compact, "compact", "c", false, "use compact output format")
f.BoolVar(&opts.Last, "last", false, "only show the last snapshot for each host and path")
f.BoolVar(&opts.last, "last", false, "only show the last snapshot for each host and path")
err := f.MarkDeprecated("last", "use --latest 1")
if err != nil {
// MarkDeprecated only returns an error when the flag is not found
@@ -71,6 +74,13 @@ func (opts *SnapshotOptions) AddFlags(f *pflag.FlagSet) {
f.VarP(&opts.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma")
}
func (opts *SnapshotOptions) Finalize() error {
if opts.last && opts.Latest == 0 {
opts.Latest = 1
}
return nil
}
func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Options, args []string, term ui.Terminal) error {
printer := progress.NewTerminalPrinter(gopts.JSON, gopts.Verbosity, term)
ctx, repo, unlock, err := openWithReadLock(ctx, gopts, gopts.NoLock, printer)
@@ -96,12 +106,12 @@ func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Option
return ctx.Err()
}
if opts.Last {
// This branch should be removed in the same time
// that --last.
list = filterLatestSnapshotsInGroup(list, 1)
} else if opts.Latest > 0 {
list = filterLatestSnapshotsInGroup(list, opts.Latest)
if opts.Latest > 0 {
if grouped {
list = filterLatestSnapshotsInGroup(list, opts.Latest)
} else {
list = filterLatestSnapshots(list, opts.Latest)
}
}
sort.Sort(sort.Reverse(list))
snapshotGroups[k] = list
@@ -135,6 +145,43 @@ func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Option
return nil
}
// filterLastSnapshotsKey is used by FilterLastSnapshots.
type filterLastSnapshotsKey struct {
Hostname string
JoinedPaths string
}
// newFilterLastSnapshotsKey initializes a filterLastSnapshotsKey from a Snapshot
func newFilterLastSnapshotsKey(sn *data.Snapshot) filterLastSnapshotsKey {
// Shallow slice copy
var paths = make([]string, len(sn.Paths))
copy(paths, sn.Paths)
sort.Strings(paths)
return filterLastSnapshotsKey{sn.Hostname, strings.Join(paths, "|")}
}
// filterLatestSnapshots filters a list of snapshots to only return
// the limit last entries for each hostname and path. If the snapshot
// contains multiple paths, they will be joined and treated as one
// item.
func filterLatestSnapshots(list data.Snapshots, limit int) data.Snapshots {
// Sort the snapshots so that the newer ones are listed first
sort.SliceStable(list, func(i, j int) bool {
return list[i].Time.After(list[j].Time)
})
var results data.Snapshots
seen := make(map[filterLastSnapshotsKey]int)
for _, sn := range list {
key := newFilterLastSnapshotsKey(sn)
if seen[key] < limit {
seen[key]++
results = append(results, sn)
}
}
return results
}
// filterLatestSnapshotsInGroup filters a list of snapshots to only return
// the `limit` last entries. It is assumed that the snapshot list only contains
// one group of snapshots.
+33 -5
View File
@@ -35,10 +35,7 @@ func testRunSnapshots(t testing.TB, gopts global.Options) (newest *Snapshot, sna
return
}
func TestSnapshotsGroupByAndLatest(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
func snapshotsGroupTestData(t *testing.T, env *testEnvironment, keepPath bool) string {
testSetupBackupData(t, env)
// two backups on the same host but with different paths
opts := BackupOptions{Host: "testhost", TimeStamp: time.Now().Format(time.DateTime)}
@@ -46,9 +43,22 @@ func TestSnapshotsGroupByAndLatest(t *testing.T) {
// Use later timestamp for second backup
opts.TimeStamp = time.Now().Add(time.Second).Format(time.DateTime)
snapshotsIDs := loadSnapshotMap(t, env.gopts)
testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata/0"}, opts, env.gopts)
targets := []string{"testdata/0"}
if keepPath {
targets = []string{"testdata"}
}
testRunBackup(t, filepath.Dir(env.testdata), targets, opts, env.gopts)
_, secondSnapshotID := lastSnapshot(snapshotsIDs, loadSnapshotMap(t, env.gopts))
return secondSnapshotID
}
func TestSnapshotsGroupByAndLatest(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
secondSnapshotID := snapshotsGroupTestData(t, env, false)
buf, err := withCaptureStdout(t, env.gopts, func(ctx context.Context, gopts global.Options) error {
gopts.JSON = true
// only group by host but not path
@@ -65,3 +75,21 @@ func TestSnapshotsGroupByAndLatest(t *testing.T) {
rtest.Assert(t, len(snapshots[0].Snapshots) == 1, "expected only one latest snapshot, got %d", len(snapshots[0].Snapshots))
rtest.Equals(t, snapshots[0].Snapshots[0].ID.String(), secondSnapshotID, "unexpected snapshot ID")
}
func TestSnapshotsLatest(t *testing.T) {
env, cleanup := withTestEnvironment(t)
defer cleanup()
secondSnapshotID := snapshotsGroupTestData(t, env, true)
buf, err := withCaptureStdout(t, env.gopts, func(ctx context.Context, gopts global.Options) error {
gopts.JSON = true
opts := SnapshotOptions{Latest: 1}
return runSnapshots(ctx, opts, gopts, []string{}, gopts.Term)
})
rtest.OK(t, err)
snapshots := []Snapshot{}
rtest.OK(t, json.Unmarshal(buf.Bytes(), &snapshots))
rtest.Assert(t, len(snapshots) == 1, "expected only one snapshot, got %d", len(snapshots))
rtest.Equals(t, snapshots[0].ID.String(), secondSnapshotID, "unexpected snapshot ID")
}