fix(merge): sort output by issue id (#859)

Ensure deterministic output when merging issues. Go map iteration order
is non-deterministic, causing inconsistent merge results across runs.

Sort result slice by ID before returning, matching bd export behavior.

Fixes #853
This commit is contained in:
Peter Chanthamynavong
2026-01-02 12:39:09 -08:00
committed by GitHub
parent f37fe949e8
commit ad44b32835
2 changed files with 76 additions and 0 deletions

View File

@@ -29,9 +29,11 @@ package merge
import (
"bufio"
"cmp"
"encoding/json"
"fmt"
"os"
"slices"
"time"
"github.com/steveyegge/beads/internal/types"
@@ -523,6 +525,11 @@ func Merge3WayWithTTL(base, left, right []Issue, ttl time.Duration, debug bool)
}
}
// Sort by ID for deterministic output (matches bd export behavior)
slices.SortFunc(result, func(a, b Issue) int {
return cmp.Compare(a.ID, b.ID)
})
return result, conflicts
}

View File

@@ -2391,3 +2391,72 @@ func TestMerge3Way_TombstoneVsLiveTimestampPrecisionMismatch(t *testing.T) {
}
})
}
// TestMerge3Way_DeterministicOutputOrder verifies that merge output is sorted by ID
// for consistent, reproducible results regardless of input order or map iteration.
// This is important for:
// - Reproducible git diffs between merges
// - Cross-machine consistency
// - Matching bd export behavior
func TestMerge3Way_DeterministicOutputOrder(t *testing.T) {
// Create issues with IDs that would appear in different orders
// if map iteration order determined output order
issueA := Issue{ID: "beads-aaa", Title: "A", Status: "open", CreatedAt: "2024-01-01T00:00:00Z"}
issueB := Issue{ID: "beads-bbb", Title: "B", Status: "open", CreatedAt: "2024-01-02T00:00:00Z"}
issueC := Issue{ID: "beads-ccc", Title: "C", Status: "open", CreatedAt: "2024-01-03T00:00:00Z"}
issueZ := Issue{ID: "beads-zzz", Title: "Z", Status: "open", CreatedAt: "2024-01-04T00:00:00Z"}
issueM := Issue{ID: "beads-mmm", Title: "M", Status: "open", CreatedAt: "2024-01-05T00:00:00Z"}
t.Run("output is sorted by ID", func(t *testing.T) {
// Input in arbitrary (non-sorted) order
base := []Issue{}
left := []Issue{issueZ, issueA, issueM}
right := []Issue{issueC, issueB}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 5 {
t.Fatalf("expected 5 issues, got %d", len(result))
}
// Verify output is sorted by ID
expectedOrder := []string{"beads-aaa", "beads-bbb", "beads-ccc", "beads-mmm", "beads-zzz"}
for i, expected := range expectedOrder {
if result[i].ID != expected {
t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, expected)
}
}
})
t.Run("deterministic across multiple runs", func(t *testing.T) {
// Run merge multiple times to verify consistent ordering
base := []Issue{}
left := []Issue{issueZ, issueA, issueM}
right := []Issue{issueC, issueB}
var firstRunIDs []string
for run := 0; run < 10; run++ {
result, _ := merge3Way(base, left, right, false)
var ids []string
for _, issue := range result {
ids = append(ids, issue.ID)
}
if run == 0 {
firstRunIDs = ids
} else {
// Compare to first run
for i, id := range ids {
if id != firstRunIDs[i] {
t.Errorf("run %d: result[%d].ID = %q, want %q (non-deterministic output)", run, i, id, firstRunIDs[i])
}
}
}
}
})
}