diff --git a/README.md b/README.md index 95972bf..c284956 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ sp doctor # check store health (orphans, missing file sp ls --json | jq '.[].id' # machine-readable output for scripting sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell sp resurrect # changed your mind? pull it back +sp promote # the good ones: graduate a scratch into your repo ``` ## What works today @@ -34,7 +35,8 @@ sp resurrect # changed your mind? pull it back (M4); **automatic reaping** — `sp reap`, with human-friendly TTLs and a `--dry-run` preview (M5); and a read-only **`sp doctor`** store health check plus scripting polish — **`sp ls --json`** and **`sp completion`** for -bash/zsh/fish (M6, in progress). +bash/zsh/fish, and **`sp promote`** to graduate a scratch into your repo +(M6, in progress). ### `sp new [name]` @@ -112,6 +114,30 @@ Pulls a soft-deleted scratch back out of the morgue and into the live set. sp resurrect 1a2b # (alias: sp restore) ``` +### `sp promote [dest]` — graduate a scratch into your repo + +Sometimes a throwaway turns out to matter. `sp promote` is the escape hatch: +it moves the scratch's file out of the store and into your working tree, then +drops it from the index — once promoted it's the repo's to keep, and the reaper +can't touch it. + +```bash +sp promote 1a2b # into the current dir, named from the scratch (e.g. bug-repro.py) +sp promote 1a2b ./notes # into a directory: ./notes/. +sp promote 1a2b keep.md # to an explicit path (renames on the way out) +sp promote 1a2b keep.md --force # overwrite an existing destination +sp promote 1a2b --no-open # don't open it in $EDITOR afterwards +``` + +- With no `dest`, the file lands in the current directory under a slug of the + scratch's name (its id when unnamed), keeping the original extension. +- An existing-directory `dest` drops the file inside it; any other `dest` is the + full target path. +- Promoting **never overwrites** an existing file without `--force`, and a + refused promote leaves the scratch untouched in the store. +- After moving, the promoted file opens in `$EDITOR` (skip with `--no-open`); + a missing `$EDITOR` is not fatal — the move already happened. + ### `sp ls --morgue` Lists the morgue: id, name, when each was deleted, **time until purge**, tags, diff --git a/internal/cli/promote.go b/internal/cli/promote.go new file mode 100644 index 0000000..0ba5182 --- /dev/null +++ b/internal/cli/promote.go @@ -0,0 +1,160 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/rwrife/scratchpatch/internal/index" + "github.com/rwrife/scratchpatch/internal/store" +) + +// promoteFlags holds the parsed `sp promote` options. +type promoteFlags struct { + force bool + noOpen bool +} + +func newPromoteCommand() *cobra.Command { + var f promoteFlags + + cmd := &cobra.Command{ + Use: "promote [dest]", + Short: "Graduate a scratch into the current repo", + Long: "Move a scratch out of the store and into the working tree \u2014 the escape\n" + + "hatch for the throwaway that turned out to matter. The content file is\n" + + "relocated into the current directory (or [dest]) and the scratch is\n" + + "dropped from the index: once promoted it's the repo's to keep, and the\n" + + "reaper can't touch it.\n\n" + + "If [dest] is an existing directory the file is placed inside it under a\n" + + "slug of its name; otherwise [dest] is the full target path. Promoting\n" + + "never overwrites an existing file unless --force is given. The id may be\n" + + "an unambiguous prefix.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + dest := "" + if len(args) == 2 { + dest = args[1] + } + return runPromote(cmd, args[0], dest, f) + }, + } + + cmd.Flags().BoolVar(&f.force, "force", false, "overwrite the destination if a file is already there") + cmd.Flags().BoolVar(&f.noOpen, "no-open", false, "don't open the promoted file in $EDITOR after moving") + + return cmd +} + +func runPromote(cmd *cobra.Command, ref, dest string, f promoteFlags) error { + st, err := store.Open() + if err != nil { + return err + } + sc, err := resolve(st, ref) + if err != nil { + return err + } + + target, err := promoteTarget(sc, dest) + if err != nil { + return err + } + + // Guard against promoting a scratch onto its own store file, which the + // move would happily "succeed" at while corrupting state. + if abs, aerr := filepath.Abs(target); aerr == nil { + if same, serr := sameFile(abs, st.LivePath(sc)); serr == nil && same { + return fmt.Errorf("destination %s is the scratch's own store file", target) + } + } + + if err := st.Promote(sc, target, f.force); err != nil { + return promoteError(err, target) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "promoted scratch %s (%s) \u2192 %s\n", sc.ID, displayName(sc), target) + + if !f.noOpen { + if oerr := openInEditor(cmd, target); oerr != nil { + // The move already succeeded; a missing/failed editor just means + // the user opens it themselves. Never treat this as fatal. + fmt.Fprintln(cmd.ErrOrStderr(), oerr) + } + } + return nil +} + +// promoteTarget resolves the destination the scratch's content should land at. +// With no dest, the file goes into the current directory under a friendly slug. +// A dest that is (or looks like) a directory places the file inside it; any +// other dest is treated as the full target path, so `sp promote x keep.md` +// renames on the way out. +func promoteTarget(sc index.Scratch, dest string) (string, error) { + filename := promoteFilename(sc) + + if dest == "" { + return filename, nil + } + + if isDirDest(dest) { + return filepath.Join(dest, filename), nil + } + return dest, nil +} + +// promoteFilename builds the default on-disk name for a promoted scratch: a +// slug of its name (falling back to its id) plus its extension, so a scratch +// named "Deploy Notes" lands as deploy-notes.md rather than a bare hex id. +func promoteFilename(sc index.Scratch) string { + base := slugify(sc.Name) + if base == "" { + base = sc.ID + } + if sc.Ext != "" { + base += "." + sc.Ext + } + return base +} + +// isDirDest reports whether dest should be treated as a directory to drop the +// file into: an existing directory, or a path written with a trailing +// separator or a bare "." / ".." that clearly names a directory. +func isDirDest(dest string) bool { + if info, err := os.Stat(dest); err == nil { + return info.IsDir() + } + if dest == "." || dest == ".." { + return true + } + if os.IsPathSeparator(dest[len(dest)-1]) { + return true + } + return false +} + +// sameFile reports whether two paths refer to the same on-disk file, so we can +// refuse a no-op/destructive promote onto the scratch's own store file. +func sameFile(a, b string) (bool, error) { + ai, err := os.Stat(a) + if err != nil { + return false, err + } + bi, err := os.Stat(b) + if err != nil { + return false, err + } + return os.SameFile(ai, bi), nil +} + +// promoteError maps the store's promote failures onto CLI-actionable wording. +func promoteError(err error, target string) error { + if errors.Is(err, store.ErrDestinationExists) { + return fmt.Errorf("%s already exists \u2014 pass --force to overwrite it", target) + } + return err +} diff --git a/internal/cli/promote_test.go b/internal/cli/promote_test.go new file mode 100644 index 0000000..0539c96 --- /dev/null +++ b/internal/cli/promote_test.go @@ -0,0 +1,170 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeScratchBody writes body into the live content file for id (ext txt, as +// created by newScratchID) so promote has real content to move. +func (s *session) writeScratchBody(id, body string) string { + s.t.Helper() + matches, _ := filepath.Glob(filepath.Join(s.home, "scratches", id+".txt")) + if len(matches) != 1 { + s.t.Fatalf("expected one scratch file for %s, got %v", id, matches) + } + if err := os.WriteFile(matches[0], []byte(body), 0o600); err != nil { + s.t.Fatalf("write scratch body: %v", err) + } + return matches[0] +} + +func TestPromoteMovesScratchIntoDestAndDropsIt(t *testing.T) { + s := newSession(t) + id := s.newScratchID("keep-this") + s.writeScratchBody(id, "promote me\n") + + destDir := t.TempDir() + dest := filepath.Join(destDir, "kept.md") + + out, err := s.run("promote", id, dest) + if err != nil { + t.Fatalf("promote: %v (out=%s)", err, out) + } + if !strings.Contains(out, "promoted scratch") || !strings.Contains(out, dest) { + t.Errorf("promote should confirm the new path; got %q", out) + } + + // The file is now in the repo with its content intact... + body, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read promoted file: %v", err) + } + if string(body) != "promote me\n" { + t.Errorf("promoted content = %q, want %q", body, "promote me\n") + } + + // ...and the store no longer lists it. + lsOut, _ := s.run("ls") + if strings.Contains(lsOut, "keep-this") { + t.Errorf("promoted scratch should vanish from ls; got %q", lsOut) + } + // Its store file is gone too. + if matches, _ := filepath.Glob(filepath.Join(s.home, "scratches", id+".*")); len(matches) != 0 { + t.Errorf("store file should be gone after promote; got %v", matches) + } +} + +func TestPromoteIntoDirectoryUsesSluggedName(t *testing.T) { + s := newSession(t) + id := s.newScratchID("Deploy Notes") + s.writeScratchBody(id, "steps\n") + + destDir := t.TempDir() + out, err := s.run("promote", id, destDir) + if err != nil { + t.Fatalf("promote into dir: %v (out=%s)", err, out) + } + + // Name is slugged, ext preserved from the scratch (txt in the harness). + want := filepath.Join(destDir, "deploy-notes.txt") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected promoted file at %s: %v (out=%s)", want, err, out) + } +} + +func TestPromoteDefaultsToCurrentDir(t *testing.T) { + s := newSession(t) + id := s.newScratchID("here-please") + s.writeScratchBody(id, "local\n") + + // Run from a scratch working dir so a bare `sp promote ` lands here. + workdir := t.TempDir() + oldWd, _ := os.Getwd() + if err := os.Chdir(workdir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(oldWd) }) + + if out, err := s.run("promote", id); err != nil { + t.Fatalf("promote (default dir): %v (out=%s)", err, out) + } + if _, err := os.Stat(filepath.Join(workdir, "here-please.txt")); err != nil { + t.Errorf("expected promoted file in cwd: %v", err) + } +} + +func TestPromoteRefusesOverwriteWithoutForce(t *testing.T) { + s := newSession(t) + id := s.newScratchID("nope") + s.writeScratchBody(id, "new\n") + + dest := filepath.Join(t.TempDir(), "taken.md") + if err := os.WriteFile(dest, []byte("keep me\n"), 0o600); err != nil { + t.Fatalf("pre-write dest: %v", err) + } + + _, err := s.run("promote", id, dest) + if err == nil { + t.Fatal("promote onto an existing file should error without --force") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("error should hint at --force; got %v", err) + } + + // Non-destructive: destination untouched, scratch still present. + body, _ := os.ReadFile(dest) + if string(body) != "keep me\n" { + t.Errorf("destination should be untouched; got %q", body) + } + lsOut, _ := s.run("ls") + if !strings.Contains(lsOut, "nope") { + t.Errorf("refused promote should leave the scratch listed; got %q", lsOut) + } +} + +func TestPromoteForceOverwrites(t *testing.T) { + s := newSession(t) + id := s.newScratchID("clobber") + s.writeScratchBody(id, "fresh\n") + + dest := filepath.Join(t.TempDir(), "target.md") + if err := os.WriteFile(dest, []byte("stale\n"), 0o600); err != nil { + t.Fatalf("pre-write dest: %v", err) + } + + if out, err := s.run("promote", id, dest, "--force"); err != nil { + t.Fatalf("promote --force: %v (out=%s)", err, out) + } + body, _ := os.ReadFile(dest) + if string(body) != "fresh\n" { + t.Errorf("--force should overwrite; got %q", body) + } +} + +func TestPromoteUnknownIDErrors(t *testing.T) { + s := newSession(t) + _, err := s.run("promote", "ghost123") + if err == nil { + t.Fatal("promote of an unknown id should error") + } + if !strings.Contains(err.Error(), "no scratch matches") { + t.Errorf("error should mention no match; got %v", err) + } +} + +func TestPromotePrefixResolution(t *testing.T) { + s := newSession(t) + id := s.newScratchID("prefixy") + s.writeScratchBody(id, "body\n") + + destDir := t.TempDir() + if out, err := s.run("promote", id[:4], destDir); err != nil { + t.Fatalf("promote by prefix: %v (out=%s)", err, out) + } + if _, err := os.Stat(filepath.Join(destDir, "prefixy.txt")); err != nil { + t.Errorf("prefix promote should land the file: %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 36a7eff..3dd4153 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,8 +7,9 @@ // morgue items past the grace window (with `--dry-run`). // M6 begins the polish pass with `sp doctor`: a read-only store health check // that reconciles the index against what's actually on disk. M6 also adds -// `--json` output to `sp ls` for scripting and `sp completion` to generate -// bash/zsh/fish completion scripts. +// `--json` output to `sp ls` for scripting, `sp completion` to generate +// bash/zsh/fish completion scripts, and `sp promote`: graduate a scratch out of +// the store into the working tree so the good ones escape the reaper. package cli import ( @@ -48,6 +49,7 @@ func NewRootCommand() *cobra.Command { newOpenCommand(), newRmCommand(), newResurrectCommand(), + newPromoteCommand(), newReapCommand(), newDoctorCommand(), newCompletionCommand(), diff --git a/internal/store/lifecycle.go b/internal/store/lifecycle.go index c4c0d67..93c9e43 100644 --- a/internal/store/lifecycle.go +++ b/internal/store/lifecycle.go @@ -26,6 +26,10 @@ import ( // ErrAmbiguousID is returned when an id prefix matches more than one scratch. var ErrAmbiguousID = errors.New("ambiguous id prefix") +// ErrDestinationExists is returned by Promote when the target path already +// exists and the caller didn't ask to overwrite it. +var ErrDestinationExists = errors.New("destination already exists") + // 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. @@ -163,6 +167,48 @@ func (s *Store) Resurrect(sc index.Scratch) (index.Scratch, error) { return sc, nil } +// Promote graduates a scratch out of the store and into the wider filesystem: +// it moves the content file from wherever it lives (scratches/ or morgue/) to +// dst, then drops the scratch's index entry so the store no longer tracks it — +// the promoted file is the destination's responsibility now. +// +// dst must be the final absolute (or caller-resolved) target file path; Promote +// does not interpret directories or invent filenames, keeping this method a +// thin, testable move+forget. It refuses to clobber an existing dst unless +// overwrite is true. The filesystem move happens first; the index entry is only +// removed after the content has safely landed, and the move is rolled back if +// the index write fails — so a scratch is never lost between the two steps. +func (s *Store) Promote(sc index.Scratch, dst string, overwrite bool) error { + if !overwrite { + if _, err := os.Lstat(dst); err == nil { + return fmt.Errorf("%w: %s", ErrDestinationExists, dst) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat destination %s: %w", dst, err) + } + } + + // Make sure the destination directory exists so promoting into a nested + // path (e.g. notes/keep.md) works without the caller pre-creating it. + if dir := filepath.Dir(dst); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create destination dir %s: %w", dir, err) + } + } + + from := s.LivePath(sc) + if err := moveFile(from, dst); err != nil { + return fmt.Errorf("move scratch out of the store: %w", err) + } + + if err := s.idx.Delete(sc.ID); err != nil { + // Roll the content back to where it came from so the store stays + // consistent if we couldn't forget the metadata. + _ = moveFile(dst, from) + return fmt.Errorf("drop promoted scratch from index: %w", err) + } + return nil +} + // PurgeAt returns when a morgued scratch becomes eligible for hard-deletion: // DeletedAt + the configured grace window. The bool is false for live scratches // (which have no purge deadline). M5's reap consumes this; ls --morgue renders diff --git a/internal/store/promote_test.go b/internal/store/promote_test.go new file mode 100644 index 0000000..bb6ccf5 --- /dev/null +++ b/internal/store/promote_test.go @@ -0,0 +1,122 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/rwrife/scratchpatch/internal/index" +) + +func TestPromoteMovesContentOutAndForgetsScratch(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + sc := seed(t, s, "keeper", "worth keeping\n") + + livePath := s.ContentPath(sc) + dest := filepath.Join(t.TempDir(), "keeper.md") + + if err := s.Promote(sc, dest, false); err != nil { + t.Fatalf("Promote: %v", err) + } + + // Content left the store and landed at the destination unchanged. + if _, err := os.Stat(livePath); !errors.Is(err, os.ErrNotExist) { + t.Errorf("store content should be gone after promote, stat err = %v", err) + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read promoted file: %v", err) + } + if string(body) != "worth keeping\n" { + t.Errorf("promoted content = %q, want %q", body, "worth keeping\n") + } + + // The index no longer tracks it — the repo owns it now. + if _, err := s.Index().Get(sc.ID); !errors.Is(err, index.ErrNotFound) { + t.Errorf("promoted scratch should be dropped from index, got err = %v", err) + } +} + +func TestPromoteFromMorgue(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + sc := seed(t, s, "second-chance", "rescued\n") + morgued, err := s.MoveToMorgue(sc) + if err != nil { + t.Fatalf("MoveToMorgue: %v", err) + } + + dest := filepath.Join(t.TempDir(), "rescued.md") + if err := s.Promote(morgued, dest, false); err != nil { + t.Fatalf("Promote from morgue: %v", err) + } + + if _, err := os.Stat(s.morguePath(morgued)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("morgue content should be gone after promote, stat err = %v", err) + } + body, _ := os.ReadFile(dest) + if string(body) != "rescued\n" { + t.Errorf("promoted morgue content = %q, want %q", body, "rescued\n") + } + if _, err := s.Index().Get(morgued.ID); !errors.Is(err, index.ErrNotFound) { + t.Errorf("promoted scratch should leave the index, got err = %v", err) + } +} + +func TestPromoteRefusesToOverwriteWithoutForce(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + sc := seed(t, s, "collide", "new body\n") + + dest := filepath.Join(t.TempDir(), "existing.md") + if err := os.WriteFile(dest, []byte("do not clobber\n"), 0o600); err != nil { + t.Fatalf("pre-write dest: %v", err) + } + + err := s.Promote(sc, dest, false) + if !errors.Is(err, ErrDestinationExists) { + t.Fatalf("expected ErrDestinationExists, got %v", err) + } + + // The guard must be non-destructive: destination untouched, scratch intact. + body, _ := os.ReadFile(dest) + if string(body) != "do not clobber\n" { + t.Errorf("destination should be untouched, got %q", body) + } + if _, err := s.Index().Get(sc.ID); err != nil { + t.Errorf("scratch should survive a refused promote, got %v", err) + } + if _, err := os.Stat(s.ContentPath(sc)); err != nil { + t.Errorf("scratch content should survive a refused promote, got %v", err) + } +} + +func TestPromoteForceOverwrites(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + sc := seed(t, s, "winner", "fresh\n") + + dest := filepath.Join(t.TempDir(), "target.md") + if err := os.WriteFile(dest, []byte("stale\n"), 0o600); err != nil { + t.Fatalf("pre-write dest: %v", err) + } + + if err := s.Promote(sc, dest, true); err != nil { + t.Fatalf("Promote --force: %v", err) + } + body, _ := os.ReadFile(dest) + if string(body) != "fresh\n" { + t.Errorf("force promote should overwrite, got %q", body) + } +} + +func TestPromoteCreatesMissingDestDir(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + sc := seed(t, s, "nested", "deep\n") + + dest := filepath.Join(t.TempDir(), "a", "b", "c", "nested.md") + if err := s.Promote(sc, dest, false); err != nil { + t.Fatalf("Promote into nested dir: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("nested destination should exist, got %v", err) + } +}