feat: Vendor beads-merge 3-way merge algorithm (bd-oif6)
- Integrated @neongreen's beads-merge into internal/merge/ - Adapted to use bd's internal/types.Issue instead of custom types - Added comprehensive tests covering merge scenarios - Created ATTRIBUTION.md crediting @neongreen - All tests pass This solves: - Multi-workspace deletion sync (bd-hv01) - Git JSONL merge conflicts - Field-level intelligent merging Original: https://github.com/neongreen/mono/tree/main/beads-merge
This commit is contained in:
34
ATTRIBUTION.md
Normal file
34
ATTRIBUTION.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Attribution and Credits
|
||||||
|
|
||||||
|
## beads-merge 3-Way Merge Algorithm
|
||||||
|
|
||||||
|
The 3-way merge functionality in `internal/merge/` is based on **beads-merge** by **@neongreen**.
|
||||||
|
|
||||||
|
- **Original Repository**: https://github.com/neongreen/mono/tree/main/beads-merge
|
||||||
|
- **Author**: @neongreen (https://github.com/neongreen)
|
||||||
|
- **Integration Discussion**: https://github.com/neongreen/mono/issues/240
|
||||||
|
|
||||||
|
### What We Vendored
|
||||||
|
|
||||||
|
The core merge algorithm from beads-merge has been adapted and integrated into bd:
|
||||||
|
- Field-level 3-way merge logic
|
||||||
|
- Issue identity matching (id + created_at + created_by)
|
||||||
|
- Dependency and label merging with deduplication
|
||||||
|
- Timestamp handling (max wins)
|
||||||
|
- Deletion detection
|
||||||
|
- Conflict marker generation
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
- Adapted to use bd's `internal/types.Issue` instead of custom types
|
||||||
|
- Integrated with bd's JSONL export/import system
|
||||||
|
- Added support for bd-specific fields (Design, AcceptanceCriteria, etc.)
|
||||||
|
- Exposed as `bd merge` CLI command and library API
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
The original beads-merge code is used with permission from @neongreen. We are grateful for their contribution to the beads ecosystem.
|
||||||
|
|
||||||
|
### Thank You
|
||||||
|
|
||||||
|
Special thanks to @neongreen for building beads-merge and graciously allowing us to integrate it into bd. This solves critical multi-workspace sync issues and makes beads much more robust for collaborative workflows.
|
||||||
1
go.mod
1
go.mod
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/anthropics/anthropic-sdk-go v1.16.0
|
github.com/anthropics/anthropic-sdk-go v1.16.0
|
||||||
github.com/fatih/color v1.18.0
|
github.com/fatih/color v1.18.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
|
github.com/google/go-cmp v0.6.0
|
||||||
github.com/ncruces/go-sqlite3 v0.29.1
|
github.com/ncruces/go-sqlite3 v0.29.1
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
|||||||
362
internal/merge/merge.go
Normal file
362
internal/merge/merge.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// 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: @neongreen (https://github.com/neongreen)
|
||||||
|
// Used with permission - see ATTRIBUTION.md for full credits
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
306
internal/merge/merge_test.go
Normal file
306
internal/merge/merge_test.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// 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: @neongreen (https://github.com/neongreen)
|
||||||
|
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