feat(storage): add VersionedStorage interface with history/diff/branch operations

Extends Storage interface with Dolt-specific version control capabilities:

- New VersionedStorage interface in storage/versioned.go with:
  - History queries: History(), AsOf(), Diff()
  - Branch operations: Branch(), Merge(), CurrentBranch(), ListBranches()
  - Commit operations: Commit(), GetCurrentCommit()
  - Conflict resolution: GetConflicts(), ResolveConflicts()
  - Helper types: HistoryEntry, DiffEntry, Conflict

- DoltStore implements VersionedStorage interface

- New CLI commands:
  - bd history <id> - Show issue version history
  - bd diff <from> <to> - Show changes between commits/branches
  - bd branch [name] - List or create branches
  - bd vc merge <branch> - Merge branch to current
  - bd vc commit -m <msg> - Create a commit
  - bd vc status - Show current branch/commit

- Added --as-of flag to bd show for time-travel queries

- IsVersioned() helper for graceful SQLite backend detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
quartz
2026-01-17 01:54:55 -08:00
committed by gastown/crew/dennis
parent a7cd9136d8
commit 94581ab233
11 changed files with 1031 additions and 10 deletions

85
cmd/bd/branch.go Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/ui"
)
var branchCmd = &cobra.Command{
Use: "branch [name]",
GroupID: "sync",
Short: "List or create branches (requires Dolt backend)",
Long: `List all branches or create a new branch.
This command requires the Dolt storage backend. Without arguments,
it lists all branches. With an argument, it creates a new branch.
Examples:
bd branch # List all branches
bd branch feature-xyz # Create a new branch named feature-xyz`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("branch requires Dolt backend (current backend does not support versioning)")
}
// If no args, list branches
if len(args) == 0 {
branches, err := vs.ListBranches(ctx)
if err != nil {
FatalErrorRespectJSON("failed to list branches: %v", err)
}
currentBranch, err := vs.CurrentBranch(ctx)
if err != nil {
// Non-fatal, just don't show current marker
currentBranch = ""
}
if jsonOutput {
outputJSON(map[string]interface{}{
"current": currentBranch,
"branches": branches,
})
return
}
fmt.Printf("\n%s Branches:\n\n", ui.RenderAccent("🌿"))
for _, branch := range branches {
if branch == currentBranch {
fmt.Printf(" * %s\n", ui.StatusInProgressStyle.Render(branch))
} else {
fmt.Printf(" %s\n", branch)
}
}
fmt.Println()
return
}
// Create new branch
branchName := args[0]
if err := vs.Branch(ctx, branchName); err != nil {
FatalErrorRespectJSON("failed to create branch: %v", err)
}
if jsonOutput {
outputJSON(map[string]interface{}{
"created": branchName,
})
return
}
fmt.Printf("Created branch: %s\n", ui.RenderAccent(branchName))
},
}
func init() {
rootCmd.AddCommand(branchCmd)
}

