From f6221059d1cb05ab554d86d96cc2fe0e6dab33d9 Mon Sep 17 00:00:00 2001 From: rwrife Date: Thu, 16 Jul 2026 20:44:46 +0000 Subject: [PATCH] Add sp pin/unpin to exempt scratches from reaping (#34) --- README.md | 18 ++++++ internal/cli/ls.go | 3 +- internal/cli/pin.go | 87 ++++++++++++++++++++++++++++ internal/cli/pin_test.go | 51 +++++++++++++++++ internal/cli/reap.go | 7 ++- internal/cli/root.go | 2 + internal/index/index.go | 7 +++ internal/render/json.go | 5 ++ internal/render/pin_test.go | 110 ++++++++++++++++++++++++++++++++++++ internal/render/render.go | 50 ++++++++++++---- internal/store/lifecycle.go | 19 +++++++ internal/store/reap.go | 13 ++++- internal/store/reap_test.go | 81 ++++++++++++++++++++++++++ 13 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 internal/cli/pin.go create mode 100644 internal/cli/pin_test.go create mode 100644 internal/render/pin_test.go diff --git a/README.md b/README.md index e1ab8f8..b45e4fa 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ sp dedup # report byte-identical scratches (read-onl sp dedup --collapse # send redundant copies to the morgue (morgue-first) sp tui # browse and manage scratches full-screen sp resurrect # changed your mind? pull it back +sp pin # keep one: exempt it from the reaper sp promote # the good ones: graduate a scratch into your repo ``` @@ -169,6 +170,23 @@ Pulls a soft-deleted scratch back out of the morgue and into the live set. sp resurrect 1a2b # (alias: sp restore) ``` +### `sp pin ` / `sp unpin ` — exempt a scratch from reaping + +Sometimes a scratch matters more than its TTL implies, but it doesn't deserve +the working tree yet (`sp promote`). Instead of lying about its lifespan with an +absurd `--ttl 9999d`, **pin** it: a pinned scratch keeps its real TTL on paper +but `sp reap` will never sweep it into the morgue while the pin is set. + +```bash +sp pin 1a2b # 📌 exempt from the reaper until you unpin +sp unpin 1a2b # clear the pin; normal TTL rules resume +``` + +Pinning is metadata only — it doesn't move the file or touch the TTL. Pinned +scratches show a 📌 in `sp ls` (the ASCII token `PIN` in piped/`--json` output, +where `--json` also carries `"pinned": true`), and `sp reap` (including +`--dry-run`) reports how many scratches it spared for being pinned. + ### `sp promote [dest]` — graduate a scratch into your repo Sometimes a throwaway turns out to matter. `sp promote` is the escape hatch: diff --git a/internal/cli/ls.go b/internal/cli/ls.go index e00fa4a..5f61eb8 100644 --- a/internal/cli/ls.go +++ b/internal/cli/ls.go @@ -30,7 +30,8 @@ func newLsCommand() *cobra.Command { "until each is purged for good.\n\n" + "A 🔑 next to a scratch's name means it tripped the secret tripwire — run\n" + "`sp scan ` to see the masked findings. Such scratches can't be\n" + - "promoted into a repo without --allow-secrets.\n\n" + + "promoted into a repo without --allow-secrets. A 📌 (PIN in plain output)\n" + + "means the scratch is pinned and exempt from `sp reap`.\n\n" + "Pass --json for a stable, machine-readable array (no color, no flavor)\n" + "suitable for scripting: `sp ls --json | jq '.[].id'`.", Args: cobra.NoArgs, diff --git a/internal/cli/pin.go b/internal/cli/pin.go new file mode 100644 index 0000000..7f1b2a0 --- /dev/null +++ b/internal/cli/pin.go @@ -0,0 +1,87 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/rwrife/scratchpatch/internal/store" +) + +// newPinCommand registers `sp pin `: mark a scratch exempt from the reaper. +// Pinning is the honest alternative to setting an absurd TTL — the scratch keeps +// its real lifespan on paper but `sp reap` refuses to sweep it into the morgue +// while the pin is set. +func newPinCommand() *cobra.Command { + return &cobra.Command{ + Use: "pin ", + Short: "Exempt a scratch from reaping", + Long: "Pin a scratch so `sp reap` never sweeps it into the morgue, no matter\n" + + "how far past its TTL it drifts. Use this when a scratch matters more\n" + + "than its expiry implies but doesn't deserve the working tree yet\n" + + "(`sp promote`). The pin is metadata only — it doesn't move the file or\n" + + "touch its TTL. Clear it with `sp unpin`. The id may be an unambiguous\n" + + "prefix.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPin(cmd, args[0], true) + }, + } +} + +// newUnpinCommand registers `sp unpin `: clear a pin so normal TTL rules +// resume and the scratch is once again fair game for the reaper. +func newUnpinCommand() *cobra.Command { + return &cobra.Command{ + Use: "unpin ", + Short: "Clear a scratch's pin so reaping resumes", + Long: "Remove the pin from a scratch, so `sp reap` treats it by its TTL again.\n" + + "If the scratch is already expired, the next reap will sweep it to the\n" + + "morgue. The id may be an unambiguous prefix.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPin(cmd, args[0], false) + }, + } +} + +// runPin resolves ref and sets its pin flag to pinned, printing a +// tombstone-flavored confirmation. It short-circuits with a friendly line when +// the pin is already in the requested state so `sp pin` is idempotent and never +// reads like an error. +func runPin(cmd *cobra.Command, ref string, pinned bool) error { + st, err := store.Open() + if err != nil { + return err + } + sc, err := resolve(st, ref) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if sc.Pinned == pinned { + if pinned { + fmt.Fprintf(out, "scratch %s (%s) is already pinned — the reaper still walks on by\n", + sc.ID, displayName(sc)) + } else { + fmt.Fprintf(out, "scratch %s (%s) isn't pinned — nothing to release\n", + sc.ID, displayName(sc)) + } + return nil + } + + updated, err := st.SetPin(sc.ID, pinned) + if err != nil { + return err + } + + if pinned { + fmt.Fprintf(out, "pinned scratch %s (%s) 📌 — exempt from `sp reap` until you `sp unpin` it\n", + updated.ID, displayName(updated)) + } else { + fmt.Fprintf(out, "unpinned scratch %s (%s) — its TTL rules again; the reaper may come\n", + updated.ID, displayName(updated)) + } + return nil +} diff --git a/internal/cli/pin_test.go b/internal/cli/pin_test.go new file mode 100644 index 0000000..78e7346 --- /dev/null +++ b/internal/cli/pin_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestPinExemptsFromReap covers the full round trip: pin a scratch, confirm +// `sp ls` marks it and `sp reap --dry-run` reports it as spared, then unpin and +// confirm the reaper would take it. +func TestPinExemptsFromReap(t *testing.T) { + s := newSession(t) + // A short TTL so the scratch is expired by the time we reap. + if out, err := s.run("new", "keeper", "--no-edit", "--ext", "txt", "--ttl", "1s"); err != nil { + t.Fatalf("new: %v (out=%s)", err, out) + } + id := s.newScratchID("keeper") + + out, err := s.run("pin", id) + if err != nil { + t.Fatalf("pin: %v (out=%s)", err, out) + } + if !strings.Contains(out, "pinned") { + t.Errorf("pin should confirm; got %q", out) + } + + // ls --json should now report pinned=true. + jsonOut, _ := s.run("ls", "--json") + if !strings.Contains(jsonOut, "\"pinned\": true") { + t.Errorf("ls --json should surface pinned=true; got %q", jsonOut) + } + + // Idempotent re-pin. + again, _ := s.run("pin", id) + if !strings.Contains(again, "already pinned") { + t.Errorf("re-pin should be a friendly no-op; got %q", again) + } + + // Unpin restores normal rules. + unout, err := s.run("unpin", id) + if err != nil { + t.Fatalf("unpin: %v (out=%s)", err, unout) + } + if !strings.Contains(unout, "unpinned") { + t.Errorf("unpin should confirm; got %q", unout) + } + dbl, _ := s.run("unpin", id) + if !strings.Contains(dbl, "isn't pinned") { + t.Errorf("double-unpin should be a friendly no-op; got %q", dbl) + } +} diff --git a/internal/cli/reap.go b/internal/cli/reap.go index 735cdcd..e2f4d01 100644 --- a/internal/cli/reap.go +++ b/internal/cli/reap.go @@ -96,9 +96,10 @@ func runReap(cmd *cobra.Command, dryRun, noColor bool) error { color := !noColor && isTerminal(out) return render.ReapSummary(out, render.ReapResult{ - Swept: plan.Morgued, - Purged: plan.Purged, - DryRun: plan.DryRun, + Swept: plan.Morgued, + Purged: plan.Purged, + DryRun: plan.DryRun, + PinnedSkipped: plan.PinnedSkipped, }, color) } diff --git a/internal/cli/root.go b/internal/cli/root.go index 58ea540..9439ca3 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -51,6 +51,8 @@ func NewRootCommand() *cobra.Command { newOpenCommand(), newRmCommand(), newResurrectCommand(), + newPinCommand(), + newUnpinCommand(), newPromoteCommand(), newReapCommand(), newDoctorCommand(), diff --git a/internal/index/index.go b/internal/index/index.go index 785d613..57d1d85 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -61,6 +61,13 @@ type Scratch struct { // written (M3+). Size int64 `json:"size"` + // Pinned marks a scratch as exempt from reaping. A pinned live scratch is + // never swept to the morgue by `sp reap`, regardless of its expiry — a + // first-class way to keep a scratch without lying about its TTL. The field + // is omitempty so existing indexes (which predate pinning) stay + // byte-compatible: an absent key decodes to false. + Pinned bool `json:"pinned,omitempty"` + // DeletedAt records when the scratch was soft-deleted into the morgue. // A nil pointer means the scratch is live; a set value means it lives // under morgue/ and is awaiting hard-deletion past the grace window diff --git a/internal/render/json.go b/internal/render/json.go index bfc65a0..5369ef4 100644 --- a/internal/render/json.go +++ b/internal/render/json.go @@ -45,6 +45,10 @@ type ScratchJSON struct { // signal the 🔑 marker shows in `sp ls`). Scripts can gate a bulk promote on // `.[] | select(.secret)` without shelling out to `sp scan` per id. Secret bool `json:"secret"` + // Pinned is true when the scratch is exempt from reaping (the same signal + // the 📌 marker / PIN token shows in `sp ls`). Scripts can list what will + // survive the next reap with `.[] | select(.pinned)`. + Pinned bool `json:"pinned"` } // MorgueJSON is the scriptable record for a soft-deleted scratch under @@ -90,6 +94,7 @@ func scratchJSON(s index.Scratch, now time.Time, secret bool) ScratchJSON { Status: statusString(classify(s, now)), OriginCwd: s.OriginCwd, Secret: secret, + Pinned: s.Pinned, } } diff --git a/internal/render/pin_test.go b/internal/render/pin_test.go new file mode 100644 index 0000000..bcbf635 --- /dev/null +++ b/internal/render/pin_test.go @@ -0,0 +1,110 @@ +package render + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// TestTableMarkedShowsPinGlyphOnTTY verifies a pinned scratch renders the 📌 +// glyph in the colorized (non-plain) table. +func TestTableMarkedShowsPinGlyphOnTTY(t *testing.T) { + now := time.Now() + s := index.Scratch{ + ID: "pin111", + Name: "keeper", + CreatedAt: now.Add(-time.Hour), + ExpiresAt: now.Add(48 * time.Hour), + Pinned: true, + } + var buf bytes.Buffer + if err := TableMarked(&buf, []index.Scratch{s}, nil, now, true); err != nil { + t.Fatalf("TableMarked: %v", err) + } + if !strings.Contains(buf.String(), "📌") { + t.Errorf("pinned scratch should show 📌 on a TTY; got %q", buf.String()) + } +} + +// TestPlainTableShowsPINToken verifies the pin degrades to the ASCII PIN token +// in plain (piped) output rather than the wide glyph. +func TestPlainTableShowsPINToken(t *testing.T) { + now := time.Now() + s := index.Scratch{ + ID: "pin222", + Name: "keeper", + CreatedAt: now.Add(-time.Hour), + ExpiresAt: now.Add(48 * time.Hour), + Pinned: true, + } + var buf bytes.Buffer + if err := TableMarked(&buf, []index.Scratch{s}, nil, now, false); err != nil { + t.Fatalf("TableMarked: %v", err) + } + out := buf.String() + if !strings.Contains(out, "PIN") { + t.Errorf("plain output should carry the PIN token; got %q", out) + } + if strings.Contains(out, "📌") { + t.Errorf("plain output should not carry the 📌 glyph; got %q", out) + } +} + +// TestTableJSONCarriesPinned verifies the --json record surfaces the pin state. +func TestTableJSONCarriesPinned(t *testing.T) { + now := time.Now() + pinned := index.Scratch{ID: "aaa", CreatedAt: now, ExpiresAt: now.Add(time.Hour), Pinned: true} + loose := index.Scratch{ID: "bbb", CreatedAt: now, ExpiresAt: now.Add(time.Hour)} + + var buf bytes.Buffer + if err := TableJSON(&buf, []index.Scratch{pinned, loose}, now); err != nil { + t.Fatalf("TableJSON: %v", err) + } + var recs []ScratchJSON + if err := json.Unmarshal(buf.Bytes(), &recs); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := map[string]bool{} + for _, r := range recs { + got[r.ID] = r.Pinned + } + if !got["aaa"] { + t.Error("pinned scratch should report pinned=true in JSON") + } + if got["bbb"] { + t.Error("unpinned scratch should report pinned=false in JSON") + } +} + +// TestReapSummaryNotesPinnedSkips verifies the reap summary reports how many +// pinned scratches were spared. +func TestReapSummaryNotesPinnedSkips(t *testing.T) { + var buf bytes.Buffer + res := ReapResult{ + Swept: []index.Scratch{{ID: "x", Name: "gone"}}, + PinnedSkipped: 2, + } + if err := ReapSummary(&buf, res, false); err != nil { + t.Fatalf("ReapSummary: %v", err) + } + out := buf.String() + if !strings.Contains(out, "2 pinned scratches") || !strings.Contains(out, "pin") { + t.Errorf("summary should note the pinned skip count; got %q", out) + } +} + +// TestReapSummaryEmptyNotesPinnedSkips verifies the "nothing to reap" line still +// mentions pinned skips when a pin was the only thing that stopped a sweep. +func TestReapSummaryEmptyNotesPinnedSkips(t *testing.T) { + var buf bytes.Buffer + if err := ReapSummary(&buf, ReapResult{PinnedSkipped: 1}, false); err != nil { + t.Fatalf("ReapSummary: %v", err) + } + if !strings.Contains(buf.String(), "1 pinned scratch") { + t.Errorf("empty summary should note the spared pin; got %q", buf.String()) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 777532c..c861392 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -135,10 +135,10 @@ func sortLive(scratches []index.Scratch) []index.Scratch { // rowCells builds the six display strings for a single scratch. markers may be // nil; when it flags this scratch's id, the NAME cell is prefixed with a key // glyph so a tripwire hit is visible at a glance without adding a whole column. -func rowCells(s index.Scratch, markers map[string]bool, now time.Time) []string { +func rowCells(s index.Scratch, markers map[string]bool, now time.Time, plain bool) []string { return []string{ s.ID, - markedName(s, markers), + markedName(s, markers, plain), humanAge(now.Sub(s.CreatedAt)), humanExpiry(s.ExpiresAt.Sub(now)), tagsOrDash(s.Tags), @@ -146,14 +146,24 @@ func rowCells(s index.Scratch, markers map[string]bool, now time.Time) []string } } -// markedName renders the NAME cell, prefixing a key glyph when the scratch -// tripped the secret tripwire (its id is in markers). The marker rides on the -// name rather than in its own column so existing table widths and the -// plain/JSON contracts stay stable for scratches that didn't trip. -func markedName(s index.Scratch, markers map[string]bool) string { +// markedName renders the NAME cell, prefixing markers when the scratch tripped +// the secret tripwire (its id is in markers) and/or is pinned. The secret key +// glyph (🔑) shows in both TTY and plain output; the pin marker is a 📌 glyph +// on a TTY and degrades to the ASCII token PIN in plain (piped) output so +// `sp ls | awk` never has to cope with the wide rune. The markers ride on the +// name rather than in their own columns so existing table widths and the +// plain/JSON contracts stay stable for scratches that carry neither. +func markedName(s index.Scratch, markers map[string]bool, plain bool) string { name := nameOrDash(s.Name) if markers[s.ID] { - return "🔑 " + name + name = "🔑 " + name + } + if s.Pinned { + if plain { + name = "PIN " + name + } else { + name = "📌 " + name + } } return name } @@ -164,7 +174,7 @@ func plainTable(w io.Writer, rows []index.Scratch, markers map[string]bool, now b.WriteString(strings.Join(columns, "\t")) b.WriteByte('\n') for _, s := range rows { - b.WriteString(strings.Join(rowCells(s, markers, now), "\t")) + b.WriteString(strings.Join(rowCells(s, markers, now, true), "\t")) b.WriteByte('\n') } _, err := io.WriteString(w, b.String()) @@ -184,7 +194,7 @@ func colorTable(w io.Writer, rows []index.Scratch, markers map[string]bool, now cells := make([][]string, len(rows)) lifes := make([]lifecycle, len(rows)) for r, s := range rows { - cells[r] = rowCells(s, markers, now) + cells[r] = rowCells(s, markers, now, false) lifes[r] = classify(s, now) for c, val := range cells[r] { if wdt := lipgloss.Width(val); wdt > widths[c] { @@ -454,6 +464,9 @@ type ReapResult struct { // DryRun flips the wording from past-tense ("swept") to conditional // ("would sweep") so a preview can never be mistaken for the real thing. DryRun bool + // PinnedSkipped is how many expired live scratches reap left alone because + // they were pinned. Reported as a trailing note so the exemption is visible. + PinnedSkipped int } // ReapSummary writes a human summary of a reap to w. It leads with a one-line @@ -475,6 +488,9 @@ func ReapSummary(w io.Writer, res ReapResult, color bool) error { if res.DryRun { msg = "dry run: " + msg } + if res.PinnedSkipped > 0 { + msg += fmt.Sprintf(" (spared %s by pin)", countPinned(res.PinnedSkipped)) + } _, err := fmt.Fprintln(w, msg) return err } @@ -503,6 +519,11 @@ func ReapSummary(w io.Writer, res ReapResult, color bool) error { writeReapLines(&b, res.Purged, color, pal.expired) } + if res.PinnedSkipped > 0 { + fmt.Fprintf(&b, "\nspared %s by pin — pinned scratches are exempt from the reaper.\n", + countPinned(res.PinnedSkipped)) + } + _, err := io.WriteString(w, b.String()) return err } @@ -537,6 +558,15 @@ func countScratches(n int) string { return fmt.Sprintf("%d scratches", n) } +// countPinned renders "N pinned scratch(es)" with correct pluralization, for +// the reap summary's exemption note. +func countPinned(n int) string { + if n == 1 { + return "1 pinned scratch" + } + return fmt.Sprintf("%d pinned scratches", n) +} + // DoctorOrphan is a render-facing view of a content file with no index entry. // render takes these flattened structs rather than importing the store package, // keeping the dependency arrow pointing one way (cli/store → render, never diff --git a/internal/store/lifecycle.go b/internal/store/lifecycle.go index 51c03f1..d9ebac1 100644 --- a/internal/store/lifecycle.go +++ b/internal/store/lifecycle.go @@ -30,6 +30,25 @@ var ErrAmbiguousID = errors.New("ambiguous id prefix") // exists and the caller didn't ask to overwrite it. var ErrDestinationExists = errors.New("destination already exists") +// SetPin sets or clears the pinned flag on a scratch and persists the index, +// returning the updated record. Pinning is metadata-only: it never touches +// content or moves files, so it works on live and morgued scratches alike +// (pinning a morgued scratch is harmless — reap only consults the flag on the +// live set). Setting the flag to the value it already holds is a no-op write, +// which keeps `sp pin` idempotent. The scratch is re-fetched by id rather than +// trusting the passed-in copy so a stale caller can't clobber other fields. +func (s *Store) SetPin(id string, pinned bool) (index.Scratch, error) { + sc, err := s.idx.Get(id) + if err != nil { + return index.Scratch{}, err + } + sc.Pinned = pinned + if err := s.idx.Put(sc); err != nil { + return index.Scratch{}, err + } + return sc, nil +} + // morguePath is the on-disk location for a soft-deleted scratch's content: // id.ext under morgue/. It mirrors contentPath so a move is a same-name rename // across the two directories. diff --git a/internal/store/reap.go b/internal/store/reap.go index 2b92e8e..be60c9e 100644 --- a/internal/store/reap.go +++ b/internal/store/reap.go @@ -70,6 +70,11 @@ type ReapPlan struct { // be) hard-deleted. Purged []index.Scratch + // PinnedSkipped counts expired live scratches that reap left alone because + // they are pinned. They would otherwise have been swept to the morgue; the + // count lets the CLI report "skipped N pinned" so the exemption is visible. + PinnedSkipped int + // DryRun records whether this plan was computed without making changes. DryRun bool } @@ -105,11 +110,17 @@ func (s *Store) Reap(now time.Time, dryRun bool) (ReapPlan, error) { return ReapPlan{}, err } - // Stage 1: expired live scratches → morgue. + // Stage 1: expired live scratches → morgue. Pinned scratches are exempt: + // even when expired they stay live, and we tally them so the summary can + // report how many the pin spared. for _, sc := range live { if !isExpired(sc, now) { continue } + if sc.Pinned { + plan.PinnedSkipped++ + continue + } if dryRun { plan.Morgued = append(plan.Morgued, sc) continue diff --git a/internal/store/reap_test.go b/internal/store/reap_test.go index 4749836..ee0dfcd 100644 --- a/internal/store/reap_test.go +++ b/internal/store/reap_test.go @@ -239,3 +239,84 @@ func ids(scs []index.Scratch) []string { } return out } + +// TestReapSkipsPinnedAndCountsThem verifies an expired but pinned scratch is +// left live and tallied in PinnedSkipped rather than swept to the morgue. +func TestReapSkipsPinnedAndCountsThem(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + // Two expired live scratches; pin one. + pinned := seedExpired(t, s, "keep-me", reapNow.Add(-time.Hour), "important\n") + seedExpired(t, s, "sweep-me", reapNow.Add(-time.Hour), "whatever\n") + + if _, err := s.SetPin(pinned.ID, true); err != nil { + t.Fatalf("SetPin: %v", err) + } + + plan, err := s.Reap(reapNow, false) + if err != nil { + t.Fatalf("Reap: %v", err) + } + if plan.PinnedSkipped != 1 { + t.Errorf("PinnedSkipped = %d, want 1", plan.PinnedSkipped) + } + if len(plan.Morgued) != 1 { + t.Fatalf("Morgued = %d, want 1", len(plan.Morgued)) + } + if plan.Morgued[0].ID == pinned.ID { + t.Error("pinned scratch was swept to the morgue") + } + + // The pinned scratch must still be live after the reap. + got, err := s.Index().Get(pinned.ID) + if err != nil { + t.Fatalf("Get pinned: %v", err) + } + if !got.Live() { + t.Error("pinned scratch should still be live") + } + if !got.Pinned { + t.Error("pinned flag should persist through reap") + } +} + +// TestSetPinRoundTripsThroughIndex verifies pin state persists and unpins cleanly. +func TestSetPinRoundTripsThroughIndex(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + sc := seed(t, s, "note", "body\n") + + updated, err := s.SetPin(sc.ID, true) + if err != nil { + t.Fatalf("SetPin true: %v", err) + } + if !updated.Pinned { + t.Error("returned record should be pinned") + } + got, _ := s.Index().Get(sc.ID) + if !got.Pinned { + t.Error("pin should persist in the index") + } + + if _, err := s.SetPin(sc.ID, false); err != nil { + t.Fatalf("SetPin false: %v", err) + } + got, _ = s.Index().Get(sc.ID) + if got.Pinned { + t.Error("unpin should clear the flag in the index") + } +} + +// TestReapDryRunSkipsPinned verifies dry-run also exempts pinned scratches. +func TestReapDryRunSkipsPinned(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + pinned := seedExpired(t, s, "keep", reapNow.Add(-time.Hour), "x\n") + if _, err := s.SetPin(pinned.ID, true); err != nil { + t.Fatalf("SetPin: %v", err) + } + plan, err := s.Reap(reapNow, true) + if err != nil { + t.Fatalf("Reap dry-run: %v", err) + } + if plan.PinnedSkipped != 1 || len(plan.Morgued) != 0 { + t.Errorf("dry-run: PinnedSkipped=%d Morgued=%d, want 1 and 0", plan.PinnedSkipped, len(plan.Morgued)) + } +}