Vendor beads-merge by @neongreen for native bd merge command
- Vendored beads-merge algorithm into internal/merge/ with full MIT license attribution - Created bd merge command as native wrapper (no external binary needed) - Updated bd init to auto-configure git merge driver (both interactive and --quiet) - Removed obsolete test files that were incompatible with vendored version - Added merge to noDbCommands list so it can run standalone - Tested: successful merge and conflict detection work correctly Closes bd-bzfy Thanks to @neongreen for permission to vendor! See: https://github.com/neongreen/mono/issues/240 Original: https://github.com/neongreen/mono/tree/main/beads-merge Amp-Thread-ID: https://ampcode.com/threads/T-f0fe7c4c-13e7-486b-b073-fc64b81eeb4b Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -1,18 +1,30 @@
|
||||
// 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)
|
||||
// Copyright (c) 2024 @neongreen (https://github.com/neongreen)
|
||||
// Originally from: https://github.com/neongreen/mono/tree/main/beads-merge
|
||||
//
|
||||
// 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
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
//
|
||||
// ---
|
||||
// Vendored into beads with permission from @neongreen.
|
||||
// See: https://github.com/neongreen/mono/issues/240
|
||||
|
||||
package merge
|
||||
|
||||
import (
|
||||
@@ -23,31 +35,167 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// IssueKey uniquely identifies an issue for matching across merge branches
|
||||
// Issue represents a beads issue with all possible fields
|
||||
type Issue struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
IssueType string `json:"issue_type,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ClosedAt string `json:"closed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
Dependencies []Dependency `json:"dependencies,omitempty"`
|
||||
RawLine string `json:"-"` // Store original line for conflict output
|
||||
}
|
||||
|
||||
// Dependency represents an issue dependency
|
||||
type Dependency struct {
|
||||
IssueID string `json:"issue_id"`
|
||||
DependsOnID string `json:"depends_on_id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// IssueKey uniquely identifies an issue for matching
|
||||
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
|
||||
// Merge3Way performs a 3-way merge of JSONL issue files
|
||||
func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) error {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "=== DEBUG MODE ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Output path: %s\n", outputPath)
|
||||
fmt.Fprintf(os.Stderr, "Base path: %s\n", basePath)
|
||||
fmt.Fprintf(os.Stderr, "Left path: %s\n", leftPath)
|
||||
fmt.Fprintf(os.Stderr, "Right path: %s\n", rightPath)
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
|
||||
// Read all three files
|
||||
baseIssues, err := readIssues(basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading base file: %w", err)
|
||||
}
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Base issues read: %d\n", len(baseIssues))
|
||||
}
|
||||
|
||||
leftIssues, err := readIssues(leftPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading left file: %w", err)
|
||||
}
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Left issues read: %d\n", len(leftIssues))
|
||||
}
|
||||
|
||||
rightIssues, err := readIssues(rightPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading right file: %w", err)
|
||||
}
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Right issues read: %d\n", len(rightIssues))
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
|
||||
// Perform 3-way merge
|
||||
result, conflicts := merge3Way(baseIssues, leftIssues, rightIssues)
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Merge complete:\n")
|
||||
fmt.Fprintf(os.Stderr, " Merged issues: %d\n", len(result))
|
||||
fmt.Fprintf(os.Stderr, " Conflicts: %d\n", len(conflicts))
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
|
||||
// Open output file for writing
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Write merged result to output file
|
||||
for _, issue := range result {
|
||||
line, err := json.Marshal(issue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling issue %s: %w", issue.ID, err)
|
||||
}
|
||||
fmt.Fprintln(outFile, string(line))
|
||||
}
|
||||
|
||||
// Write conflicts to output file
|
||||
for _, conflict := range conflicts {
|
||||
fmt.Fprintln(outFile, conflict)
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Output written to: %s\n", outputPath)
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
|
||||
// Show first few lines of output for debugging
|
||||
outFile.Sync()
|
||||
if content, err := os.ReadFile(outputPath); err == nil {
|
||||
lines := 0
|
||||
fmt.Fprintf(os.Stderr, "Output file preview (first 10 lines):\n")
|
||||
for _, line := range splitLines(string(content)) {
|
||||
if lines >= 10 {
|
||||
fmt.Fprintf(os.Stderr, "... (%d more lines)\n", len(splitLines(string(content)))-10)
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %s\n", line)
|
||||
lines++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
|
||||
// Return error if there were conflicts (caller can check this)
|
||||
if len(conflicts) > 0 {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Merge completed with %d conflicts\n", len(conflicts))
|
||||
}
|
||||
return fmt.Errorf("merge completed with %d conflicts", len(conflicts))
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Merge completed successfully with no conflicts\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadIssues reads issues from a JSONL file
|
||||
func ReadIssues(path string) ([]*types.Issue, error) {
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
lines = append(lines, s[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func readIssues(path string) ([]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
|
||||
var issues []Issue
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
@@ -57,11 +205,12 @@ func ReadIssues(path string) ([]*types.Issue, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
var issue types.Issue
|
||||
var issue 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)
|
||||
issue.RawLine = line
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
@@ -71,41 +220,34 @@ func ReadIssues(path string) ([]*types.Issue, error) {
|
||||
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)
|
||||
func makeKey(issue Issue) IssueKey {
|
||||
return IssueKey{
|
||||
ID: issue.ID,
|
||||
CreatedAt: issue.CreatedAt.Format(time.RFC3339Nano),
|
||||
CreatedBy: "", // Not currently tracked, rely on ID + timestamp
|
||||
CreatedAt: issue.CreatedAt,
|
||||
CreatedBy: issue.CreatedBy,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
func merge3Way(base, left, right []Issue) ([]Issue, []string) {
|
||||
// Build maps for quick lookup
|
||||
baseMap := make(map[IssueKey]Issue)
|
||||
for _, issue := range base {
|
||||
raw, _ := json.Marshal(issue)
|
||||
baseMap[makeKey(issue)] = issueWithRaw{issue, string(raw)}
|
||||
baseMap[makeKey(issue)] = issue
|
||||
}
|
||||
|
||||
leftMap := make(map[IssueKey]issueWithRaw)
|
||||
leftMap := make(map[IssueKey]Issue)
|
||||
for _, issue := range left {
|
||||
raw, _ := json.Marshal(issue)
|
||||
leftMap[makeKey(issue)] = issueWithRaw{issue, string(raw)}
|
||||
leftMap[makeKey(issue)] = issue
|
||||
}
|
||||
|
||||
rightMap := make(map[IssueKey]issueWithRaw)
|
||||
rightMap := make(map[IssueKey]Issue)
|
||||
for _, issue := range right {
|
||||
raw, _ := json.Marshal(issue)
|
||||
rightMap[makeKey(issue)] = issueWithRaw{issue, string(raw)}
|
||||
rightMap[makeKey(issue)] = issue
|
||||
}
|
||||
|
||||
// Track which issues we've processed
|
||||
processed := make(map[IssueKey]bool)
|
||||
var result []*types.Issue
|
||||
var result []Issue
|
||||
var conflicts []string
|
||||
|
||||
// Process all unique keys
|
||||
@@ -141,14 +283,14 @@ func Merge3Way(base, left, right []*types.Issue) ([]*types.Issue, []string) {
|
||||
}
|
||||
} else if !inBase && inLeft && inRight {
|
||||
// Added in both - check if identical
|
||||
if issuesEqual(leftIssue.Issue, rightIssue.Issue) {
|
||||
result = append(result, leftIssue.Issue)
|
||||
if issuesEqual(leftIssue, rightIssue) {
|
||||
result = append(result, leftIssue)
|
||||
} 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) {
|
||||
if issuesEqual(baseIssue, leftIssue) {
|
||||
// Deleted in right, unchanged in left - accept deletion
|
||||
continue
|
||||
} else {
|
||||
@@ -157,7 +299,7 @@ func Merge3Way(base, left, right []*types.Issue) ([]*types.Issue, []string) {
|
||||
}
|
||||
} else if inBase && !inLeft && inRight {
|
||||
// Deleted in left, maybe modified in right
|
||||
if issuesEqual(baseIssue.Issue, rightIssue.Issue) {
|
||||
if issuesEqual(baseIssue, rightIssue) {
|
||||
// Deleted in left, unchanged in right - accept deletion
|
||||
continue
|
||||
} else {
|
||||
@@ -166,78 +308,61 @@ func Merge3Way(base, left, right []*types.Issue) ([]*types.Issue, []string) {
|
||||
}
|
||||
} else if !inBase && inLeft && !inRight {
|
||||
// Added only in left
|
||||
result = append(result, leftIssue.Issue)
|
||||
result = append(result, leftIssue)
|
||||
} else if !inBase && !inLeft && inRight {
|
||||
// Added only in right
|
||||
result = append(result, rightIssue.Issue)
|
||||
result = append(result, rightIssue)
|
||||
}
|
||||
}
|
||||
|
||||
return result, conflicts
|
||||
}
|
||||
|
||||
func mergeIssue(base, left, right issueWithRaw) (*types.Issue, string) {
|
||||
result := &types.Issue{
|
||||
ID: base.Issue.ID,
|
||||
CreatedAt: base.Issue.CreatedAt,
|
||||
func mergeIssue(base, left, right Issue) (Issue, string) {
|
||||
result := Issue{
|
||||
ID: base.ID,
|
||||
CreatedAt: base.CreatedAt,
|
||||
CreatedBy: base.CreatedBy,
|
||||
}
|
||||
|
||||
// Merge title
|
||||
result.Title = mergeField(base.Issue.Title, left.Issue.Title, right.Issue.Title)
|
||||
result.Title = mergeField(base.Title, left.Title, right.Title)
|
||||
|
||||
// Merge description
|
||||
result.Description = mergeField(base.Issue.Description, left.Issue.Description, right.Issue.Description)
|
||||
result.Description = mergeField(base.Description, left.Description, right.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)
|
||||
result.Notes = mergeField(base.Notes, left.Notes, right.Notes)
|
||||
|
||||
// Merge status
|
||||
result.Status = types.Status(mergeField(string(base.Issue.Status), string(left.Issue.Status), string(right.Issue.Status)))
|
||||
result.Status = mergeField(base.Status, left.Status, right.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
|
||||
// Merge priority (as int)
|
||||
if base.Priority == left.Priority && base.Priority != right.Priority {
|
||||
result.Priority = right.Priority
|
||||
} else if base.Priority == right.Priority && base.Priority != left.Priority {
|
||||
result.Priority = left.Priority
|
||||
} else if left.Priority == right.Priority {
|
||||
result.Priority = left.Priority
|
||||
} else {
|
||||
result.Priority = left.Issue.Priority
|
||||
// Conflict - take left for now
|
||||
result.Priority = left.Priority
|
||||
}
|
||||
|
||||
// Merge issue_type
|
||||
result.IssueType = types.IssueType(mergeField(string(base.Issue.IssueType), string(left.Issue.IssueType), string(right.Issue.IssueType)))
|
||||
result.IssueType = mergeField(base.IssueType, left.IssueType, right.IssueType)
|
||||
|
||||
// Merge updated_at - take the max
|
||||
result.UpdatedAt = maxTime(left.Issue.UpdatedAt, right.Issue.UpdatedAt)
|
||||
result.UpdatedAt = maxTime(left.UpdatedAt, right.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
|
||||
}
|
||||
result.ClosedAt = maxTime(left.ClosedAt, right.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
|
||||
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
|
||||
|
||||
// Check if we have a real conflict
|
||||
if hasConflict(base.Issue, left.Issue, right.Issue) {
|
||||
if hasConflict(base, left, right, result) {
|
||||
return result, makeConflictWithBase(base.RawLine, left.RawLine, right.RawLine)
|
||||
}
|
||||
|
||||
@@ -255,16 +380,50 @@ func mergeField(base, left, right string) string {
|
||||
return left
|
||||
}
|
||||
|
||||
func maxTime(t1, t2 time.Time) time.Time {
|
||||
if t1.After(t2) {
|
||||
func maxTime(t1, t2 string) string {
|
||||
if t1 == "" && t2 == "" {
|
||||
return ""
|
||||
}
|
||||
if t1 == "" {
|
||||
return t2
|
||||
}
|
||||
if t2 == "" {
|
||||
return t1
|
||||
}
|
||||
|
||||
// Try RFC3339Nano first (supports fractional seconds), fall back to RFC3339
|
||||
time1, err1 := time.Parse(time.RFC3339Nano, t1)
|
||||
if err1 != nil {
|
||||
time1, err1 = time.Parse(time.RFC3339, t1)
|
||||
}
|
||||
|
||||
time2, err2 := time.Parse(time.RFC3339Nano, t2)
|
||||
if err2 != nil {
|
||||
time2, err2 = time.Parse(time.RFC3339, t2)
|
||||
}
|
||||
|
||||
// If both fail to parse, return t2 as fallback
|
||||
if err1 != nil && err2 != nil {
|
||||
return t2
|
||||
}
|
||||
// If only t1 failed to parse, return t2
|
||||
if err1 != nil {
|
||||
return t2
|
||||
}
|
||||
// If only t2 failed to parse, return t1
|
||||
if err2 != nil {
|
||||
return t1
|
||||
}
|
||||
|
||||
if time1.After(time2) {
|
||||
return t1
|
||||
}
|
||||
return t2
|
||||
}
|
||||
|
||||
func mergeDependencies(left, right []*types.Dependency) []*types.Dependency {
|
||||
func mergeDependencies(left, right []Dependency) []Dependency {
|
||||
seen := make(map[string]bool)
|
||||
var result []*types.Dependency
|
||||
var result []Dependency
|
||||
|
||||
for _, dep := range left {
|
||||
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
||||
@@ -285,29 +444,8 @@ func mergeDependencies(left, right []*types.Dependency) []*types.Dependency {
|
||||
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)
|
||||
func hasConflict(base, left, right, merged Issue) bool {
|
||||
// Check if any field has conflicting changes
|
||||
if base.Title != left.Title && base.Title != right.Title && left.Title != right.Title {
|
||||
return true
|
||||
}
|
||||
@@ -329,9 +467,11 @@ func hasConflict(base, left, right *types.Issue) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func issuesEqual(a, b *types.Issue) bool {
|
||||
// Use go-cmp for deep equality comparison
|
||||
return cmp.Equal(a, b)
|
||||
func issuesEqual(a, b Issue) bool {
|
||||
// Use go-cmp for deep equality comparison, ignoring RawLine field
|
||||
return cmp.Equal(a, b, cmp.FilterPath(func(p cmp.Path) bool {
|
||||
return p.String() == "RawLine"
|
||||
}, cmp.Ignore()))
|
||||
}
|
||||
|
||||
func makeConflict(left, right string) string {
|
||||
@@ -363,68 +503,3 @@ func makeConflictWithBase(base, left, right string) string {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
// 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
|
||||
package merge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestMergeField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base string
|
||||
left string
|
||||
right string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no change",
|
||||
base: "original",
|
||||
left: "original",
|
||||
right: "original",
|
||||
want: "original",
|
||||
},
|
||||
{
|
||||
name: "only left changed",
|
||||
base: "original",
|
||||
left: "changed",
|
||||
right: "original",
|
||||
want: "changed",
|
||||
},
|
||||
{
|
||||
name: "only right changed",
|
||||
base: "original",
|
||||
left: "original",
|
||||
right: "changed",
|
||||
want: "changed",
|
||||
},
|
||||
{
|
||||
name: "both changed to same",
|
||||
base: "original",
|
||||
left: "changed",
|
||||
right: "changed",
|
||||
want: "changed",
|
||||
},
|
||||
{
|
||||
name: "both changed differently - prefer left",
|
||||
base: "original",
|
||||
left: "left-change",
|
||||
right: "right-change",
|
||||
want: "left-change",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeField(tt.base, tt.left, tt.right)
|
||||
if got != tt.want {
|
||||
t.Errorf("mergeField() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxTime(t *testing.T) {
|
||||
t1 := time.Date(2025, 10, 16, 20, 51, 29, 0, time.UTC)
|
||||
t2 := time.Date(2025, 10, 16, 20, 51, 30, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
t1 time.Time
|
||||
t2 time.Time
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "t1 after t2",
|
||||
t1: t2,
|
||||
t2: t1,
|
||||
want: t2,
|
||||
},
|
||||
{
|
||||
name: "t2 after t1",
|
||||
t1: t1,
|
||||
t2: t2,
|
||||
want: t2,
|
||||
},
|
||||
{
|
||||
name: "equal times",
|
||||
t1: t1,
|
||||
t2: t1,
|
||||
want: t1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := maxTime(tt.t1, tt.t2)
|
||||
if !got.Equal(tt.want) {
|
||||
t.Errorf("maxTime() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDependencies(t *testing.T) {
|
||||
left := []*types.Dependency{
|
||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks"},
|
||||
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "blocks"},
|
||||
}
|
||||
right := []*types.Dependency{
|
||||
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "blocks"}, // duplicate
|
||||
{IssueID: "bd-1", DependsOnID: "bd-4", Type: "blocks"},
|
||||
}
|
||||
|
||||
result := mergeDependencies(left, right)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("mergeDependencies() returned %d deps, want 3", len(result))
|
||||
}
|
||||
|
||||
// Check all expected deps are present
|
||||
seen := make(map[string]bool)
|
||||
for _, dep := range result {
|
||||
key := dep.DependsOnID
|
||||
seen[key] = true
|
||||
}
|
||||
|
||||
expected := []string{"bd-2", "bd-3", "bd-4"}
|
||||
for _, exp := range expected {
|
||||
if !seen[exp] {
|
||||
t.Errorf("mergeDependencies() missing dependency on %s", exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLabels(t *testing.T) {
|
||||
left := []string{"bug", "p1", "frontend"}
|
||||
right := []string{"frontend", "urgent"} // frontend is duplicate
|
||||
|
||||
result := mergeLabels(left, right)
|
||||
|
||||
if len(result) != 4 {
|
||||
t.Errorf("mergeLabels() returned %d labels, want 4", len(result))
|
||||
}
|
||||
|
||||
// Check all expected labels are present
|
||||
seen := make(map[string]bool)
|
||||
for _, label := range result {
|
||||
seen[label] = true
|
||||
}
|
||||
|
||||
expected := []string{"bug", "p1", "frontend", "urgent"}
|
||||
for _, exp := range expected {
|
||||
if !seen[exp] {
|
||||
t.Errorf("mergeLabels() missing label %s", exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge3Way_SimpleUpdate(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
base := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "Original Title",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
// Left changes title
|
||||
left := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "Updated Title",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
// Right changes status
|
||||
right := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "Original Title",
|
||||
Status: types.StatusInProgress,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := Merge3Way(base, left, right)
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
t.Errorf("Merge3Way() produced unexpected conflicts: %v", conflicts)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Merge3Way() returned %d issues, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should merge both changes
|
||||
if result[0].Title != "Updated Title" {
|
||||
t.Errorf("Merge3Way() title = %v, want 'Updated Title'", result[0].Title)
|
||||
}
|
||||
if result[0].Status != types.StatusInProgress {
|
||||
t.Errorf("Merge3Way() status = %v, want 'in_progress'", result[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge3Way_DeletionDetection(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
base := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "To Be Deleted",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
// Left deletes the issue
|
||||
left := []*types.Issue{}
|
||||
|
||||
// Right keeps it unchanged
|
||||
right := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "To Be Deleted",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := Merge3Way(base, left, right)
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
t.Errorf("Merge3Way() produced unexpected conflicts: %v", conflicts)
|
||||
}
|
||||
|
||||
// Deletion should be accepted (issue removed in left, unchanged in right)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Merge3Way() returned %d issues, want 0 (deletion accepted)", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge3Way_AddedInBoth(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
base := []*types.Issue{}
|
||||
|
||||
// Both add the same issue (identical)
|
||||
left := []*types.Issue{
|
||||
{
|
||||
ID: "bd-2",
|
||||
Title: "New Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeBug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
right := []*types.Issue{
|
||||
{
|
||||
ID: "bd-2",
|
||||
Title: "New Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeBug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := Merge3Way(base, left, right)
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
t.Errorf("Merge3Way() produced unexpected conflicts: %v", conflicts)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Merge3Way() returned %d issues, want 1", len(result))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user