151
cmd/bd/diff.go Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/ui"
)
var diffCmd = &cobra.Command{
Use: "diff <from-ref> <to-ref>",
GroupID: "views",
Short: "Show changes between two commits or branches (requires Dolt backend)",
Long: `Show the differences in issues between two commits or branches.
This command requires the Dolt storage backend. The refs can be:
- Commit hashes (e.g., abc123def)
- Branch names (e.g., main, feature-branch)
- Special refs like HEAD, HEAD~1
Examples:
bd diff main feature-branch # Compare main to feature branch
bd diff HEAD~5 HEAD # Show changes in last 5 commits
bd diff abc123 def456 # Compare two specific commits`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
fromRef := args[0]
toRef := args[1]
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("diff requires Dolt backend (current backend does not support versioning)")
}
// Get diff between refs
entries, err := vs.Diff(ctx, fromRef, toRef)
if err != nil {
FatalErrorRespectJSON("failed to get diff: %v", err)
}
if len(entries) == 0 {
fmt.Printf("No changes between %s and %s\n", fromRef, toRef)
return
}
if jsonOutput {
outputJSON(entries)
return
}
// Display diff in human-readable format
fmt.Printf("\n%s Changes from %s to %s (%d issues affected)\n\n",
ui.RenderAccent("📊"),
ui.RenderMuted(fromRef),
ui.RenderMuted(toRef),
len(entries))
// Group by diff type
var added, modified, removed []*storage.DiffEntry
for _, entry := range entries {
switch entry.DiffType {
case "added":
added = append(added, entry)
case "modified":
modified = append(modified, entry)
case "removed":
removed = append(removed, entry)
}
}
// Display added issues
if len(added) > 0 {
fmt.Printf("%s Added (%d):\n", ui.RenderAccent("+"), len(added))
for _, entry := range added {
if entry.NewValue != nil {
fmt.Printf(" + %s: %s\n",
ui.StatusOpenStyle.Render(entry.IssueID),
entry.NewValue.Title)
} else {
fmt.Printf(" + %s\n", ui.StatusOpenStyle.Render(entry.IssueID))
}
}
fmt.Println()
}
// Display modified issues
if len(modified) > 0 {
fmt.Printf("%s Modified (%d):\n", ui.RenderAccent("~"), len(modified))
for _, entry := range modified {
fmt.Printf(" ~ %s", ui.StatusInProgressStyle.Render(entry.IssueID))
if entry.OldValue != nil && entry.NewValue != nil {
// Show what changed
changes := []string{}
if entry.OldValue.Title != entry.NewValue.Title {
changes = append(changes, "title")
}
if entry.OldValue.Status != entry.NewValue.Status {
changes = append(changes, fmt.Sprintf("status: %s -> %s",
entry.OldValue.Status, entry.NewValue.Status))
}
if entry.OldValue.Priority != entry.NewValue.Priority {
changes = append(changes, fmt.Sprintf("priority: P%d -> P%d",
entry.OldValue.Priority, entry.NewValue.Priority))
}
if entry.OldValue.Description != entry.NewValue.Description {
changes = append(changes, "description")
}
if len(changes) > 0 {
fmt.Printf(" (%s)", ui.RenderMuted(joinStrings(changes, ", ")))
}
}
fmt.Println()
}
fmt.Println()
}
// Display removed issues
if len(removed) > 0 {
fmt.Printf("%s Removed (%d):\n", ui.RenderAccent("-"), len(removed))
for _, entry := range removed {
if entry.OldValue != nil {
fmt.Printf(" - %s: %s\n",
ui.RenderMuted(entry.IssueID),
ui.RenderMuted(entry.OldValue.Title))
} else {
fmt.Printf(" - %s\n", ui.RenderMuted(entry.IssueID))
}
}
fmt.Println()
}
},
}
// joinStrings joins strings with a separator (simple helper to avoid importing strings)
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
}
result := strs[0]
for i := 1; i < len(strs); i++ {
result += sep + strs[i]
}
return result
}
func init() {
rootCmd.AddCommand(diffCmd)
}

95
cmd/bd/history.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/ui"
)
var (
historyLimit int
)
var historyCmd = &cobra.Command{
Use: "history <id>",
GroupID: "views",
Short: "Show version history for an issue (requires Dolt backend)",
Long: `Show the complete version history of an issue, including all commits
where the issue was modified.
This command requires the Dolt storage backend. If you're using SQLite,
you'll see an error message suggesting to use Dolt for versioning features.
Examples:
bd history bd-123 # Show all history for issue bd-123
bd history bd-123 --limit 5 # Show last 5 changes`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
issueID := args[0]
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("history requires Dolt backend (current backend does not support versioning)")
}
// Get issue history
history, err := vs.History(ctx, issueID)
if err != nil {
FatalErrorRespectJSON("failed to get history: %v", err)
}
if len(history) == 0 {
fmt.Printf("No history found for issue %s\n", issueID)
return
}
// Apply limit if specified
if historyLimit > 0 && historyLimit < len(history) {
history = history[:historyLimit]
}
if jsonOutput {
outputJSON(history)
return
}
// Display history in human-readable format
fmt.Printf("\n%s History for %s (%d entries)\n\n",
ui.RenderAccent("📜"), issueID, len(history))
for i, entry := range history {
// Commit info line
fmt.Printf("%s %s\n",
ui.RenderMuted(entry.CommitHash[:8]),
ui.RenderMuted(entry.CommitDate.Format("2006-01-02 15:04:05")))
fmt.Printf(" Author: %s\n", entry.Committer)
if entry.Issue != nil {
// Show issue state at this commit
statusIcon := ui.GetStatusIcon(string(entry.Issue.Status))
fmt.Printf(" %s %s: %s [P%d - %s]\n",
statusIcon,
entry.Issue.ID,
entry.Issue.Title,
entry.Issue.Priority,
entry.Issue.Status)
}
// Separator between entries
if i < len(history)-1 {
fmt.Println()
}
}
fmt.Println()
},
}
func init() {
historyCmd.Flags().IntVar(&historyLimit, "limit", 0, "Limit number of history entries (0 = all)")
historyCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(historyCmd)
}

