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,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