From b52f2aa9a40351a4cb4e40706eecf7e4e7b3e5ed Mon Sep 17 00:00:00 2001
From: Matthew Holt <mholt@users.noreply.github.com>
Date: Mon, 23 Apr 2018 14:34:37 -0600
Subject: [PATCH] forget: Add policy to keep snapshots before a date

---
 cmd/restic/cmd_forget.go                      | 37 ++++---
 internal/restic/snapshot_policy.go            | 30 +++---
 internal/restic/snapshot_policy_test.go       |  1 +
 .../restic/testdata/policy_keep_snapshots_21  | 97 +++++++++++++++++++
 4 files changed, 137 insertions(+), 28 deletions(-)
 create mode 100644 internal/restic/testdata/policy_keep_snapshots_21

diff --git a/cmd/restic/cmd_forget.go b/cmd/restic/cmd_forget.go
index 79cc9f449..989fe0975 100644
--- a/cmd/restic/cmd_forget.go
+++ b/cmd/restic/cmd_forget.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"sort"
 	"strings"
+	"time"
 
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/restic"
@@ -27,13 +28,14 @@ data after 'forget' was run successfully, see the 'prune' command. `,
 
 // ForgetOptions collects all options for the forget command.
 type ForgetOptions struct {
-	Last     int
-	Hourly   int
-	Daily    int
-	Weekly   int
-	Monthly  int
-	Yearly   int
-	KeepTags restic.TagLists
+	Last      int
+	Hourly    int
+	Daily     int
+	Weekly    int
+	Monthly   int
+	Yearly    int
+	NewerThan time.Duration
+	KeepTags  restic.TagLists
 
 	Host    string
 	Tags    restic.TagLists
@@ -58,6 +60,7 @@ func init() {
 	f.IntVarP(&forgetOptions.Weekly, "keep-weekly", "w", 0, "keep the last `n` weekly snapshots")
 	f.IntVarP(&forgetOptions.Monthly, "keep-monthly", "m", 0, "keep the last `n` monthly snapshots")
 	f.IntVarP(&forgetOptions.Yearly, "keep-yearly", "y", 0, "keep the last `n` yearly snapshots")
+	f.DurationVar(&forgetOptions.NewerThan, "keep-newer-than", 0, "keep snapshots that were created within this timeframe")
 
 	f.Var(&forgetOptions.KeepTags, "keep-tag", "keep snapshots with this `taglist` (can be specified multiple times)")
 	// Sadly the commonly used shortcut `H` is already used.
@@ -163,14 +166,20 @@ func runForget(opts ForgetOptions, gopts GlobalOptions, args []string) error {
 		}
 	}
 
+	var ageCutoff time.Time
+	if opts.NewerThan > 0 {
+		ageCutoff = time.Now().Add(-opts.NewerThan)
+	}
+
 	policy := restic.ExpirePolicy{
-		Last:    opts.Last,
-		Hourly:  opts.Hourly,
-		Daily:   opts.Daily,
-		Weekly:  opts.Weekly,
-		Monthly: opts.Monthly,
-		Yearly:  opts.Yearly,
-		Tags:    opts.KeepTags,
+		Last:      opts.Last,
+		Hourly:    opts.Hourly,
+		Daily:     opts.Daily,
+		Weekly:    opts.Weekly,
+		Monthly:   opts.Monthly,
+		Yearly:    opts.Yearly,
+		NewerThan: ageCutoff,
+		Tags:      opts.KeepTags,
 	}
 
 	if policy.Empty() && len(args) == 0 {
diff --git a/internal/restic/snapshot_policy.go b/internal/restic/snapshot_policy.go
index 8dd7e5ed6..88533a2cb 100644
--- a/internal/restic/snapshot_policy.go
+++ b/internal/restic/snapshot_policy.go
@@ -10,13 +10,14 @@ import (
 
 // ExpirePolicy configures which snapshots should be automatically removed.
 type ExpirePolicy struct {
-	Last    int       // keep the last n snapshots
-	Hourly  int       // keep the last n hourly snapshots
-	Daily   int       // keep the last n daily snapshots
-	Weekly  int       // keep the last n weekly snapshots
-	Monthly int       // keep the last n monthly snapshots
-	Yearly  int       // keep the last n yearly snapshots
-	Tags    []TagList // keep all snapshots that include at least one of the tag lists.
+	Last      int       // keep the last n snapshots
+	Hourly    int       // keep the last n hourly snapshots
+	Daily     int       // keep the last n daily snapshots
+	Weekly    int       // keep the last n weekly snapshots
+	Monthly   int       // keep the last n monthly snapshots
+	Yearly    int       // keep the last n yearly snapshots
+	NewerThan time.Time // keep snapshots newer than this time
+	Tags      []TagList // keep all snapshots that include at least one of the tag lists.
 }
 
 func (e ExpirePolicy) String() (s string) {
@@ -39,15 +40,11 @@ func (e ExpirePolicy) String() (s string) {
 	if e.Yearly > 0 {
 		keeps = append(keeps, fmt.Sprintf("%d yearly", e.Yearly))
 	}
-
-	s = "keep the last "
-	for _, k := range keeps {
-		s += k + ", "
+	if !e.NewerThan.IsZero() {
+		keeps = append(keeps, fmt.Sprintf("snapshots newer than %s", e.NewerThan))
 	}
-	s = strings.Trim(s, ", ")
-	s += " snapshots"
 
-	return s
+	return fmt.Sprintf("keep the last %s snapshots", strings.Join(keeps, ", "))
 }
 
 // Sum returns the maximum number of snapshots to be kept according to this
@@ -133,6 +130,11 @@ func ApplyPolicy(list Snapshots, p ExpirePolicy) (keep, remove Snapshots) {
 			}
 		}
 
+		// If a timestamp is specified, it's a hard cutoff for older snapshots.
+		if !p.NewerThan.IsZero() && cur.Time.After(p.NewerThan) {
+			keepSnap = true
+		}
+
 		// Now update the other buckets and see if they have some counts left.
 		for i, b := range buckets {
 			if b.Count > 0 {
diff --git a/internal/restic/snapshot_policy_test.go b/internal/restic/snapshot_policy_test.go
index 69111fab1..b725dcbad 100644
--- a/internal/restic/snapshot_policy_test.go
+++ b/internal/restic/snapshot_policy_test.go
@@ -171,6 +171,7 @@ var expireTests = []restic.ExpirePolicy{
 	{Tags: []restic.TagList{{"foo"}}},
 	{Tags: []restic.TagList{{"foo", "bar"}}},
 	{Tags: []restic.TagList{{"foo"}, {"bar"}}},
+	{NewerThan: parseTimeUTC("2016-01-01 01:00:00")},
 }
 
 func TestApplyPolicy(t *testing.T) {
diff --git a/internal/restic/testdata/policy_keep_snapshots_21 b/internal/restic/testdata/policy_keep_snapshots_21
new file mode 100644
index 000000000..11be139f5
--- /dev/null
+++ b/internal/restic/testdata/policy_keep_snapshots_21
@@ -0,0 +1,97 @@
+[
+  {
+    "time": "2016-01-18T12:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-12T21:08:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-12T21:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-09T21:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-08T20:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-07T10:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-06T08:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-05T09:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T16:23:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T12:30:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T12:28:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T12:24:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T12:23:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T11:23:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-04T10:23:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-03T07:02:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-01T07:08:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-01T01:03:03Z",
+    "tree": null,
+    "paths": null
+  },
+  {
+    "time": "2016-01-01T01:02:03Z",
+    "tree": null,
+    "paths": null
+  }
+]
\ No newline at end of file