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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ sp doctor # check store health (orphans, missing file
sp doctor --json | jq -e '.healthy' # gate a script on store health
sp stats # fun store metrics: footprint, oldest survivor, tags
sp stats --json | jq '.totalBytes' # bytes kept out of /tmp, for scripting
sp export --out snap.tar.gz # snapshot the whole store to a tarball
sp import snap.tar.gz # restore it on another machine (merge)
sp ls --json | jq '.[].id' # machine-readable output for scripting
sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell
sp scan <id> # tripwire: does this scratch hold a secret?
Expand Down Expand Up @@ -314,6 +316,37 @@ strings for each size, a `graceSeconds` field, an `oldest` sub-object (`null`
when there are no live scratches), and a `tags` array (always an array, never
null). Pull the headline number with `sp stats --json | jq '.totalBytes'`.

### `sp export` / `sp import` — move the store between machines

The store is local files by design, but you probably have more than one machine.
`export` snapshots the whole store into a single dependency-free `.tar.gz`
(stdlib `archive/tar` + `compress/gzip` only); `import` restores it elsewhere.

```bash
sp export # scratchpatch-export-<timestamp>.tar.gz in cwd
sp export --out snap.tar.gz # write to a specific file
sp export --include-morgue # also archive soft-deleted scratches
sp export --out - | ssh box 'sp import -' # pipe straight to another machine

sp import snap.tar.gz # merge (default): add, never clobber
sp import - < snap.tar.gz # read the tarball from stdin
sp import snap.tar.gz --replace # back up, then replace the whole store
```

By default only **live** scratches are exported; `--include-morgue` also bundles
morgued ones (they land back in the morgue on import). Import has two modes:

- **`--merge` (default)** — adds incoming scratches. On an id collision it keeps
your existing scratch and reports the incoming one as *skipped*; it never
overwrites content silently.
- **`--replace`** — destructive, so it must be explicit. It first writes a
timestamped backup tarball next to the store root, *then* replaces the store
with the archive's contents, keeping the operation recoverable.

Exports carry a self-describing manifest (not the raw index), so a round-trip —
export → fresh store → import — reproduces each scratch's content and metadata
(id, name, tags, timestamps, TTL) identically.

### `sp scan <id>` — the secret tripwire

AI coding agents and tired humans leak API keys and `.env` dumps into throwaway
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ func NewRootCommand() *cobra.Command {
newReapCommand(),
newDoctorCommand(),
newStatsCommand(),
newExportCommand(),
newImportCommand(),
newDedupCommand(),
newScanCommand(),
newTUICommand(),
Expand Down
145 changes: 145 additions & 0 deletions internal/cli/transfer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package cli

import (
"fmt"
"io"
"os"
"time"

"github.com/spf13/cobra"

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

func newExportCommand() *cobra.Command {
var out string
var includeMorgue bool

cmd := &cobra.Command{
Use: "export",
Short: "Snapshot the whole store to a single .tar.gz",
Long: "Bundle the store — the index metadata plus every scratch's content —\n" +
"into one portable .tar.gz you can copy to another machine and `sp\n" +
"import` there.\n\n" +
"By default only live scratches are exported. Pass --include-morgue to\n" +
"also archive soft-deleted scratches still in the morgue.\n\n" +
"Writes to scratchpatch-export-<timestamp>.tar.gz in the current\n" +
"directory unless --out names a file. Use --out - to stream the\n" +
"tarball to stdout (for piping straight into ssh, another tool, etc.).\n\n" +
"Uses only Go's stdlib archive formats — no external tar/gzip needed.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runExport(cmd, out, includeMorgue)
},
}

cmd.Flags().StringVar(&out, "out", "", "output file (default scratchpatch-export-<timestamp>.tar.gz; \"-\" for stdout)")
cmd.Flags().BoolVar(&includeMorgue, "include-morgue", false, "also export soft-deleted scratches in the morgue")

return cmd
}

func runExport(cmd *cobra.Command, out string, includeMorgue bool) error {
st, err := store.Open()
if err != nil {
return err
}

var w io.Writer
var dest string
if out == "-" {
w = cmd.OutOrStdout()
} else {
if out == "" {
out = fmt.Sprintf("scratchpatch-export-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
}
f, err := os.Create(out)
if err != nil {
return fmt.Errorf("export: create %s: %w", out, err)
}
defer f.Close()
w = f
dest = out
}

if err := st.Export(w, store.ExportOptions{IncludeMorgue: includeMorgue}); err != nil {
return err
}

if dest != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "exported store → %s\n", dest)
}
return nil
}

