diff --git a/README.md b/README.md index 537a5cc..e1ab8f8 100644 --- a/README.md +++ b/README.md @@ -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 # tripwire: does this scratch hold a secret? @@ -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-.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 ` — the secret tripwire AI coding agents and tired humans leak API keys and `.env` dumps into throwaway diff --git a/internal/cli/root.go b/internal/cli/root.go index 194fea4..58ea540 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -55,6 +55,8 @@ func NewRootCommand() *cobra.Command { newReapCommand(), newDoctorCommand(), newStatsCommand(), + newExportCommand(), + newImportCommand(), newDedupCommand(), newScanCommand(), newTUICommand(), diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go new file mode 100644 index 0000000..e23b58b --- /dev/null +++ b/internal/cli/transfer.go @@ -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-.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-.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 ", + 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 +} diff --git a/internal/cli/transfer_test.go b/internal/cli/transfer_test.go new file mode 100644 index 0000000..4a1862c --- /dev/null +++ b/internal/cli/transfer_test.go @@ -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") + } +} diff --git a/internal/store/transfer.go b/internal/store/transfer.go new file mode 100644 index 0000000..7056c6a --- /dev/null +++ b/internal/store/transfer.go @@ -0,0 +1,337 @@ +// transfer.go implements `sp export` / `sp import`: snapshotting the whole +// store to a single .tar.gz and restoring it elsewhere. +// +// The store is "just files" by design (an index.json plus per-scratch content +// under scratches/ and morgue/), so a portable snapshot is a tarball of a +// self-describing manifest plus the content files. We deliberately do NOT ship +// the raw on-disk index.json: exporting a manifest of exactly the scratches we +// bundle keeps import's merge/replace semantics honest even if the source +// store changes between listing and archiving. +// +// stdlib only: archive/tar + compress/gzip, no new dependencies. +package store + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path" + "sort" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// manifestName is the tar entry holding the exported metadata. It is read +// first on import; content entries follow under scratches/ and morgue/. +const manifestName = "scratchpatch.json" + +// manifestSchema versions the export format so a future breaking change to the +// tarball layout can be detected and rejected rather than silently mishandled. +const manifestSchema = 1 + +// manifest is the JSON object stored at manifestName inside the tarball. It +// carries the exported scratch records plus a little provenance. +type manifest struct { + Schema int `json:"schema"` + ExportedAt time.Time `json:"exportedAt"` + Scratches []index.Scratch `json:"scratches"` +} + +// ExportOptions controls what Export bundles. +type ExportOptions struct { + // IncludeMorgue also archives soft-deleted (morgued) scratches. When + // false (default), only live scratches are exported. + IncludeMorgue bool +} + +// Export writes a .tar.gz snapshot of the store to w. It archives a manifest +// of the exported scratches followed by each scratch's content file. Live +// scratches always go in; morgued ones only when opts.IncludeMorgue is set. +func (s *Store) Export(w io.Writer, opts ExportOptions) error { + all, err := s.idx.List() + if err != nil { + return fmt.Errorf("export: read index: %w", err) + } + + var picked []index.Scratch + for _, sc := range all { + if sc.Morgued() && !opts.IncludeMorgue { + continue + } + picked = append(picked, sc) + } + + // Stable, deterministic ordering (id) so exports are reproducible. + sort.Slice(picked, func(i, j int) bool { return picked[i].ID < picked[j].ID }) + + gz := gzip.NewWriter(w) + tw := tar.NewWriter(gz) + + m := manifest{ + Schema: manifestSchema, + ExportedAt: time.Now().UTC(), + Scratches: picked, + } + mb, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("export: encode manifest: %w", err) + } + if err := writeTarBytes(tw, manifestName, mb); err != nil { + return err + } + + for _, sc := range picked { + src := s.LivePath(sc) + data, err := os.ReadFile(src) + if err != nil { + // A missing content file is a real inconsistency; surface it + // rather than shipping a manifest entry with no bytes. + return fmt.Errorf("export: read content for %s: %w", sc.ID, err) + } + if err := writeTarBytes(tw, tarEntryName(sc), data); err != nil { + return err + } + } + + if err := tw.Close(); err != nil { + return fmt.Errorf("export: finalize tar: %w", err) + } + if err := gz.Close(); err != nil { + return fmt.Errorf("export: finalize gzip: %w", err) + } + return nil +} + +// ImportMode selects how Import reconciles incoming scratches with the +// existing store. +type ImportMode int + +const ( + // ImportMerge adds incoming scratches; on id collision it keeps the + // existing scratch and records the incoming one as skipped. It never + // clobbers existing content. This is the default. + ImportMerge ImportMode = iota + + // ImportReplace backs up the current store, then replaces it with the + // tarball's contents. Destructive and therefore must be explicit. + ImportReplace +) + +// ImportResult reports what Import did. +type ImportResult struct { + // Added are the ids of scratches written into the store. + Added []string + // Skipped are incoming ids that collided with an existing scratch + // (merge mode only). + Skipped []string + // BackupPath is where the pre-replace backup was written (replace mode). + BackupPath string +} + +// Import restores scratches from a .tar.gz produced by Export. See ImportMode +// for the reconciliation rules. +func (s *Store) Import(r io.Reader, mode ImportMode) (ImportResult, error) { + if mode == ImportReplace { + return s.importReplace(r) + } + return s.importMerge(r) +} + +// importMerge adds incoming scratches without ever overwriting existing ids. +func (s *Store) importMerge(r io.Reader) (ImportResult, error) { + var res ImportResult + + existing := map[string]bool{} + cur, err := s.idx.List() + if err != nil { + return res, fmt.Errorf("import: read index: %w", err) + } + for _, sc := range cur { + existing[sc.ID] = true + } + + m, contents, err := readArchive(r) + if err != nil { + return res, err + } + + for _, sc := range m.Scratches { + if existing[sc.ID] { + res.Skipped = append(res.Skipped, sc.ID) + continue + } + data, ok := contents[tarEntryName(sc)] + if !ok { + return res, fmt.Errorf("import: tarball missing content for %s", sc.ID) + } + if err := s.writeScratch(sc, data); err != nil { + return res, err + } + res.Added = append(res.Added, sc.ID) + existing[sc.ID] = true // guard against duplicate ids within one tarball + } + + sort.Strings(res.Added) + sort.Strings(res.Skipped) + return res, nil +} + +// importReplace backs up the current store to a timestamped tarball, wipes the +// live/morgue content and index, then imports everything from the incoming +// archive. The backup makes the destructive path recoverable. +func (s *Store) importReplace(r io.Reader) (ImportResult, error) { + var res ImportResult + + // Read the incoming archive fully before touching anything on disk, so a + // malformed tarball can't leave us half-wiped. + m, contents, err := readArchive(r) + if err != nil { + return res, err + } + + // Back up the current store (including morgue) next to the store root. + backup := fmt.Sprintf("%s-backup-%s.tar.gz", s.cfg.Home, time.Now().UTC().Format("20060102-150405")) + bf, err := os.Create(backup) + if err != nil { + return res, fmt.Errorf("import: create backup: %w", err) + } + if err := s.Export(bf, ExportOptions{IncludeMorgue: true}); err != nil { + _ = bf.Close() + return res, fmt.Errorf("import: write backup: %w", err) + } + if err := bf.Close(); err != nil { + return res, fmt.Errorf("import: close backup: %w", err) + } + res.BackupPath = backup + + // Wipe existing content and reset the index. + cur, err := s.idx.List() + if err != nil { + return res, fmt.Errorf("import: read index: %w", err) + } + for _, sc := range cur { + if err := os.Remove(s.LivePath(sc)); err != nil && !os.IsNotExist(err) { + return res, fmt.Errorf("import: remove old content for %s: %w", sc.ID, err) + } + if err := s.idx.Delete(sc.ID); err != nil { + return res, fmt.Errorf("import: reset index for %s: %w", sc.ID, err) + } + } + + seen := map[string]bool{} + for _, sc := range m.Scratches { + if seen[sc.ID] { + continue + } + data, ok := contents[tarEntryName(sc)] + if !ok { + return res, fmt.Errorf("import: tarball missing content for %s", sc.ID) + } + if err := s.writeScratch(sc, data); err != nil { + return res, err + } + res.Added = append(res.Added, sc.ID) + seen[sc.ID] = true + } + + sort.Strings(res.Added) + return res, nil +} + +// writeScratch persists one incoming scratch: its content to the right +// directory (scratches/ or morgue/ depending on Morgued) and its metadata to +// the index. +func (s *Store) writeScratch(sc index.Scratch, data []byte) error { + dst := s.LivePath(sc) + if err := os.WriteFile(dst, data, filePerm); err != nil { + return fmt.Errorf("import: write content for %s: %w", sc.ID, err) + } + if err := s.idx.Put(sc); err != nil { + return fmt.Errorf("import: index %s: %w", sc.ID, err) + } + return nil +} + +// tarEntryName is the in-tarball path for a scratch's content: it mirrors the +// on-disk layout (scratches/ or morgue/) so the archive is self-explanatory. +func tarEntryName(sc index.Scratch) string { + name := sc.ID + if sc.Ext != "" { + name += "." + sc.Ext + } + dir := "scratches" + if sc.Morgued() { + dir = "morgue" + } + return path.Join(dir, name) +} + +// writeTarBytes writes a single regular-file entry into tw. +func writeTarBytes(tw *tar.Writer, name string, data []byte) error { + hdr := &tar.Header{ + Name: name, + Mode: int64(filePerm), + Size: int64(len(data)), + ModTime: time.Now().UTC(), + } + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("export: write header %s: %w", name, err) + } + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("export: write body %s: %w", name, err) + } + return nil +} + +// readArchive decodes a .tar.gz into its manifest and a map of content-entry +// name → bytes. It validates the manifest schema and requires the manifest to +// be present. +func readArchive(r io.Reader) (manifest, map[string][]byte, error) { + var m manifest + contents := map[string][]byte{} + haveManifest := false + + gz, err := gzip.NewReader(r) + if err != nil { + return m, nil, fmt.Errorf("import: open gzip: %w", err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return m, nil, fmt.Errorf("import: read tar: %w", err) + } + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + continue + } + data, err := io.ReadAll(tr) + if err != nil { + return m, nil, fmt.Errorf("import: read entry %s: %w", hdr.Name, err) + } + if hdr.Name == manifestName { + if err := json.Unmarshal(data, &m); err != nil { + return m, nil, fmt.Errorf("import: parse manifest: %w", err) + } + haveManifest = true + continue + } + contents[path.Clean(hdr.Name)] = data + } + + if !haveManifest { + return m, nil, fmt.Errorf("import: not a scratchpatch export (missing %s)", manifestName) + } + if m.Schema != manifestSchema { + return m, nil, fmt.Errorf("import: unsupported export schema %d (want %d)", m.Schema, manifestSchema) + } + return m, contents, nil +} diff --git a/internal/store/transfer_test.go b/internal/store/transfer_test.go new file mode 100644 index 0000000..fd2777f --- /dev/null +++ b/internal/store/transfer_test.go @@ -0,0 +1,225 @@ +package store + +import ( + "bytes" + "os" + "testing" + "time" +) + +// seedContent creates a live scratch and writes real content bytes so +// round-trips have something to compare. +func seedContent(t *testing.T, s *Store, name, body string) (id string, path string) { + t.Helper() + sc := seed(t, s, name, body) + return sc.ID, s.ContentPath(sc) +} + +func TestExportImportRoundTrip(t *testing.T) { + src, _ := OpenWith(testConfig(t)) + idA, _ := seedContent(t, src, "alpha", "hello alpha\n") + idB, _ := seedContent(t, src, "beta", "hello beta\n") + + var buf bytes.Buffer + if err := src.Export(&buf, ExportOptions{}); err != nil { + t.Fatalf("Export: %v", err) + } + + dst, _ := OpenWith(testConfig(t)) + res, err := dst.Import(&buf, ImportMerge) + if err != nil { + t.Fatalf("Import: %v", err) + } + if len(res.Added) != 2 { + t.Fatalf("Added = %v, want 2", res.Added) + } + + for id, want := range map[string]string{idA: "hello alpha\n", idB: "hello beta\n"} { + sc, err := dst.Index().Get(id) + if err != nil { + t.Fatalf("Get(%s): %v", id, err) + } + got, err := os.ReadFile(dst.LivePath(sc)) + if err != nil { + t.Fatalf("read content for %s: %v", id, err) + } + if string(got) != want { + t.Errorf("content for %s = %q, want %q", id, got, want) + } + } +} + +func TestExportImportMetadataPreserved(t *testing.T) { + src, _ := OpenWith(testConfig(t)) + sc := seed(t, src, "meta", "body\n") + sc.Tags = []string{"keep", "me"} + sc.CreatedAt = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + if err := src.Index().Put(sc); err != nil { + t.Fatalf("Put: %v", err) + } + + var buf bytes.Buffer + if err := src.Export(&buf, ExportOptions{}); err != nil { + t.Fatalf("Export: %v", err) + } + + dst, _ := OpenWith(testConfig(t)) + if _, err := dst.Import(&buf, ImportMerge); err != nil { + t.Fatalf("Import: %v", err) + } + got, err := dst.Index().Get(sc.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Tags) != 2 || got.Tags[0] != "keep" || got.Tags[1] != "me" { + t.Errorf("tags = %v, want [keep me]", got.Tags) + } + if !got.CreatedAt.Equal(sc.CreatedAt) { + t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, sc.CreatedAt) + } +} + +func TestImportMergeSkipsCollisions(t *testing.T) { + src, _ := OpenWith(testConfig(t)) + idA, _ := seedContent(t, src, "alpha", "source alpha\n") + + var buf bytes.Buffer + if err := src.Export(&buf, ExportOptions{}); err != nil { + t.Fatalf("Export: %v", err) + } + + // Destination already holds the same id with different content. + dst, _ := OpenWith(testConfig(t)) + existing := seed(t, dst, "existing", "DO NOT CLOBBER\n") + // Force the destination scratch to share the source id. + if err := dst.Index().Delete(existing.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + _ = os.Remove(dst.ContentPath(existing)) + existing.ID = idA + if err := dst.Index().Put(existing); err != nil { + t.Fatalf("Put: %v", err) + } + if err := os.WriteFile(dst.ContentPath(existing), []byte("DO NOT CLOBBER\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + res, err := dst.Import(&buf, ImportMerge) + if err != nil { + t.Fatalf("Import: %v", err) + } + if len(res.Added) != 0 { + t.Errorf("Added = %v, want none", res.Added) + } + if len(res.Skipped) != 1 || res.Skipped[0] != idA { + t.Errorf("Skipped = %v, want [%s]", res.Skipped, idA) + } + + got, _ := dst.Index().Get(idA) + content, _ := os.ReadFile(dst.LivePath(got)) + if string(content) != "DO NOT CLOBBER\n" { + t.Errorf("merge clobbered existing content: %q", content) + } +} + +func TestExportEmptyStore(t *testing.T) { + src, _ := OpenWith(testConfig(t)) + + var buf bytes.Buffer + if err := src.Export(&buf, ExportOptions{}); err != nil { + t.Fatalf("Export empty: %v", err) + } + if buf.Len() == 0 { + t.Fatal("empty-store export produced no bytes") + } + + dst, _ := OpenWith(testConfig(t)) + res, err := dst.Import(&buf, ImportMerge) + if err != nil { + t.Fatalf("Import empty: %v", err) + } + if len(res.Added) != 0 || len(res.Skipped) != 0 { + t.Errorf("empty import did something: %+v", res) + } +} + +func TestExportIncludeMorgue(t *testing.T) { + src, _ := OpenWith(testConfig(t)) + live, _ := seedContent(t, src, "live", "alive\n") + dead := seedMorgued(t, src, "dead", time.Now().Add(-time.Hour), "buried\n") + + // Without --include-morgue: only the live scratch travels. + var noMorgue bytes.Buffer + if err := src.Export(&noMorgue, ExportOptions{}); err != nil { + t.Fatalf("Export: %v", err) + } + dst1, _ := OpenWith(testConfig(t)) + res1, _ := dst1.Import(&noMorgue, ImportMerge) + if len(res1.Added) != 1 || res1.Added[0] != live { + t.Errorf("live-only Added = %v, want [%s]", res1.Added, live) + } + + // With --include-morgue: both travel, and the dead one lands in the morgue. + var withMorgue bytes.Buffer + if err := src.Export(&withMorgue, ExportOptions{IncludeMorgue: true}); err != nil { + t.Fatalf("Export: %v", err) + } + dst2, _ := OpenWith(testConfig(t)) + res2, _ := dst2.Import(&withMorgue, ImportMerge) + if len(res2.Added) != 2 { + t.Fatalf("with-morgue Added = %v, want 2", res2.Added) + } + got, err := dst2.Index().Get(dead.ID) + if err != nil { + t.Fatalf("Get dead: %v", err) + } + if !got.Morgued() { + t.Error("imported morgue scratch should still be morgued") + } + if _, err := os.Stat(dst2.morguePath(got)); err != nil { + t.Errorf("morgue content missing: %v", err) + } +} + +func TestImportReplaceBacksUpAndReplaces(t *testing.T) { + src, _ := OpenWith(testConfig(t)) + newID, _ := seedContent(t, src, "incoming", "fresh\n") + + var buf bytes.Buffer + if err := src.Export(&buf, ExportOptions{}); err != nil { + t.Fatalf("Export: %v", err) + } + + dst, _ := OpenWith(testConfig(t)) + oldID, _ := seedContent(t, dst, "stale", "old\n") + + res, err := dst.Import(&buf, ImportReplace) + if err != nil { + t.Fatalf("Import replace: %v", err) + } + if res.BackupPath == "" { + t.Fatal("replace should record a backup path") + } + if _, err := os.Stat(res.BackupPath); err != nil { + t.Errorf("backup file missing: %v", err) + } + // Old scratch is gone, new one present. + if _, err := dst.Index().Get(oldID); err == nil { + t.Errorf("old scratch %s should be gone after replace", oldID) + } + if _, err := dst.Index().Get(newID); err != nil { + t.Errorf("new scratch %s should be present after replace: %v", newID, err) + } +} + +func TestImportRejectsNonExport(t *testing.T) { + dst, _ := OpenWith(testConfig(t)) + // Random gzip'd bytes that aren't a scratchpatch export. + var buf bytes.Buffer + // A valid-but-empty gzip stream via Export of empty store would pass; use + // plainly bogus bytes to exercise the gzip-open error path instead. + buf.WriteString("not a gzip stream at all") + if _, err := dst.Import(&buf, ImportMerge); err == nil { + t.Error("importing garbage should error") + } +}