diff --git a/cmd/vcs.go b/cmd/vcs.go new file mode 100644 index 0000000..8428beb --- /dev/null +++ b/cmd/vcs.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/dgraph-io/badger/v4" + gap "github.com/muesli/go-app-paths" + "github.com/spf13/cobra" +) + +var vcsCmd = &cobra.Command{ + Use: "vcs", + Short: "Version control utilities", +} + +var vcsInitCmd = &cobra.Command{ + Use: "init", + Short: "Initialise local version control for pda data", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + repoDir, err := vcsRepoRoot() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(repoDir), 0o750); err != nil { + return err + } + + gitDir := filepath.Join(repoDir, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + if err := runGit(repoDir, "init"); err != nil { + return err + } + } + + if err := writeGitignore(repoDir); err != nil { + return err + } + + return nil + }, +} + +var vcsSnapshotCmd = &cobra.Command{ + Use: "snapshot", + Short: "commit a snapshot into the vcs", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + repoDir, err := ensureVCSInitialized() + if err != nil { + return err + } + + store := &Store{} + stores, err := store.AllStores() + if err != nil { + return err + } + + for _, db := range stores { + if err := snapshotDB(store, repoDir, db); err != nil { + return fmt.Errorf("snapshot %q: %w", db, err) + } + } + + if err := runGit(repoDir, "add", "snapshots"); err != nil { + return err + } + + message := fmt.Sprintf("snapshot: %s", time.Now().UTC().Format(time.RFC3339)) + if err := runGit(repoDir, "commit", "-m", message); err != nil { + fmt.Println(err.Error()) + return nil + } + + return nil + }, +} + +func init() { + vcsCmd.AddCommand(vcsInitCmd) + vcsCmd.AddCommand(vcsSnapshotCmd) + rootCmd.AddCommand(vcsCmd) +} + +func vcsRepoRoot() (string, error) { + scope := gap.NewVendorScope(gap.User, "pda", "vcs") + dir, err := scope.DataPath("") + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", err + } + return dir, nil +} + +func ensureVCSInitialized() (string, error) { + repoDir, err := vcsRepoRoot() + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("vcs repository not initialised; run 'pda vcs init' first") + } + return "", err + } + return repoDir, nil +} + +func writeGitignore(repoDir string) error { + path := filepath.Join(repoDir, ".gitignore") + content := strings.Join([]string{ + "# generated by pda", + "*", + "!snapshots/", + "!snapshots/*.ndjson", + "", + }, "\n") + return os.WriteFile(path, []byte(content), 0o640) +} + +func snapshotDB(store *Store, repoDir, db string) error { + snapDir := filepath.Join(repoDir, "snapshots") + if err := os.MkdirAll(snapDir, 0o750); err != nil { + return err + } + target := filepath.Join(snapDir, fmt.Sprintf("%s.ndjson", db)) + f, err := os.Create(target) + if err != nil { + return err + } + defer f.Close() + + trans := TransactionArgs{ + key: "@" + db, + readonly: true, + sync: true, + transact: func(tx *badger.Txn, k []byte) error { + it := tx.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + key := item.KeyCopy(nil) + meta := item.UserMeta() + isSecret := meta&metaSecret != 0 + expiresAt := item.ExpiresAt() + if err := item.Value(func(v []byte) error { + entry := dumpEntry{ + Key: string(key), + Secret: isSecret, + } + if expiresAt > 0 { + ts := int64(expiresAt) + entry.ExpiresAt = &ts + } + if utf8.Valid(v) { + entry.Encoding = "text" + entry.Value = string(v) + } else { + encodeBase64(&entry, v) + } + payload, err := json.Marshal(entry) + if err != nil { + return err + } + _, err = fmt.Fprintln(f, string(payload)) + return err + }); err != nil { + return err + } + } + return nil + }, + } + + if err := store.Transaction(trans); err != nil { + return err + } + + return f.Sync() +} + +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +}