This commit is contained in:
flinteger-code 2026-05-22 23:20:34 +00:00 committed by GitHub
commit daf6bdb798
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 236 additions and 13 deletions

View file

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

View file

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

View file

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