diff --git a/changelog/unreleased/issue-21869 b/changelog/unreleased/issue-21869 new file mode 100644 index 000000000..774c5e4d1 --- /dev/null +++ b/changelog/unreleased/issue-21869 @@ -0,0 +1,11 @@ +Bugfix: Restore old behavior of `snapshots --latest ` without `--group-by` + +Restic 0.19.0 changed the behavior of `snapshots --latest ` to no longer +group snapshots by default. + +`snapshots --latest ` 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 diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go index 96a89b035..120fd2349 100644 --- a/cmd/restic/cmd_backup.go +++ b/cmd/restic/cmd_backup.go @@ -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 diff --git a/cmd/restic/cmd_snapshots.go b/cmd/restic/cmd_snapshots.go index cefe25cd6..4e56d4ea7 100644 --- a/cmd/restic/cmd_snapshots.go +++ b/cmd/restic/cmd_snapshots.go @@ -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. diff --git a/cmd/restic/cmd_snapshots_integration_test.go b/cmd/restic/cmd_snapshots_integration_test.go index 2401544d7..761d50e96 100644 --- a/cmd/restic/cmd_snapshots_integration_test.go +++ b/cmd/restic/cmd_snapshots_integration_test.go @@ -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") +}