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() }