View File

@@ -25,8 +25,15 @@ var showCmd = &cobra.Command{
shortMode, _ := cmd.Flags().GetBool("short")
showRefs, _ := cmd.Flags().GetBool("refs")
showChildren, _ := cmd.Flags().GetBool("children")
asOfRef, _ := cmd.Flags().GetString("as-of")
ctx := rootCtx
// Handle --as-of flag: show issue at a specific point in history
if asOfRef != "" {
showIssueAsOf(ctx, args, asOfRef, shortMode)
return
}
// Check database freshness before reading
// Skip check when using daemon (daemon auto-imports on staleness)
if daemonClient == nil {
@@ -1039,11 +1046,62 @@ func containsStr(slice []string, val string) bool {
return false
}
// showIssueAsOf displays issues as they existed at a specific commit or branch ref.
// This requires a versioned storage backend (e.g., Dolt).
func showIssueAsOf(ctx context.Context, args []string, ref string, shortMode bool) {
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("--as-of requires Dolt backend (current backend does not support versioning)")
}
var allIssues []*types.Issue
for idx, id := range args {
issue, err := vs.AsOf(ctx, id, ref)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching %s as of %s: %v\n", id, ref, err)
continue
}
if issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s did not exist at %s\n", id, ref)
continue
}
if shortMode {
fmt.Println(formatShortIssue(issue))
continue
}
if jsonOutput {
allIssues = append(allIssues, issue)
continue
}
if idx > 0 {
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("-", 60)))
}
// Display header with ref indicator
fmt.Printf("\n%s (as of %s)\n", formatIssueHeader(issue), ui.RenderMuted(ref))
fmt.Println(formatIssueMetadata(issue))
if issue.Description != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
fmt.Println()
}
if jsonOutput && len(allIssues) > 0 {
outputJSON(allIssues)
}
}
func init() {
showCmd.Flags().Bool("thread", false, "Show full conversation thread (for messages)")
showCmd.Flags().Bool("short", false, "Show compact one-line output per issue")
showCmd.Flags().Bool("refs", false, "Show issues that reference this issue (reverse lookup)")
showCmd.Flags().Bool("children", false, "Show only the children of this issue")
showCmd.Flags().String("as-of", "", "Show issue as it existed at a specific commit hash or branch (requires Dolt)")
showCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(showCmd)
}

