Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # changed your mind? pull it back
sp pin <id> # keep one: exempt it from the reaper
sp promote <id> # the good ones: graduate a scratch into your repo
```

Expand Down Expand Up @@ -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 <id>` / `sp unpin <id>` — 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 <id> [dest]` — graduate a scratch into your repo

Sometimes a throwaway turns out to matter. `sp promote` is the escape hatch:
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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,
Expand Down
87 changes: 87 additions & 0 deletions internal/cli/pin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"

"github.com/rwrife/scratchpatch/internal/store"
)

// newPinCommand registers `sp pin <id>`: 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 <id>",
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 <id>`: 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 <id>",
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
}
51 changes: 51 additions & 0 deletions internal/cli/pin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 4 additions & 3 deletions internal/cli/reap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func NewRootCommand() *cobra.Command {
newOpenCommand(),
newRmCommand(),
newResurrectCommand(),
newPinCommand(),
newUnpinCommand(),
newPromoteCommand(),
newReapCommand(),
newDoctorCommand(),
Expand Down
7 changes: 7 additions & 0 deletions internal/index/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/render/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}

Expand Down
110 changes: 110 additions & 0 deletions internal/render/pin_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading