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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ relocates the file and stamps a deletion time. Restore it any time with
`sp resurrect`.

```bash
sp rm 1a2b # → moved to the morgue; printed restore hint
sp rm 1a2b # → buried in the morgue (not gone, just resting); printed restore hint
```

### `sp resurrect <id>` — bring it back
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func runOpen(cmd *cobra.Command, ref string) error {
}
}

fmt.Fprintf(out, "opened scratch %s (%s)\n", sc.ID, displayName(sc))
fmt.Fprintf(out, "opened scratch %s (%s) — back on the slab\n", sc.ID, displayName(sc))
return nil
}

Expand Down Expand Up @@ -140,7 +140,7 @@ func runRm(cmd *cobra.Command, ref string) error {
return err
}
fmt.Fprintf(cmd.OutOrStdout(),
"moved scratch %s (%s) to the morgue — restore with `sp resurrect %s`\n",
"buried scratch %s (%s) in the morgue — not gone, just resting; restore with `sp resurrect %s`\n",
sc.ID, displayName(sc), sc.ID)
return nil
}
Expand Down Expand Up @@ -177,7 +177,7 @@ func runResurrect(cmd *cobra.Command, ref string) error {
return err
}
fmt.Fprintf(cmd.OutOrStdout(),
"resurrected scratch %s (%s) — it's live again\n", sc.ID, displayName(sc))
"resurrected scratch %s (%s) — it claws its way out of the morgue and is live again\n", sc.ID, displayName(sc))
return nil
}

Expand Down
33 changes: 32 additions & 1 deletion internal/cli/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,41 @@ func runNew(cmd *cobra.Command, name string, f newFlags) error {
}
}

fmt.Fprintf(out, "created scratch %s (%s)\n", sc.ID, sc.Name)
fmt.Fprintf(out, "created scratch %s (%s) — %s\n", sc.ID, sc.Name, lifespanNote(sc.ExpiresAt, time.Now()))
return nil
}

// lifespanNote renders a one-clause, tombstone-flavored reminder of when a
// freshly created scratch is due to be swept. It's deliberately terse so the
// confirmation line stays a single glanceable sentence, and it never fires for
// a scratch with no expiry (belt-and-suspenders — new scratches always get
// one). now is passed in so the wording is deterministic for tests.
func lifespanNote(expiresAt, now time.Time) string {
if expiresAt.IsZero() {
return "it'll live in the store until you reap it"
}
return "living on borrowed time, " + humanCountdown(expiresAt.Sub(now))
}

// humanCountdown phrases a time-until-expiry span for prose ("expires in ~7d"),
// or notes it's already due when the deadline has passed. It leans on the same
// compact day/hour/minute feel as the ls table without importing render.
func humanCountdown(d time.Duration) string {
if d <= 0 {
return "and already due for the reaper"
}
switch {
case d >= 24*time.Hour:
return fmt.Sprintf("expires in ~%dd", int(d/(24*time.Hour)))
case d >= time.Hour:
return fmt.Sprintf("expires in ~%dh", int(d/time.Hour))
case d >= time.Minute:
return fmt.Sprintf("expires in ~%dm", int(d/time.Minute))
default:
return "expires within the minute"
}
}

// openInEditor launches $EDITOR on path, wiring it to the current stdio so an
// interactive editor works. It returns a friendly error when $EDITOR is unset
// so the caller can fall back gracefully.
Expand Down
113 changes: 113 additions & 0 deletions internal/cli/personality_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cli

import (
"strings"
"testing"
"time"
)

// The M6 polish pass gives the direct command confirmation lines the same
// tombstone-flavored voice the render layer already had. These tests pin that
// flavor in place (so it can't silently regress to clinical wording) while also
// guarding the stable substrings other tests and scripts rely on.

func TestNewConfirmationHasFlavorAndLifespan(t *testing.T) {
s := newSession(t)
out, err := s.run("new", "note", "--no-edit", "--ext", "txt", "--ttl", "7d")
if err != nil {
t.Fatalf("new: %v (out=%s)", err, out)
}
// Stable anchor other tests depend on.
if !strings.Contains(out, "created scratch") {
t.Errorf("new should confirm creation with the stable anchor; got %q", out)
}
// Flavor: a freshly created scratch is reminded of its mortality.
if !strings.Contains(out, "borrowed time") {
t.Errorf("new should note the scratch's borrowed time; got %q", out)
}
// And the countdown should reflect a multi-day TTL. Measured from "now",
// a 7d TTL can floor to ~6d after the sub-second elapsed, so assert on the
// day-granularity shape rather than a brittle exact count.
if !strings.Contains(out, "expires in ~") || !strings.HasSuffix(strings.TrimSpace(out), "d") {
t.Errorf("new should surface a multi-day expiry countdown; got %q", out)
}
}

func TestOpenConfirmationHasFlavor(t *testing.T) {
s := newSession(t)
id := s.newScratchID("reopen-me")
// Use a no-op editor so open takes the success path and prints its
// confirmation line (with EDITOR unset it would fall back to printing the
// path instead).
t.Setenv("EDITOR", "true")
out, err := s.run("open", id)
if err != nil {
t.Fatalf("open: %v (out=%s)", err, out)
}
if !strings.Contains(out, "opened scratch") {
t.Errorf("open should confirm with the stable anchor; got %q", out)
}
if !strings.Contains(out, "slab") {
t.Errorf("open should carry the tombstone flavor; got %q", out)
}
}

func TestRmAndResurrectKeepAnchorsAndFlavor(t *testing.T) {
s := newSession(t)
id := s.newScratchID("cycle")

rmOut, err := s.run("rm", id)
if err != nil {
t.Fatalf("rm: %v (out=%s)", err, rmOut)
}
// Anchor: existing tests assert on "morgue".
if !strings.Contains(rmOut, "morgue") {
t.Errorf("rm should mention the morgue; got %q", rmOut)
}
// Flavor + the restore hint must survive.
if !strings.Contains(rmOut, "buried") || !strings.Contains(rmOut, "sp resurrect") {
t.Errorf("rm should be flavored and still hint at restore; got %q", rmOut)
}

resOut, err := s.run("resurrect", id)
if err != nil {
t.Fatalf("resurrect: %v (out=%s)", err, resOut)
}
// Anchor: existing tests assert on "live again".
if !strings.Contains(resOut, "live again") {
t.Errorf("resurrect should confirm the scratch is live again; got %q", resOut)
}
if !strings.Contains(resOut, "morgue") {
t.Errorf("resurrect should reference clawing out of the morgue; got %q", resOut)
}
}

func TestHumanCountdownWording(t *testing.T) {
cases := []struct {
name string
d time.Duration
want string
}{
{"days", 7 * 24 * time.Hour, "expires in ~7d"},
{"hours", 5 * time.Hour, "expires in ~5h"},
{"minutes", 12 * time.Minute, "expires in ~12m"},
{"sub-minute", 20 * time.Second, "expires within the minute"},
{"already-due", -time.Hour, "already due for the reaper"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := humanCountdown(tc.d); !strings.Contains(got, tc.want) {
t.Errorf("humanCountdown(%v) = %q, want it to contain %q", tc.d, got, tc.want)
}
})
}
}

func TestLifespanNoteFallsBackWithoutExpiry(t *testing.T) {
// Defensive path: a zero expiry (shouldn't happen for real scratches)
// still yields a sensible, non-panicking line.
got := lifespanNote(time.Time{}, time.Now())
if !strings.Contains(got, "reap it") {
t.Errorf("lifespanNote with no expiry should mention reaping; got %q", got)
}
}
2 changes: 1 addition & 1 deletion internal/cli/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func runPromote(cmd *cobra.Command, ref, dest string, f promoteFlags) error {
}

out := cmd.OutOrStdout()
fmt.Fprintf(out, "promoted scratch %s (%s) \u2192 %s\n", sc.ID, displayName(sc), target)
fmt.Fprintf(out, "promoted scratch %s (%s) \u2192 %s \u2014 it escapes the reaper and joins the working tree\n", sc.ID, displayName(sc), target)

if !f.noOpen {
if oerr := openInEditor(cmd, target); oerr != nil {
Expand Down
Loading