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

View File

@@ -288,8 +288,9 @@ type IssueDiff struct {
ToDescription string
}
// GetConflicts returns any merge conflicts in the current state
func (s *DoltStore) GetConflicts(ctx context.Context) ([]*Conflict, error) {
// GetInternalConflicts returns any merge conflicts in the current state (internal format).
// For the public interface, use GetConflicts which returns storage.Conflict.
func (s *DoltStore) GetInternalConflicts(ctx context.Context) ([]*TableConflict, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT table_name, num_conflicts FROM dolt_conflicts
`)
@@ -298,9 +299,9 @@ func (s *DoltStore) GetConflicts(ctx context.Context) ([]*Conflict, error) {
}
defer rows.Close()
var conflicts []*Conflict
var conflicts []*TableConflict
for rows.Next() {
var c Conflict
var c TableConflict
if err := rows.Scan(&c.TableName, &c.NumConflicts); err != nil {
return nil, fmt.Errorf("failed to scan conflict: %w", err)
}
@@ -310,8 +311,8 @@ func (s *DoltStore) GetConflicts(ctx context.Context) ([]*Conflict, error) {
return conflicts, rows.Err()
}
// Conflict represents a merge conflict
type Conflict struct {
// TableConflict represents a Dolt table-level merge conflict (internal representation).
type TableConflict struct {
TableName string
NumConflicts int
}

View File

@@ -28,6 +28,8 @@ import (
// Import Dolt driver
_ "github.com/dolthub/driver"
"github.com/steveyegge/beads/internal/storage"
)
// DoltStore implements the Storage interface using Dolt
@@ -342,13 +344,19 @@ func (s *DoltStore) Checkout(ctx context.Context, branch string) error {
return nil
}
// Merge merges the specified branch into the current branch
func (s *DoltStore) Merge(ctx context.Context, branch string) error {
// Merge merges the specified branch into the current branch.
// Returns any merge conflicts if present. Implements storage.VersionedStorage.
func (s *DoltStore) Merge(ctx context.Context, branch string) ([]storage.Conflict, error) {
_, err := s.db.ExecContext(ctx, "CALL DOLT_MERGE(?)", branch)
if err != nil {
return fmt.Errorf("failed to merge branch %s: %w", branch, err)
// Check if the error is due to conflicts
conflicts, conflictErr := s.GetConflicts(ctx)
if conflictErr == nil && len(conflicts) > 0 {
return conflicts, nil
}
return nil
return nil, fmt.Errorf("failed to merge branch %s: %w", branch, err)
}
return nil, nil
}
// CurrentBranch returns the current branch name

View File

@@ -0,0 +1,188 @@
package dolt
import (
"context"
"fmt"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
)
// Ensure DoltStore implements VersionedStorage at compile time.
var _ storage.VersionedStorage = (*DoltStore)(nil)
// History returns the complete version history for an issue.
// Implements storage.VersionedStorage.
func (s *DoltStore) History(ctx context.Context, issueID string) ([]*storage.HistoryEntry, error) {
internal, err := s.GetIssueHistory(ctx, issueID)
if err != nil {
return nil, err
}
// Convert internal representation to interface type
entries := make([]*storage.HistoryEntry, len(internal))
for i, h := range internal {
entries[i] = &storage.HistoryEntry{
CommitHash: h.CommitHash,
Committer: h.Committer,
CommitDate: h.CommitDate,
Issue: h.Issue,
}
}
return entries, nil
}
// AsOf returns the state of an issue at a specific commit hash or branch ref.
// Implements storage.VersionedStorage.
func (s *DoltStore) AsOf(ctx context.Context, issueID string, ref string) (*types.Issue, error) {
return s.GetIssueAsOf(ctx, issueID, ref)
}
// Diff returns changes between two commits/branches.
// Implements storage.VersionedStorage.
func (s *DoltStore) Diff(ctx context.Context, fromRef, toRef string) ([]*storage.DiffEntry, error) {
// Validate refs to prevent SQL injection
if err := validateRef(fromRef); err != nil {
return nil, fmt.Errorf("invalid fromRef: %w", err)
}
if err := validateRef(toRef); err != nil {
return nil, fmt.Errorf("invalid toRef: %w", err)
}
// Query issue-level diffs directly
// Note: refs are validated above
// nolint:gosec // G201: refs validated by validateRef()
query := fmt.Sprintf(`
SELECT
COALESCE(from_id, '') as from_id,
COALESCE(to_id, '') as to_id,
diff_type,
from_title, to_title,
from_description, to_description,
from_status, to_status,
from_priority, to_priority
FROM dolt_diff_issues('%s', '%s')
`, fromRef, toRef)
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to get diff: %w", err)
}
defer rows.Close()
var entries []*storage.DiffEntry
for rows.Next() {
var fromID, toID, diffType string
var fromTitle, toTitle, fromDesc, toDesc, fromStatus, toStatus *string
var fromPriority, toPriority *int
if err := rows.Scan(&fromID, &toID, &diffType,
&fromTitle, &toTitle,
&fromDesc, &toDesc,
&fromStatus, &toStatus,
&fromPriority, &toPriority); err != nil {
return nil, fmt.Errorf("failed to scan diff: %w", err)
}
entry := &storage.DiffEntry{
DiffType: diffType,
}
// Determine issue ID (use to_id for added, from_id for removed, either for modified)
if toID != "" {
entry.IssueID = toID
} else {
entry.IssueID = fromID
}
// Build old value for modified/removed
if diffType != "added" && fromID != "" {
entry.OldValue = &types.Issue{
ID: fromID,
}
if fromTitle != nil {
entry.OldValue.Title = *fromTitle
}
if fromDesc != nil {
entry.OldValue.Description = *fromDesc
}
if fromStatus != nil {
entry.OldValue.Status = types.Status(*fromStatus)
}
if fromPriority != nil {
entry.OldValue.Priority = *fromPriority
}
}
// Build new value for modified/added
if diffType != "removed" && toID != "" {
entry.NewValue = &types.Issue{
ID: toID,
}
if toTitle != nil {
entry.NewValue.Title = *toTitle
}
if toDesc != nil {
entry.NewValue.Description = *toDesc
}
if toStatus != nil {
entry.NewValue.Status = types.Status(*toStatus)
}
if toPriority != nil {
entry.NewValue.Priority = *toPriority
}
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
// ListBranches returns the names of all branches.
// Implements storage.VersionedStorage.
func (s *DoltStore) ListBranches(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT name FROM dolt_branches ORDER BY name")
if err != nil {
return nil, fmt.Errorf("failed to list branches: %w", err)
}
defer rows.Close()
var branches []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("failed to scan branch: %w", err)
}
branches = append(branches, name)
}
return branches, rows.Err()
}
// GetCurrentCommit returns the hash of the current HEAD commit.
// Implements storage.VersionedStorage.
func (s *DoltStore) GetCurrentCommit(ctx context.Context) (string, error) {
var hash string
err := s.db.QueryRowContext(ctx, "SELECT DOLT_HASHOF('HEAD')").Scan(&hash)
if err != nil {
return "", fmt.Errorf("failed to get current commit: %w", err)
}
return hash, nil
}
// GetConflicts returns any merge conflicts in the current state.
// Implements storage.VersionedStorage.
func (s *DoltStore) GetConflicts(ctx context.Context) ([]storage.Conflict, error) {
internal, err := s.GetInternalConflicts(ctx)
if err != nil {
return nil, err
}
conflicts := make([]storage.Conflict, 0, len(internal))
for _, c := range internal {
conflicts = append(conflicts, storage.Conflict{
Field: c.TableName,
})
}
return conflicts, nil
}

View File

@@ -0,0 +1,24 @@
package dolt
import (
"testing"
"github.com/steveyegge/beads/internal/storage"
)
// TestDoltStoreImplementsVersionedStorage verifies DoltStore implements VersionedStorage.
// This is a compile-time check.
func TestDoltStoreImplementsVersionedStorage(t *testing.T) {
// The var _ declaration in versioned.go already ensures this at compile time.
// This test just documents the expectation.
var _ storage.VersionedStorage = (*DoltStore)(nil)
}
// TestVersionedStorageMethodsExist ensures all required methods are defined.
// This is mostly a documentation test since Go's type system enforces this.
func TestVersionedStorageMethodsExist(t *testing.T) {
// If DoltStore doesn't implement all VersionedStorage methods,
// this file won't compile. This test exists for documentation.
t.Log("DoltStore implements all VersionedStorage methods")
}

View File

@@ -0,0 +1,119 @@
// Package storage defines the interface for issue storage backends.
package storage
import (
"context"
"time"
"github.com/steveyegge/beads/internal/types"
)
// VersionedStorage extends Storage with version control capabilities.
// This interface is implemented by storage backends that support history,
// branching, and merging (e.g., Dolt).
//
// Not all storage backends support versioning. Use IsVersioned() to check
// if a storage instance supports these operations before calling them.
type VersionedStorage interface {
Storage // Embed base interface
// History queries
// History returns the complete version history for an issue.
// Results are ordered by commit date, most recent first.
History(ctx context.Context, issueID string) ([]*HistoryEntry, error)
// AsOf returns the state of an issue at a specific commit hash or branch ref.
// Returns nil if the issue didn't exist at that point in time.
AsOf(ctx context.Context, issueID string, ref string) (*types.Issue, error)
// Diff returns changes between two commits/branches.
// Shows which issues were added, modified, or removed.
Diff(ctx context.Context, fromRef, toRef string) ([]*DiffEntry, error)
// Branch operations
// Branch creates a new branch from the current state.
Branch(ctx context.Context, name string) error
// Merge merges the specified branch into the current branch.
// Returns a list of conflicts if any exist.
Merge(ctx context.Context, branch string) ([]Conflict, error)
// CurrentBranch returns the name of the currently active branch.
CurrentBranch(ctx context.Context) (string, error)
// ListBranches returns the names of all branches.
ListBranches(ctx context.Context) ([]string, error)
// Commit operations
// Commit creates a new commit with all staged changes.
Commit(ctx context.Context, message string) error
// GetCurrentCommit returns the hash of the current HEAD commit.
GetCurrentCommit(ctx context.Context) (string, error)
// Conflict resolution
// GetConflicts returns any merge conflicts in the current state.
GetConflicts(ctx context.Context) ([]Conflict, error)
// ResolveConflicts resolves conflicts using the specified strategy.
// Strategy must be "ours" or "theirs".
ResolveConflicts(ctx context.Context, table string, strategy string) error
}
// HistoryEntry represents an issue at a specific point in history.
type HistoryEntry struct {
CommitHash string // The commit hash at this point
Committer string // Who made the commit
CommitDate time.Time // When the commit was made
Issue *types.Issue // The issue state at that commit
}
// DiffEntry represents a change between two commits.
type DiffEntry struct {
IssueID string // The ID of the affected issue
DiffType string // "added", "modified", or "removed"
OldValue *types.Issue // State before (nil for "added")
NewValue *types.Issue // State after (nil for "removed")
}
// Conflict represents a merge conflict.
type Conflict struct {
IssueID string // The ID of the conflicting issue
Field string // Which field has the conflict (empty for table-level)
OursValue interface{} // Value on current branch
TheirsValue interface{} // Value on merged branch
}
// IsVersioned checks if a storage instance supports version control operations.
// Returns true if the storage implements VersionedStorage.
//
// Example usage:
//
// if !storage.IsVersioned(store) {
// return fmt.Errorf("history requires Dolt backend")
// }
// vs := store.(storage.VersionedStorage)
// history, err := vs.History(ctx, issueID)
func IsVersioned(s Storage) bool {
_, ok := s.(VersionedStorage)
return ok
}
// AsVersioned attempts to cast a Storage to VersionedStorage.
// Returns the VersionedStorage and true if successful, nil and false otherwise.
//
// Example usage:
//
// vs, ok := storage.AsVersioned(store)
// if !ok {
// return fmt.Errorf("history requires Dolt backend")
// }
// history, err := vs.History(ctx, issueID)
func AsVersioned(s Storage) (VersionedStorage, bool) {
vs, ok := s.(VersionedStorage)
return vs, ok
}

View File

@@ -0,0 +1,86 @@
package storage_test
import (
"testing"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/memory"
)
// TestIsVersioned verifies the IsVersioned type detection helper.
func TestIsVersioned(t *testing.T) {
// Memory storage is NOT versioned
memStore := memory.New("")
if storage.IsVersioned(memStore) {
t.Error("IsVersioned should return false for memory storage")
}
// Test AsVersioned returns false for non-versioned storage
vs, ok := storage.AsVersioned(memStore)
if ok {
t.Error("AsVersioned should return false for memory storage")
}
if vs != nil {
t.Error("AsVersioned should return nil for memory storage")
}
}
// TestVersionedStorageInterface ensures the interface is correctly defined.
func TestVersionedStorageInterface(t *testing.T) {
// This test verifies that the interface types exist and have the expected methods.
// Actual implementation testing would be done in the dolt package.
// HistoryEntry should have the expected fields
entry := storage.HistoryEntry{
CommitHash: "abc123",
Committer: "test",
}
if entry.CommitHash != "abc123" {
t.Error("HistoryEntry.CommitHash not working")
}
// DiffEntry should have the expected fields
diff := storage.DiffEntry{
IssueID: "bd-123",
DiffType: "modified",
}
if diff.IssueID != "bd-123" {
t.Error("DiffEntry.IssueID not working")
}
if diff.DiffType != "modified" {
t.Error("DiffEntry.DiffType not working")
}
// Conflict should have the expected fields
conflict := storage.Conflict{
IssueID: "bd-456",
Field: "title",
}
if conflict.IssueID != "bd-456" {
t.Error("Conflict.IssueID not working")
}
if conflict.Field != "title" {
t.Error("Conflict.Field not working")
}
}
// TestVersionedStorageTypes verifies type values work as expected.
func TestVersionedStorageTypes(t *testing.T) {
// Test DiffEntry types
testCases := []struct {
diffType string
valid bool
}{
{"added", true},
{"modified", true},
{"removed", true},
}
for _, tc := range testCases {
entry := storage.DiffEntry{DiffType: tc.diffType}
if entry.DiffType != tc.diffType {
t.Errorf("DiffEntry.DiffType mismatch: expected %s, got %s", tc.diffType, entry.DiffType)
}
}
}