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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # changed your mind? pull it back
sp promote <id> # the good ones: graduate a scratch into your repo
```

## What works today
Expand All @@ -34,7 +35,8 @@ sp resurrect <id> # 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]`

Expand Down Expand Up @@ -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 <id> [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/<slug>.<ext>
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,
Expand Down
160 changes: 160 additions & 0 deletions internal/cli/promote.go
Original file line number Diff line number Diff line change
@@ -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 <id> [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
}
170 changes: 170 additions & 0 deletions internal/cli/promote_test.go
Original file line number Diff line number Diff line change
@@ -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 <id>` 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)
}
}
Loading
Loading