Files
beads/internal/merge/merge.go
Steve Yegge 52c505956f feat: Add bd merge command for git 3-way JSONL merging (bd-omx1)
- Implemented bd merge command with dual-mode operation:
  1. Git 3-way merge: bd merge <output> <base> <left> <right>
  2. Duplicate issue merge: bd merge <sources...> --into <target> (placeholder)
- Added MergeFiles wrapper to internal/merge package
- Command works without database when used as git merge driver
- Supports --debug flag for verbose output
- Exit code 0 for clean merge, 1 for conflicts
- Handles deletions intelligently (delete-modify conflicts)
- Added proper MIT license attribution for @neongreen's beads-merge code
- Tests pass for git merge functionality

This enables git merge driver setup for .beads/beads.jsonl files.
2025-11-05 19:16:50 -08:00

431 lines
12 KiB
Go

// Package merge implements 3-way merge for beads JSONL files.
//
// This code is vendored from https://github.com/neongreen/mono/tree/main/beads-merge
// Original author: Emily (@neongreen, https://github.com/neongreen)
//
// MIT License
// Copyright (c) 2025 Emily
// See ATTRIBUTION.md for full license text
//
// The merge algorithm provides field-level intelligent merging for beads issues:
// - Matches issues by identity (id + created_at + created_by)
// - Smart field merging with 3-way comparison
// - Dependency union with deduplication
// - Timestamp handling (max wins)
// - Deletion detection
package merge
import (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
"github.com/google/go-cmp/cmp"
"github.com/steveyegge/beads/internal/types"
)
// IssueKey uniquely identifies an issue for matching across merge branches
type IssueKey struct {
ID string
CreatedAt string
CreatedBy string
}
// issueWithRaw wraps an issue with its original JSONL line for conflict output
type issueWithRaw struct {
Issue *types.Issue
RawLine string
}
// ReadIssues reads issues from a JSONL file
func ReadIssues(path string) ([]*types.Issue, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
var issues []*types.Issue
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if line == "" {
continue
}
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
return nil, fmt.Errorf("failed to parse line %d: %w", lineNum, err)
}
issues = append(issues, &issue)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
return issues, nil
}
// makeKey creates an IssueKey from an issue for identity matching
func makeKey(issue *types.Issue) IssueKey {
// Use created_at for key (created_by not tracked in types.Issue currently)
return IssueKey{
ID: issue.ID,
CreatedAt: issue.CreatedAt.Format(time.RFC3339Nano),
CreatedBy: "", // Not currently tracked, rely on ID + timestamp
}
}
// Merge3Way performs a 3-way merge of issue lists
// Returns merged issues and conflict markers (if any)
func Merge3Way(base, left, right []*types.Issue) ([]*types.Issue, []string) {
// Convert to maps with raw lines preserved
baseMap := make(map[IssueKey]issueWithRaw)
for _, issue := range base {
raw, _ := json.Marshal(issue)
baseMap[makeKey(issue)] = issueWithRaw{issue, string(raw)}
}
leftMap := make(map[IssueKey]issueWithRaw)
for _, issue := range left {
raw, _ := json.Marshal(issue)
leftMap[makeKey(issue)] = issueWithRaw{issue, string(raw)}
}
rightMap := make(map[IssueKey]issueWithRaw)
for _, issue := range right {
raw, _ := json.Marshal(issue)
rightMap[makeKey(issue)] = issueWithRaw{issue, string(raw)}
}
// Track which issues we've processed
processed := make(map[IssueKey]bool)
var result []*types.Issue
var conflicts []string
// Process all unique keys
allKeys := make(map[IssueKey]bool)
for k := range baseMap {
allKeys[k] = true
}
for k := range leftMap {
allKeys[k] = true
}
for k := range rightMap {
allKeys[k] = true
}
for key := range allKeys {
if processed[key] {
continue
}
processed[key] = true
baseIssue, inBase := baseMap[key]
leftIssue, inLeft := leftMap[key]
rightIssue, inRight := rightMap[key]
// Handle different scenarios
if inBase && inLeft && inRight {
// All three present - merge
merged, conflict := mergeIssue(baseIssue, leftIssue, rightIssue)
if conflict != "" {
conflicts = append(conflicts, conflict)
} else {
result = append(result, merged)
}
} else if !inBase && inLeft && inRight {
// Added in both - check if identical
if issuesEqual(leftIssue.Issue, rightIssue.Issue) {
result = append(result, leftIssue.Issue)
} else {
conflicts = append(conflicts, makeConflict(leftIssue.RawLine, rightIssue.RawLine))
}
} else if inBase && inLeft && !inRight {
// Deleted in right, maybe modified in left
if issuesEqual(baseIssue.Issue, leftIssue.Issue) {
// Deleted in right, unchanged in left - accept deletion
continue
} else {
// Modified in left, deleted in right - conflict
conflicts = append(conflicts, makeConflictWithBase(baseIssue.RawLine, leftIssue.RawLine, ""))
}
} else if inBase && !inLeft && inRight {
// Deleted in left, maybe modified in right
if issuesEqual(baseIssue.Issue, rightIssue.Issue) {
// Deleted in left, unchanged in right - accept deletion
continue
} else {
// Modified in right, deleted in left - conflict
conflicts = append(conflicts, makeConflictWithBase(baseIssue.RawLine, "", rightIssue.RawLine))
}
} else if !inBase && inLeft && !inRight {
// Added only in left
result = append(result, leftIssue.Issue)
} else if !inBase && !inLeft && inRight {
// Added only in right
result = append(result, rightIssue.Issue)
}
}
return result, conflicts
}
func mergeIssue(base, left, right issueWithRaw) (*types.Issue, string) {
result := &types.Issue{
ID: base.Issue.ID,
CreatedAt: base.Issue.CreatedAt,
}
// Merge title
result.Title = mergeField(base.Issue.Title, left.Issue.Title, right.Issue.Title)
// Merge description
result.Description = mergeField(base.Issue.Description, left.Issue.Description, right.Issue.Description)
// Merge notes
result.Notes = mergeField(base.Issue.Notes, left.Issue.Notes, right.Issue.Notes)
// Merge design
result.Design = mergeField(base.Issue.Design, left.Issue.Design, right.Issue.Design)
// Merge acceptance criteria
result.AcceptanceCriteria = mergeField(base.Issue.AcceptanceCriteria, left.Issue.AcceptanceCriteria, right.Issue.AcceptanceCriteria)
// Merge status
result.Status = types.Status(mergeField(string(base.Issue.Status), string(left.Issue.Status), string(right.Issue.Status)))
// Merge priority
if base.Issue.Priority == left.Issue.Priority && base.Issue.Priority != right.Issue.Priority {
result.Priority = right.Issue.Priority
} else if base.Issue.Priority == right.Issue.Priority && base.Issue.Priority != left.Issue.Priority {
result.Priority = left.Issue.Priority
} else {
result.Priority = left.Issue.Priority
}
// Merge issue_type
result.IssueType = types.IssueType(mergeField(string(base.Issue.IssueType), string(left.Issue.IssueType), string(right.Issue.IssueType)))
// Merge updated_at - take the max
result.UpdatedAt = maxTime(left.Issue.UpdatedAt, right.Issue.UpdatedAt)
// Merge closed_at - take the max
if left.Issue.ClosedAt != nil && right.Issue.ClosedAt != nil {
max := maxTime(*left.Issue.ClosedAt, *right.Issue.ClosedAt)
result.ClosedAt = &max
} else if left.Issue.ClosedAt != nil {
result.ClosedAt = left.Issue.ClosedAt
} else if right.Issue.ClosedAt != nil {
result.ClosedAt = right.Issue.ClosedAt
}
// Merge dependencies - combine and deduplicate
result.Dependencies = mergeDependencies(left.Issue.Dependencies, right.Issue.Dependencies)
// Merge labels - combine and deduplicate
result.Labels = mergeLabels(left.Issue.Labels, right.Issue.Labels)
// Copy other fields from left (assignee, external_ref, source_repo)
result.Assignee = left.Issue.Assignee
result.ExternalRef = left.Issue.ExternalRef
result.SourceRepo = left.Issue.SourceRepo
// Check if we have a real conflict
if hasConflict(base.Issue, left.Issue, right.Issue) {
return result, makeConflictWithBase(base.RawLine, left.RawLine, right.RawLine)
}
return result, ""
}
func mergeField(base, left, right string) string {
if base == left && base != right {
return right
}
if base == right && base != left {
return left
}
// Both changed to same value or no change
return left
}
func maxTime(t1, t2 time.Time) time.Time {
if t1.After(t2) {
return t1
}
return t2
}
func mergeDependencies(left, right []*types.Dependency) []*types.Dependency {
seen := make(map[string]bool)
var result []*types.Dependency
for _, dep := range left {
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
if !seen[key] {
seen[key] = true
result = append(result, dep)
}
}
for _, dep := range right {
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
if !seen[key] {
seen[key] = true
result = append(result, dep)
}
}
return result
}
func mergeLabels(left, right []string) []string {
seen := make(map[string]bool)
var result []string
for _, label := range left {
if !seen[label] {
seen[label] = true
result = append(result, label)
}
}
for _, label := range right {
if !seen[label] {
seen[label] = true
result = append(result, label)
}
}
return result
}
func hasConflict(base, left, right *types.Issue) bool {
// Check if any field has conflicting changes (all three different)
if base.Title != left.Title && base.Title != right.Title && left.Title != right.Title {
return true
}
if base.Description != left.Description && base.Description != right.Description && left.Description != right.Description {
return true
}
if base.Notes != left.Notes && base.Notes != right.Notes && left.Notes != right.Notes {
return true
}
if base.Status != left.Status && base.Status != right.Status && left.Status != right.Status {
return true
}
if base.Priority != left.Priority && base.Priority != right.Priority && left.Priority != right.Priority {
return true
}
if base.IssueType != left.IssueType && base.IssueType != right.IssueType && left.IssueType != right.IssueType {
return true
}
return false
}
func issuesEqual(a, b *types.Issue) bool {
// Use go-cmp for deep equality comparison
return cmp.Equal(a, b)
}
func makeConflict(left, right string) string {
conflict := "<<<<<<< left\n"
if left != "" {
conflict += left + "\n"
}
conflict += "=======\n"
if right != "" {
conflict += right + "\n"
}
conflict += ">>>>>>> right\n"
return conflict
}
func makeConflictWithBase(base, left, right string) string {
conflict := "<<<<<<< left\n"
if left != "" {
conflict += left + "\n"
}
conflict += "||||||| base\n"
if base != "" {
conflict += base + "\n"
}
conflict += "=======\n"
if right != "" {
conflict += right + "\n"
}
conflict += ">>>>>>> right\n"
return conflict
}
// MergeFiles performs 3-way merge on JSONL files and writes result to output
// Returns true if conflicts were found, false if merge was clean
func MergeFiles(outputPath, basePath, leftPath, rightPath string, debug bool) (bool, error) {
// Read all input files
baseIssues, err := ReadIssues(basePath)
if err != nil {
return false, fmt.Errorf("failed to read base file: %w", err)
}
leftIssues, err := ReadIssues(leftPath)
if err != nil {
return false, fmt.Errorf("failed to read left file: %w", err)
}
rightIssues, err := ReadIssues(rightPath)
if err != nil {
return false, fmt.Errorf("failed to read right file: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "Base issues: %d\n", len(baseIssues))
fmt.Fprintf(os.Stderr, "Left issues: %d\n", len(leftIssues))
fmt.Fprintf(os.Stderr, "Right issues: %d\n", len(rightIssues))
}
// Perform 3-way merge
merged, conflicts := Merge3Way(baseIssues, leftIssues, rightIssues)
if debug {
fmt.Fprintf(os.Stderr, "Merged issues: %d\n", len(merged))
fmt.Fprintf(os.Stderr, "Conflicts: %d\n", len(conflicts))
}
// Write output file
outFile, err := os.Create(outputPath)
if err != nil {
return false, fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
// Write merged issues
for _, issue := range merged {
data, err := json.Marshal(issue)
if err != nil {
return false, fmt.Errorf("failed to marshal issue: %w", err)
}
if _, err := outFile.Write(data); err != nil {
return false, fmt.Errorf("failed to write issue: %w", err)
}
if _, err := outFile.WriteString("\n"); err != nil {
return false, fmt.Errorf("failed to write newline: %w", err)
}
}
// Write conflict markers if any
for _, conflict := range conflicts {
if _, err := outFile.WriteString(conflict); err != nil {
return false, fmt.Errorf("failed to write conflict: %w", err)
}
}
hasConflicts := len(conflicts) > 0
return hasConflicts, nil
}