206
cmd/bd/vc.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/ui"
)
var vcCmd = &cobra.Command{
Use: "vc",
GroupID: "sync",
Short: "Version control operations (requires Dolt backend)",
Long: `Version control operations for the beads database.
These commands require the Dolt storage backend. They provide git-like
version control for your issue data, including branching, merging, and
viewing history.
Note: 'bd history', 'bd diff', and 'bd branch' also work for quick access.
This subcommand provides additional operations like merge and commit.`,
}
var vcMergeStrategy string
var vcMergeCmd = &cobra.Command{
Use: "merge <branch>",
Short: "Merge a branch into the current branch",
Long: `Merge the specified branch into the current branch.
If there are merge conflicts, they will be reported. You can resolve
conflicts with --strategy.
Examples:
bd vc merge feature-xyz # Merge feature-xyz into current branch
bd vc merge feature-xyz --strategy ours # Merge, preferring our changes on conflict
bd vc merge feature-xyz --strategy theirs # Merge, preferring their changes on conflict`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
branchName := args[0]
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("merge requires Dolt backend (current backend does not support versioning)")
}
// Perform merge
conflicts, err := vs.Merge(ctx, branchName)
if err != nil {
FatalErrorRespectJSON("failed to merge branch: %v", err)
}
// Handle conflicts
if len(conflicts) > 0 {
if vcMergeStrategy != "" {
// Auto-resolve conflicts with specified strategy
for _, conflict := range conflicts {
table := conflict.Field // Field contains table name from GetConflicts
if table == "" {
table = "issues" // Default to issues table
}
if err := vs.ResolveConflicts(ctx, table, vcMergeStrategy); err != nil {
FatalErrorRespectJSON("failed to resolve conflicts: %v", err)
}
}
if jsonOutput {
outputJSON(map[string]interface{}{
"merged": branchName,
"conflicts": len(conflicts),
"resolved_with": vcMergeStrategy,
})
return
}
fmt.Printf("Merged %s with %d conflicts resolved using '%s' strategy\n",
ui.RenderAccent(branchName), len(conflicts), vcMergeStrategy)
return
}
// Report conflicts without auto-resolution
if jsonOutput {
outputJSON(map[string]interface{}{
"merged": branchName,
"conflicts": conflicts,
})
return
}
fmt.Printf("\n%s Merge completed with conflicts:\n\n", ui.RenderAccent("!!"))
for _, conflict := range conflicts {
fmt.Printf(" - %s\n", conflict.Field)
}
fmt.Printf("\nResolve conflicts with: bd vc merge %s --strategy [ours|theirs]\n\n", branchName)
return
}
if jsonOutput {
outputJSON(map[string]interface{}{
"merged": branchName,
"conflicts": 0,
})
return
}
fmt.Printf("Successfully merged %s\n", ui.RenderAccent(branchName))
},
}
var vcCommitMessage string
var vcCommitCmd = &cobra.Command{
Use: "commit",
Short: "Create a commit with all staged changes",
Long: `Create a new Dolt commit with all current changes.
Examples:
bd vc commit -m "Added new feature issues"
bd vc commit --message "Fixed priority on several issues"`,
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
if vcCommitMessage == "" {
FatalErrorRespectJSON("commit message is required (use -m or --message)")
}
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("commit requires Dolt backend (current backend does not support versioning)")
}
if err := vs.Commit(ctx, vcCommitMessage); err != nil {
FatalErrorRespectJSON("failed to commit: %v", err)
}
// Get the new commit hash
hash, err := vs.GetCurrentCommit(ctx)
if err != nil {
hash = "(unknown)"
}
if jsonOutput {
outputJSON(map[string]interface{}{
"committed": true,
"hash": hash,
"message": vcCommitMessage,
})
return
}
fmt.Printf("Created commit %s\n", ui.RenderMuted(hash[:8]))
},
}
var vcStatusCmd = &cobra.Command{
Use: "status",
Short: "Show current branch and uncommitted changes",
Long: `Show the current branch, commit hash, and any uncommitted changes.
Examples:
bd vc status`,
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Check if storage supports versioning
vs, ok := storage.AsVersioned(store)
if !ok {
FatalErrorRespectJSON("status requires Dolt backend (current backend does not support versioning)")
}
currentBranch, err := vs.CurrentBranch(ctx)
if err != nil {
FatalErrorRespectJSON("failed to get current branch: %v", err)
}
currentCommit, err := vs.GetCurrentCommit(ctx)
if err != nil {
currentCommit = "(unknown)"
}
if jsonOutput {
outputJSON(map[string]interface{}{
"branch": currentBranch,
"commit": currentCommit,
})
return
}
fmt.Printf("\n%s Version Control Status\n\n", ui.RenderAccent("📊"))
fmt.Printf(" Branch: %s\n", ui.StatusInProgressStyle.Render(currentBranch))
fmt.Printf(" Commit: %s\n", ui.RenderMuted(currentCommit[:8]))
fmt.Println()
},
}
func init() {
vcMergeCmd.Flags().StringVar(&vcMergeStrategy, "strategy", "", "Conflict resolution strategy: 'ours' or 'theirs'")
vcCommitCmd.Flags().StringVarP(&vcCommitMessage, "message", "m", "", "Commit message")
vcCmd.AddCommand(vcMergeCmd)
vcCmd.AddCommand(vcCommitCmd)
vcCmd.AddCommand(vcStatusCmd)
rootCmd.AddCommand(vcCmd)
}