From 34a5495ce8ac5888c294e63e4a87495a25027044 Mon Sep 17 00:00:00 2001 From: flinteger-code Date: Mon, 19 Jan 2026 09:15:17 -0800 Subject: [PATCH] Enhancement: snapshots: Add --sort-by-time option to sort snapshots by time --- changelog/unreleased/pull-5677 | 5 + cmd/restic/cmd_snapshots.go | 66 ++++++++++-- cmd/restic/cmd_snapshots_test.go | 178 ++++++++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 changelog/unreleased/pull-5677 diff --git a/changelog/unreleased/pull-5677 b/changelog/unreleased/pull-5677 new file mode 100644 index 000000000..0df169995 --- /dev/null +++ b/changelog/unreleased/pull-5677 @@ -0,0 +1,5 @@ +Enhancement: snapshots: Add --sort-by-time option to sort snapshots by time. + +Restic now supports sorting snapshots by timestamp with the new `--sort-by-time` option. When used with the `snapshots` command, it lets you list snapshots in ascending (asc) or descending (desc) time order, with ascending order as the default. + +https://github.com/restic/restic/pull/5677 diff --git a/cmd/restic/cmd_snapshots.go b/cmd/restic/cmd_snapshots.go index 5f938fbe8..42f629290 100644 --- a/cmd/restic/cmd_snapshots.go +++ b/cmd/restic/cmd_snapshots.go @@ -51,10 +51,11 @@ Exit status is 12 if the password is incorrect. // SnapshotOptions bundles all options for the snapshots command. type SnapshotOptions struct { data.SnapshotFilter - Compact bool - Last bool // This option should be removed in favour of Latest. - Latest int - GroupBy data.SnapshotGroupByOptions + Compact bool + Last bool // This option should be removed in favour of Latest. + Latest int + GroupBy data.SnapshotGroupByOptions + SortByTime string } func (opts *SnapshotOptions) AddFlags(f *pflag.FlagSet) { @@ -68,6 +69,7 @@ func (opts *SnapshotOptions) AddFlags(f *pflag.FlagSet) { } f.IntVar(&opts.Latest, "latest", 0, "only show the last `n` snapshots for each host and path") f.VarP(&opts.GroupBy, "group-by", "g", "`group` snapshots by host, paths and/or tags, separated by comma") + f.StringVar(&opts.SortByTime, "sort-by-time", "asc", "sort snapshots by time: asc (ascending, oldest first) or desc (descending, newest first) (default: asc)") } func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Options, args []string, term ui.Terminal) error { @@ -78,6 +80,17 @@ func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Option } defer unlock() + // Set default sort-by-time if not specified + if opts.SortByTime == "" { + opts.SortByTime = "asc" + } + + // Validate sort-by-time option + sortByTime := strings.ToLower(opts.SortByTime) + if sortByTime != "asc" && sortByTime != "desc" { + return fmt.Errorf("invalid --sort-by-time value: %q (must be 'asc' or 'desc')", opts.SortByTime) + } + var snapshots data.Snapshots for sn := range FindFilteredSnapshots(ctx, repo, repo, &opts.SnapshotFilter, args, printer) { snapshots = append(snapshots, sn) @@ -107,7 +120,7 @@ func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Option } if gopts.JSON { - err := printSnapshotGroupJSON(gopts.Term.OutputWriter(), snapshotGroups, grouped) + err := printSnapshotGroupJSON(gopts.Term.OutputWriter(), snapshotGroups, grouped, sortByTime) if err != nil { printer.E("error printing snapshots: %v", err) } @@ -125,7 +138,7 @@ func runSnapshots(ctx context.Context, opts SnapshotOptions, gopts global.Option return err } } - err := PrintSnapshots(gopts.Term.OutputWriter(), list, nil, opts.Compact) + err := printSnapshotsWithSort(gopts.Term.OutputWriter(), list, nil, opts.Compact, sortByTime) if err != nil { return err } @@ -148,6 +161,11 @@ func filterLatestSnapshotsInGroup(list data.Snapshots, limit int) data.Snapshots // PrintSnapshots prints a text table of the snapshots in list to stdout. func PrintSnapshots(stdout io.Writer, list data.Snapshots, reasons []data.KeepReason, compact bool) error { + return printSnapshotsWithSort(stdout, list, reasons, compact, "asc") +} + +// printSnapshotsWithSort prints a text table of the snapshots in list to stdout with custom sorting. +func printSnapshotsWithSort(stdout io.Writer, list data.Snapshots, reasons []data.KeepReason, compact bool, sortByTime string) error { // keep the reasons a snasphot is being kept in a map, so that it doesn't // get lost when the list of snapshots is sorted keepReasons := make(map[restic.ID]data.KeepReason, len(reasons)) @@ -163,10 +181,17 @@ func PrintSnapshots(stdout io.Writer, list data.Snapshots, reasons []data.KeepRe hasSize = hasSize || (sn.Summary != nil) } - // always sort the snapshots so that the newer ones are listed last - sort.SliceStable(list, func(i, j int) bool { - return list[i].Time.Before(list[j].Time) - }) + // Sort the snapshots based on sortByTime option + if sortByTime == "desc" { + sort.SliceStable(list, func(i, j int) bool { + return list[i].Time.After(list[j].Time) + }) + } else { + // default: asc - oldest first, newest last + sort.SliceStable(list, func(i, j int) bool { + return list[i].Time.Before(list[j].Time) + }) + } // Determine the max widths for host and tag. maxHost, maxTag := 10, 6 @@ -320,7 +345,7 @@ type SnapshotGroup struct { } // printSnapshotGroupJSON writes the JSON representation of list to stdout. -func printSnapshotGroupJSON(stdout io.Writer, snGroups map[string]data.Snapshots, grouped bool) error { +func printSnapshotGroupJSON(stdout io.Writer, snGroups map[string]data.Snapshots, grouped bool, sortByTime string) error { if grouped { snapshotGroups := []SnapshotGroup{} @@ -343,6 +368,8 @@ func printSnapshotGroupJSON(stdout io.Writer, snGroups map[string]data.Snapshots snapshots = append(snapshots, k) } + sortSnapshotsByTime(snapshots, sortByTime) + group := SnapshotGroup{ GroupKey: key, Snapshots: snapshots, @@ -367,5 +394,22 @@ func printSnapshotGroupJSON(stdout io.Writer, snGroups map[string]data.Snapshots } } + sortSnapshotsByTime(snapshots, sortByTime) + return json.NewEncoder(stdout).Encode(snapshots) } + +// sortSnapshotsByTime sorts the snapshots slice in place according to sortByTime. +// `sortByTime` can be "asc" or "desc". +func sortSnapshotsByTime(snapshots []Snapshot, sortByTime string) { + if sortByTime == "desc" { + sort.SliceStable(snapshots, func(i, j int) bool { + return snapshots[i].Time.After(snapshots[j].Time) + }) + } else { + // default: asc - oldest first, newest last + sort.SliceStable(snapshots, func(i, j int) bool { + return snapshots[i].Time.Before(snapshots[j].Time) + }) + } +} diff --git a/cmd/restic/cmd_snapshots_test.go b/cmd/restic/cmd_snapshots_test.go index 777c4272f..b3bc3eeac 100644 --- a/cmd/restic/cmd_snapshots_test.go +++ b/cmd/restic/cmd_snapshots_test.go @@ -1,19 +1,193 @@ package main import ( + "bytes" + "encoding/json" "strings" "testing" + "time" + "github.com/restic/restic/internal/data" + "github.com/restic/restic/internal/restic" rtest "github.com/restic/restic/internal/test" ) +// Note: All test functions starting with "TestSnapshots", to run all the tests in this file: +// go test -v -run TestSnapshots ./cmd/restic/... + // Regression test for #2979: no snapshots should print as [], not null. -func TestEmptySnapshotGroupJSON(t *testing.T) { +func TestSnapshotsEmptySnapshotGroupJSON(t *testing.T) { for _, grouped := range []bool{false, true} { var w strings.Builder - err := printSnapshotGroupJSON(&w, nil, grouped) + err := printSnapshotGroupJSON(&w, nil, grouped, "asc") rtest.OK(t, err) rtest.Equals(t, "[]", strings.TrimSpace(w.String())) } } + +// TestSnapshotsSortByTimeAsc verifies that snapshots are sorted in ascending order (oldest first). +func TestSnapshotsSortByTimeAsc(t *testing.T) { + // Create test snapshots with different times + now := time.Now() + snapshots := []Snapshot{ + { + Snapshot: &data.Snapshot{Time: now.Add(2 * time.Hour)}, + ID: &restic.ID{}, + ShortID: "snap3", + }, + { + Snapshot: &data.Snapshot{Time: now}, + ID: &restic.ID{}, + ShortID: "snap1", + }, + { + Snapshot: &data.Snapshot{Time: now.Add(1 * time.Hour)}, + ID: &restic.ID{}, + ShortID: "snap2", + }, + } + + // Sort in ascending order + sortSnapshotsByTime(snapshots, "asc") + + // Verify snapshots are sorted oldest first + rtest.Equals(t, "snap1", snapshots[0].ShortID) + rtest.Equals(t, "snap2", snapshots[1].ShortID) + rtest.Equals(t, "snap3", snapshots[2].ShortID) +} + +// TestSnapshotsSortByTimeDesc verifies that snapshots are sorted in descending order (newest first). +func TestSnapshotsSortByTimeDesc(t *testing.T) { + // Create test snapshots with different times + now := time.Now() + snapshots := []Snapshot{ + { + Snapshot: &data.Snapshot{Time: now}, + ID: &restic.ID{}, + ShortID: "snap1", + }, + { + Snapshot: &data.Snapshot{Time: now.Add(2 * time.Hour)}, + ID: &restic.ID{}, + ShortID: "snap3", + }, + { + Snapshot: &data.Snapshot{Time: now.Add(1 * time.Hour)}, + ID: &restic.ID{}, + ShortID: "snap2", + }, + } + + // Sort in descending order + sortSnapshotsByTime(snapshots, "desc") + + // Verify snapshots are sorted newest first + rtest.Equals(t, "snap3", snapshots[0].ShortID) + rtest.Equals(t, "snap2", snapshots[1].ShortID) + rtest.Equals(t, "snap1", snapshots[2].ShortID) +} + +// TestSnapshotsPrintSnapshotGroupJSONSortAsc verifies JSON output is sorted in ascending order. +func TestSnapshotsPrintSnapshotGroupJSONSortAsc(t *testing.T) { + now := time.Now() + snapshotGroups := map[string]data.Snapshots{ + "{}": { + &data.Snapshot{Time: now.Add(2 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now, Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now.Add(1 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + }, + } + + var buf bytes.Buffer + err := printSnapshotGroupJSON(&buf, snapshotGroups, false, "asc") + rtest.OK(t, err) + + var snapshots []Snapshot + rtest.OK(t, json.Unmarshal(buf.Bytes(), &snapshots)) + + // Verify snapshots are sorted oldest first + rtest.Assert(t, len(snapshots) == 3, "expected 3 snapshots, got %d", len(snapshots)) + rtest.Assert(t, snapshots[0].Time.Before(snapshots[1].Time), "first snapshot should be before second") + rtest.Assert(t, snapshots[1].Time.Before(snapshots[2].Time), "second snapshot should be before third") +} + +// TestSnapshotsPrintSnapshotGroupJSONSortDesc verifies JSON output is sorted in descending order. +func TestSnapshotsPrintSnapshotGroupJSONSortDesc(t *testing.T) { + now := time.Now() + snapshotGroups := map[string]data.Snapshots{ + "{}": { + &data.Snapshot{Time: now, Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now.Add(2 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now.Add(1 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + }, + } + + var buf bytes.Buffer + err := printSnapshotGroupJSON(&buf, snapshotGroups, false, "desc") + rtest.OK(t, err) + + var snapshots []Snapshot + rtest.OK(t, json.Unmarshal(buf.Bytes(), &snapshots)) + + // Verify snapshots are sorted newest first + rtest.Assert(t, len(snapshots) == 3, "expected 3 snapshots, got %d", len(snapshots)) + rtest.Assert(t, snapshots[0].Time.After(snapshots[1].Time), "first snapshot should be after second") + rtest.Assert(t, snapshots[1].Time.After(snapshots[2].Time), "second snapshot should be after third") +} + +// TestSnapshotsPrintSnapshotGroupJSONGroupedSortAsc verifies grouped JSON output is sorted in ascending order. +func TestSnapshotsPrintSnapshotGroupJSONGroupedSortAsc(t *testing.T) { + now := time.Now() + snapshotGroups := map[string]data.Snapshots{ + `{"hostname":"host1","tags":null,"paths":null}`: { + &data.Snapshot{Time: now.Add(2 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now, Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now.Add(1 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + }, + } + + var buf bytes.Buffer + err := printSnapshotGroupJSON(&buf, snapshotGroups, true, "asc") + rtest.OK(t, err) + + var snapshotGroupList []SnapshotGroup + rtest.OK(t, json.Unmarshal(buf.Bytes(), &snapshotGroupList)) + + // Verify we have one group with 3 snapshots + rtest.Assert(t, len(snapshotGroupList) == 1, "expected 1 group, got %d", len(snapshotGroupList)) + rtest.Assert(t, len(snapshotGroupList[0].Snapshots) == 3, "expected 3 snapshots, got %d", len(snapshotGroupList[0].Snapshots)) + + // Verify snapshots are sorted oldest first + snapshots := snapshotGroupList[0].Snapshots + rtest.Assert(t, snapshots[0].Time.Before(snapshots[1].Time), "first snapshot should be before second") + rtest.Assert(t, snapshots[1].Time.Before(snapshots[2].Time), "second snapshot should be before third") +} + +// TestSnapshotsPrintSnapshotGroupJSONGroupedSortDesc verifies grouped JSON output is sorted in descending order. +func TestSnapshotsPrintSnapshotGroupJSONGroupedSortDesc(t *testing.T) { + now := time.Now() + snapshotGroups := map[string]data.Snapshots{ + `{"hostname":"host1","tags":null,"paths":null}`: { + &data.Snapshot{Time: now, Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now.Add(2 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + &data.Snapshot{Time: now.Add(1 * time.Hour), Hostname: "host1", Paths: []string{"/data"}}, + }, + } + + var buf bytes.Buffer + err := printSnapshotGroupJSON(&buf, snapshotGroups, true, "desc") + rtest.OK(t, err) + + var snapshotGroupList []SnapshotGroup + rtest.OK(t, json.Unmarshal(buf.Bytes(), &snapshotGroupList)) + + // Verify we have one group with 3 snapshots + rtest.Assert(t, len(snapshotGroupList) == 1, "expected 1 group, got %d", len(snapshotGroupList)) + rtest.Assert(t, len(snapshotGroupList[0].Snapshots) == 3, "expected 3 snapshots, got %d", len(snapshotGroupList[0].Snapshots)) + + // Verify snapshots are sorted newest first + snapshots := snapshotGroupList[0].Snapshots + rtest.Assert(t, snapshots[0].Time.After(snapshots[1].Time), "first snapshot should be after second") + rtest.Assert(t, snapshots[1].Time.After(snapshots[2].Time), "second snapshot should be after third") +}