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>
344 lines
9.3 KiB
Go
344 lines
9.3 KiB
Go
package dolt
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// validRefPattern matches valid Dolt commit hashes (32 hex chars) or branch names
|
|
var validRefPattern = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
|
|
|
|
// validTablePattern matches valid table names
|
|
var validTablePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
|
|
|
// validateRef checks if a ref is safe to use in queries
|
|
func validateRef(ref string) error {
|
|
if ref == "" {
|
|
return fmt.Errorf("ref cannot be empty")
|
|
}
|
|
if len(ref) > 128 {
|
|
return fmt.Errorf("ref too long")
|
|
}
|
|
if !validRefPattern.MatchString(ref) {
|
|
return fmt.Errorf("invalid ref format: %s", ref)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateTableName checks if a table name is safe to use in queries
|
|
func validateTableName(table string) error {
|
|
if table == "" {
|
|
return fmt.Errorf("table name cannot be empty")
|
|
}
|
|
if len(table) > 64 {
|
|
return fmt.Errorf("table name too long")
|
|
}
|
|
if !validTablePattern.MatchString(table) {
|
|
return fmt.Errorf("invalid table name: %s", table)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IssueHistory represents an issue at a specific point in history
|
|
type IssueHistory struct {
|
|
Issue *types.Issue
|
|
CommitHash string
|
|
Committer string
|
|
CommitDate time.Time
|
|
}
|
|
|
|
// GetIssueHistory returns the complete history of an issue
|
|
func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*IssueHistory, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT
|
|
id, title, description, design, acceptance_criteria, notes,
|
|
status, priority, issue_type, assignee, owner, created_by,
|
|
estimated_minutes, created_at, updated_at, closed_at, close_reason,
|
|
pinned, mol_type,
|
|
commit_hash, committer, commit_date
|
|
FROM dolt_history_issues
|
|
WHERE id = ?
|
|
ORDER BY commit_date DESC
|
|
`, issueID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issue history: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var history []*IssueHistory
|
|
for rows.Next() {
|
|
var h IssueHistory
|
|
var issue types.Issue
|
|
var closedAt sql.NullTime
|
|
var assignee, owner, createdBy, closeReason, molType sql.NullString
|
|
var estimatedMinutes sql.NullInt64
|
|
var pinned sql.NullInt64
|
|
|
|
if err := rows.Scan(
|
|
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes,
|
|
&issue.Status, &issue.Priority, &issue.IssueType, &assignee, &owner, &createdBy,
|
|
&estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &closeReason,
|
|
&pinned, &molType,
|
|
&h.CommitHash, &h.Committer, &h.CommitDate,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to scan history: %w", err)
|
|
}
|
|
|
|
if closedAt.Valid {
|
|
issue.ClosedAt = &closedAt.Time
|
|
}
|
|
if assignee.Valid {
|
|
issue.Assignee = assignee.String
|
|
}
|
|
if owner.Valid {
|
|
issue.Owner = owner.String
|
|
}
|
|
if createdBy.Valid {
|
|
issue.CreatedBy = createdBy.String
|
|
}
|
|
if estimatedMinutes.Valid {
|
|
mins := int(estimatedMinutes.Int64)
|
|
issue.EstimatedMinutes = &mins
|
|
}
|
|
if closeReason.Valid {
|
|
issue.CloseReason = closeReason.String
|
|
}
|
|
if pinned.Valid && pinned.Int64 != 0 {
|
|
issue.Pinned = true
|
|
}
|
|
if molType.Valid {
|
|
issue.MolType = types.MolType(molType.String)
|
|
}
|
|
|
|
h.Issue = &issue
|
|
history = append(history, &h)
|
|
}
|
|
|
|
return history, rows.Err()
|
|
}
|
|
|
|
// GetIssueAsOf returns an issue as it existed at a specific commit or time
|
|
func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string) (*types.Issue, error) {
|
|
// Validate ref to prevent SQL injection
|
|
if err := validateRef(ref); err != nil {
|
|
return nil, fmt.Errorf("invalid ref: %w", err)
|
|
}
|
|
|
|
var issue types.Issue
|
|
var closedAt sql.NullTime
|
|
var assignee, owner, contentHash sql.NullString
|
|
var estimatedMinutes sql.NullInt64
|
|
|
|
// nolint:gosec // G201: ref is validated by validateRef() above - AS OF requires literal
|
|
query := fmt.Sprintf(`
|
|
SELECT id, content_hash, title, description, status, priority, issue_type, assignee, estimated_minutes,
|
|
created_at, created_by, owner, updated_at, closed_at
|
|
FROM issues AS OF '%s'
|
|
WHERE id = ?
|
|
`, ref)
|
|
|
|
err := s.db.QueryRowContext(ctx, query, issueID).Scan(
|
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
|
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt,
|
|
)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issue as of %s: %w", ref, err)
|
|
}
|
|
|
|
if contentHash.Valid {
|
|
issue.ContentHash = contentHash.String
|
|
}
|
|
if closedAt.Valid {
|
|
issue.ClosedAt = &closedAt.Time
|
|
}
|
|
if assignee.Valid {
|
|
issue.Assignee = assignee.String
|
|
}
|
|
if owner.Valid {
|
|
issue.Owner = owner.String
|
|
}
|
|
if estimatedMinutes.Valid {
|
|
mins := int(estimatedMinutes.Int64)
|
|
issue.EstimatedMinutes = &mins
|
|
}
|
|
|
|
return &issue, nil
|
|
}
|
|
|
|
// DiffEntry represents a change between two commits
|
|
type DiffEntry struct {
|
|
TableName string
|
|
DiffType string // "added", "modified", "removed"
|
|
FromCommit string
|
|
ToCommit string
|
|
RowID string
|
|
}
|
|
|
|
// GetDiff returns changes between two commits
|
|
func (s *DoltStore) GetDiff(ctx context.Context, fromRef, toRef string) ([]*DiffEntry, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT table_name, diff_type, from_commit, to_commit
|
|
FROM dolt_diff(?, ?)
|
|
`, fromRef, toRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get diff: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []*DiffEntry
|
|
for rows.Next() {
|
|
var e DiffEntry
|
|
if err := rows.Scan(&e.TableName, &e.DiffType, &e.FromCommit, &e.ToCommit); err != nil {
|
|
return nil, fmt.Errorf("failed to scan diff entry: %w", err)
|
|
}
|
|
entries = append(entries, &e)
|
|
}
|
|
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
// GetIssueDiff returns detailed changes to a specific issue between commits
|
|
func (s *DoltStore) GetIssueDiff(ctx context.Context, issueID, fromRef, toRef string) (*IssueDiff, 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)
|
|
}
|
|
|
|
// nolint:gosec // G201: refs are validated by validateRef() above - dolt_diff_issues requires literal
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
from_id, to_id,
|
|
from_title, to_title,
|
|
from_status, to_status,
|
|
from_description, to_description,
|
|
diff_type
|
|
FROM dolt_diff_issues('%s', '%s')
|
|
WHERE from_id = ? OR to_id = ?
|
|
`, fromRef, toRef)
|
|
|
|
var diff IssueDiff
|
|
var fromID, toID, fromTitle, toTitle, fromStatus, toStatus sql.NullString
|
|
var fromDesc, toDesc sql.NullString
|
|
|
|
err := s.db.QueryRowContext(ctx, query, issueID, issueID).Scan(
|
|
&fromID, &toID,
|
|
&fromTitle, &toTitle,
|
|
&fromStatus, &toStatus,
|
|
&fromDesc, &toDesc,
|
|
&diff.DiffType,
|
|
)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issue diff: %w", err)
|
|
}
|
|
|
|
if fromID.Valid {
|
|
diff.FromID = fromID.String
|
|
}
|
|
if toID.Valid {
|
|
diff.ToID = toID.String
|
|
}
|
|
if fromTitle.Valid {
|
|
diff.FromTitle = fromTitle.String
|
|
}
|
|
if toTitle.Valid {
|
|
diff.ToTitle = toTitle.String
|
|
}
|
|
if fromStatus.Valid {
|
|
diff.FromStatus = fromStatus.String
|
|
}
|
|
if toStatus.Valid {
|
|
diff.ToStatus = toStatus.String
|
|
}
|
|
if fromDesc.Valid {
|
|
diff.FromDescription = fromDesc.String
|
|
}
|
|
if toDesc.Valid {
|
|
diff.ToDescription = toDesc.String
|
|
}
|
|
|
|
return &diff, nil
|
|
}
|
|
|
|
// IssueDiff represents changes to an issue between two commits
|
|
type IssueDiff struct {
|
|
DiffType string // "added", "modified", "removed"
|
|
FromID string
|
|
ToID string
|
|
FromTitle string
|
|
ToTitle string
|
|
FromStatus string
|
|
ToStatus string
|
|
FromDescription string
|
|
ToDescription string
|
|
}
|
|
|
|
// 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
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get conflicts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var conflicts []*TableConflict
|
|
for rows.Next() {
|
|
var c TableConflict
|
|
if err := rows.Scan(&c.TableName, &c.NumConflicts); err != nil {
|
|
return nil, fmt.Errorf("failed to scan conflict: %w", err)
|
|
}
|
|
conflicts = append(conflicts, &c)
|
|
}
|
|
|
|
return conflicts, rows.Err()
|
|
}
|
|
|
|
// TableConflict represents a Dolt table-level merge conflict (internal representation).
|
|
type TableConflict struct {
|
|
TableName string
|
|
NumConflicts int
|
|
}
|
|
|
|
// ResolveConflicts resolves conflicts using the specified strategy
|
|
func (s *DoltStore) ResolveConflicts(ctx context.Context, table string, strategy string) error {
|
|
// Validate table name to prevent SQL injection
|
|
if err := validateTableName(table); err != nil {
|
|
return fmt.Errorf("invalid table name: %w", err)
|
|
}
|
|
|
|
var query string
|
|
switch strategy {
|
|
case "ours":
|
|
// Note: DOLT_CONFLICTS_RESOLVE requires literal value, but we've validated table is safe
|
|
query = fmt.Sprintf("CALL DOLT_CONFLICTS_RESOLVE('--ours', '%s')", table)
|
|
case "theirs":
|
|
query = fmt.Sprintf("CALL DOLT_CONFLICTS_RESOLVE('--theirs', '%s')", table)
|
|
default:
|
|
return fmt.Errorf("unknown conflict resolution strategy: %s", strategy)
|
|
}
|
|
|
|
_, err := s.db.ExecContext(ctx, query)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve conflicts: %w", err)
|
|
}
|
|
return nil
|
|
}
|