func newImportCommand() *cobra.Command {
var merge bool
var replace bool

cmd := &cobra.Command{
Use: "import <FILE>",
Short: "Restore scratches from an `sp export` tarball",
Long: "Read a .tar.gz produced by `sp export` and restore its scratches into\n" +
"this store. Pass \"-\" as FILE to read the tarball from stdin.\n\n" +
"Two reconciliation modes:\n\n" +
" --merge (default) — add incoming scratches. On an id collision the\n" +
" existing scratch is kept and the incoming one is reported as\n" +
" skipped. Merge never overwrites anything you already have.\n\n" +
" --replace — back up the current store to a timestamped tarball next\n" +
" to the store root, then replace it with the archive's contents.\n" +
" This is destructive, so it must be requested explicitly; the\n" +
" backup keeps it recoverable.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runImport(cmd, args[0], merge, replace)
},
}

cmd.Flags().BoolVar(&merge, "merge", false, "add incoming scratches, never overwriting existing ids (default)")
cmd.Flags().BoolVar(&replace, "replace", false, "back up then replace the entire store (destructive)")

return cmd
}

func runImport(cmd *cobra.Command, file string, merge, replace bool) error {
if merge && replace {
return fmt.Errorf("import: choose one of --merge or --replace, not both")
}

mode := store.ImportMerge
if replace {
mode = store.ImportReplace
}

st, err := store.Open()
if err != nil {
return err
}

var r io.Reader
if file == "-" {
r = cmd.InOrStdin()
} else {
f, err := os.Open(file)
if err != nil {
return fmt.Errorf("import: open %s: %w", file, err)
}
defer f.Close()
r = f
}

res, err := st.Import(r, mode)
if err != nil {
return err
}

errOut := cmd.ErrOrStderr()
if res.BackupPath != "" {
fmt.Fprintf(errOut, "backed up existing store → %s\n", res.BackupPath)
}
fmt.Fprintf(errOut, "imported %d scratch(es)\n", len(res.Added))
if len(res.Skipped) > 0 {
fmt.Fprintf(errOut, "skipped %d colliding id(s): %v\n", len(res.Skipped), res.Skipped)
}
return nil
}
52 changes: 52 additions & 0 deletions internal/cli/transfer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cli

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestExportImportCLIRoundTrip exercises the full command path: create a
// scratch, `sp export` to a file, then `sp import` into a second store and
// confirm the scratch's content survives the trip.
func TestExportImportCLIRoundTrip(t *testing.T) {
src := newSession(t)
src.newScratchID("carryover")

dir := t.TempDir()
tarball := filepath.Join(dir, "snap.tar.gz")
if out, err := src.run("export", "--out", tarball); err != nil {
t.Fatalf("export: %v (out=%s)", err, out)
}
if _, err := os.Stat(tarball); err != nil {
t.Fatalf("export produced no file: %v", err)
}

dst := newSession(t) // fresh SCRATCHPATCH_HOME
out, err := dst.run("import", tarball)
if err != nil {
t.Fatalf("import: %v (out=%s)", err, out)
}
if !strings.Contains(out, "imported 1") {
t.Errorf("import should report one scratch; got %q", out)
}

ls, _ := dst.run("ls")
if !strings.Contains(ls, "carryover") {
t.Errorf("imported store should list the scratch; got %q", ls)
}
}

// TestImportMergeRejectsBothModes: --merge and --replace are mutually exclusive.
func TestImportMergeReplaceConflict(t *testing.T) {
s := newSession(t)
dir := t.TempDir()
tarball := filepath.Join(dir, "snap.tar.gz")
if _, err := s.run("export", "--out", tarball); err != nil {
t.Fatalf("export: %v", err)
}
if _, err := s.run("import", tarball, "--merge", "--replace"); err == nil {
t.Error("import with both --merge and --replace should error")
}
}
Loading
Loading