Files
beads/internal/merge/merge.go
Steve Yegge 386ab82f87 fix(merge): fix tombstone handling edge cases (bd-ki14, bd-ig5, bd-6x5, bd-1sn)
- bd-ki14: Preserve tombstones when other side implicitly deleted
  In merge3WayWithTTL(), implicit deletion cases now check if the
  remaining side is a tombstone and preserve it instead of dropping.

- bd-ig5: Remove duplicate constants from merge package
  StatusTombstone, DefaultTombstoneTTL, and ClockSkewGrace now
  reference the types package to avoid duplication.

- bd-6x5: Handle empty DeletedAt in mergeTombstones()
  Added explicit handling for edge cases where one or both tombstones
  have empty DeletedAt fields with deterministic behavior.

- bd-1sn: Copy tombstone fields in mergeIssue() safety fallback
  When status becomes tombstone via mergeStatus safety fallback,
  tombstone fields are now copied from the appropriate side.

Added comprehensive tests for all fixed edge cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 17:16:55 -08:00

767 lines
22 KiB
Go

// Copyright (c) 2024 @neongreen (https://github.com/neongreen)
// Originally from: https://github.com/neongreen/mono/tree/main/beads-merge
//
// MIT License
//
// 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 (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
"github.com/steveyegge/beads/internal/types"
)
// 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
// Tombstone fields (bd-0ih): inline soft-delete support for merge
DeletedAt string `json:"deleted_at,omitempty"` // When the issue was deleted
DeletedBy string `json:"deleted_by,omitempty"` // Who deleted the issue
DeleteReason string `json:"delete_reason,omitempty"` // Why the issue was deleted
OriginalType string `json:"original_type,omitempty"` // Issue type before deletion
}
// 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
}
// 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) // #nosec G304 -- outputPath provided by CLI flag but sanitized earlier
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)
}
if _, err := fmt.Fprintln(outFile, string(line)); err != nil {
return fmt.Errorf("error writing merged issue: %w", err)
}
}
// Write conflicts to output file
for _, conflict := range conflicts {
if _, err := fmt.Fprintln(outFile, conflict); err != nil {
return fmt.Errorf("error writing conflict: %w", err)
}
}
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
if err := outFile.Sync(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to sync output file: %v\n", err)
}
// #nosec G304 -- debug output reads file created earlier in same function
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
}
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) // #nosec G304 -- path supplied by CLI flag and validated upstream
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
var issues []Issue
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if line == "" {
continue
}
var issue Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
return nil, fmt.Errorf("failed to parse line %d: %w", lineNum, err)
}
issue.RawLine = line
issues = append(issues, issue)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
return issues, nil
}
func makeKey(issue Issue) IssueKey {
return IssueKey{
ID: issue.ID,
CreatedAt: issue.CreatedAt,
CreatedBy: issue.CreatedBy,
}
}
// bd-ig5: Use constants from types package to avoid duplication
const StatusTombstone = string(types.StatusTombstone)
// Alias TTL constants from types package for local use
var (
DefaultTombstoneTTL = types.DefaultTombstoneTTL
ClockSkewGrace = types.ClockSkewGrace
)
// IsTombstone returns true if the issue has been soft-deleted
func IsTombstone(issue Issue) bool {
return issue.Status == StatusTombstone
}
// IsExpiredTombstone returns true if the tombstone has exceeded its TTL.
// Non-tombstone issues always return false.
// ttl is the configured TTL duration; if zero, DefaultTombstoneTTL is used.
func IsExpiredTombstone(issue Issue, ttl time.Duration) bool {
// Non-tombstones never expire
if !IsTombstone(issue) {
return false
}
// Tombstones without DeletedAt are not expired (safety: shouldn't happen in valid data)
if issue.DeletedAt == "" {
return false
}
// Use default TTL if not specified
if ttl == 0 {
ttl = DefaultTombstoneTTL
}
// Parse the deleted_at timestamp
deletedAt, err := time.Parse(time.RFC3339Nano, issue.DeletedAt)
if err != nil {
deletedAt, err = time.Parse(time.RFC3339, issue.DeletedAt)
if err != nil {
// Invalid timestamp means not expired (safety)
return false
}
}
// Add clock skew grace period to the TTL
effectiveTTL := ttl + ClockSkewGrace
// Check if the tombstone has exceeded its TTL
expirationTime := deletedAt.Add(effectiveTTL)
return time.Now().After(expirationTime)
}
func merge3Way(base, left, right []Issue) ([]Issue, []string) {
return merge3WayWithTTL(base, left, right, DefaultTombstoneTTL)
}
// merge3WayWithTTL performs a 3-way merge with configurable tombstone TTL.
// This is the core merge function that handles tombstone semantics.
func merge3WayWithTTL(base, left, right []Issue, ttl time.Duration) ([]Issue, []string) {
// Build maps for quick lookup
baseMap := make(map[IssueKey]Issue)
for _, issue := range base {
baseMap[makeKey(issue)] = issue
}
leftMap := make(map[IssueKey]Issue)
for _, issue := range left {
leftMap[makeKey(issue)] = issue
}
rightMap := make(map[IssueKey]Issue)
for _, issue := range right {
rightMap[makeKey(issue)] = issue
}
// Track which issues we've processed
processed := make(map[IssueKey]bool)
var result []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]
// Determine tombstone status
leftTombstone := inLeft && IsTombstone(leftIssue)
rightTombstone := inRight && IsTombstone(rightIssue)
// Handle different scenarios
if inBase && inLeft && inRight {
// All three present - handle tombstone cases first
// CASE: Both are tombstones - merge tombstones (later deleted_at wins)
if leftTombstone && rightTombstone {
merged := mergeTombstones(leftIssue, rightIssue)
result = append(result, merged)
continue
}
// CASE: Left is tombstone, right is live
if leftTombstone && !rightTombstone {
if IsExpiredTombstone(leftIssue, ttl) {
// Tombstone expired - resurrection allowed, keep live issue
result = append(result, rightIssue)
} else {
// Tombstone wins
result = append(result, leftIssue)
}
continue
}
// CASE: Right is tombstone, left is live
if rightTombstone && !leftTombstone {
if IsExpiredTombstone(rightIssue, ttl) {
// Tombstone expired - resurrection allowed, keep live issue
result = append(result, leftIssue)
} else {
// Tombstone wins
result = append(result, rightIssue)
}
continue
}
// CASE: Both are live issues - standard 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 - handle tombstone cases
// CASE: Both are tombstones - merge tombstones
if leftTombstone && rightTombstone {
merged := mergeTombstones(leftIssue, rightIssue)
result = append(result, merged)
continue
}
// CASE: Left is tombstone, right is live
if leftTombstone && !rightTombstone {
if IsExpiredTombstone(leftIssue, ttl) {
result = append(result, rightIssue)
} else {
result = append(result, leftIssue)
}
continue
}
// CASE: Right is tombstone, left is live
if rightTombstone && !leftTombstone {
if IsExpiredTombstone(rightIssue, ttl) {
result = append(result, leftIssue)
} else {
result = append(result, rightIssue)
}
continue
}
// CASE: Both are live - merge using deterministic rules with empty base
emptyBase := Issue{
ID: leftIssue.ID,
CreatedAt: leftIssue.CreatedAt,
CreatedBy: leftIssue.CreatedBy,
}
merged, _ := mergeIssue(emptyBase, leftIssue, rightIssue)
result = append(result, merged)
} else if inBase && inLeft && !inRight {
// Deleted in right (implicitly), maybe modified in left
// bd-ki14: Check if left is a tombstone - tombstones must be preserved
if leftTombstone {
result = append(result, leftIssue)
continue
}
// RULE 2: deletion always wins over modification
// This is because deletion is an explicit action that should be preserved
continue
} else if inBase && !inLeft && inRight {
// Deleted in left (implicitly), maybe modified in right
// bd-ki14: Check if right is a tombstone - tombstones must be preserved
if rightTombstone {
result = append(result, rightIssue)
continue
}
// RULE 2: deletion always wins over modification
// This is because deletion is an explicit action that should be preserved
continue
} else if !inBase && inLeft && !inRight {
// Added only in left (could be a tombstone)
result = append(result, leftIssue)
} else if !inBase && !inLeft && inRight {
// Added only in right (could be a tombstone)
result = append(result, rightIssue)
}
}
return result, conflicts
}
// mergeTombstones merges two tombstones for the same issue.
// The tombstone with the later deleted_at timestamp wins.
//
// bd-6x5: Edge cases for empty DeletedAt:
// - If both empty: left wins (arbitrary but deterministic)
// - If left empty, right not: right wins (has timestamp)
// - If right empty, left not: left wins (has timestamp)
//
// Empty DeletedAt shouldn't happen in valid data (validation catches it),
// but we handle it defensively here.
func mergeTombstones(left, right Issue) Issue {
// bd-6x5: Handle empty DeletedAt explicitly for clarity
if left.DeletedAt == "" && right.DeletedAt == "" {
// Both invalid - left wins as tie-breaker
return left
}
if left.DeletedAt == "" {
// Left invalid, right valid - right wins
return right
}
if right.DeletedAt == "" {
// Right invalid, left valid - left wins
return left
}
// Both valid - use later deleted_at as the authoritative tombstone
if isTimeAfter(left.DeletedAt, right.DeletedAt) {
return left
}
return right
}
func mergeIssue(base, left, right Issue) (Issue, string) {
result := Issue{
ID: base.ID,
CreatedAt: base.CreatedAt,
CreatedBy: base.CreatedBy,
}
// Merge title - on conflict, side with latest updated_at wins
result.Title = mergeFieldByUpdatedAt(base.Title, left.Title, right.Title, left.UpdatedAt, right.UpdatedAt)
// Merge description - on conflict, side with latest updated_at wins
result.Description = mergeFieldByUpdatedAt(base.Description, left.Description, right.Description, left.UpdatedAt, right.UpdatedAt)
// Merge notes - on conflict, concatenate both sides
result.Notes = mergeNotes(base.Notes, left.Notes, right.Notes)
// Merge status - SPECIAL RULE: closed always wins over open
result.Status = mergeStatus(base.Status, left.Status, right.Status)
// Merge priority - on conflict, higher priority wins (lower number = more urgent)
result.Priority = mergePriority(base.Priority, left.Priority, right.Priority)
// Merge issue_type - on conflict, local (left) wins
result.IssueType = mergeField(base.IssueType, left.IssueType, right.IssueType)
// Merge updated_at - take the max
result.UpdatedAt = maxTime(left.UpdatedAt, right.UpdatedAt)
// Merge closed_at - only if status is closed
// This prevents invalid state (status=open with closed_at set)
if result.Status == "closed" {
result.ClosedAt = maxTime(left.ClosedAt, right.ClosedAt)
} else {
result.ClosedAt = ""
}
// Merge dependencies - combine and deduplicate
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
// bd-1sn: If status became tombstone via mergeStatus safety fallback,
// copy tombstone fields from whichever side has them
if result.Status == StatusTombstone {
// Prefer the side with more recent deleted_at, or left if tied
if isTimeAfter(left.DeletedAt, right.DeletedAt) {
result.DeletedAt = left.DeletedAt
result.DeletedBy = left.DeletedBy
result.DeleteReason = left.DeleteReason
result.OriginalType = left.OriginalType
} else if right.DeletedAt != "" {
result.DeletedAt = right.DeletedAt
result.DeletedBy = right.DeletedBy
result.DeleteReason = right.DeleteReason
result.OriginalType = right.OriginalType
} else if left.DeletedAt != "" {
result.DeletedAt = left.DeletedAt
result.DeletedBy = left.DeletedBy
result.DeleteReason = left.DeleteReason
result.OriginalType = left.OriginalType
}
// Note: if neither has DeletedAt, tombstone fields remain empty
// This represents invalid data that validation should catch
}
// All field conflicts are now auto-resolved deterministically
return result, ""
}
func mergeStatus(base, left, right string) string {
// RULE 0: tombstone is handled at the merge3Way level, not here.
// If a tombstone status reaches here, it means both sides have the same
// issue with possibly different statuses - tombstone should not be one of them
// (that case is handled by the tombstone merge logic).
// However, if somehow one side has tombstone status, preserve it as a safety measure.
if left == StatusTombstone || right == StatusTombstone {
// This shouldn't happen in normal flow - tombstones are handled earlier
// But if it does, tombstone wins (deletion is explicit)
return StatusTombstone
}
// RULE 1: closed always wins over open
// This prevents the insane situation where issues never die
if left == "closed" || right == "closed" {
return "closed"
}
// Otherwise use standard 3-way merge
return mergeField(base, left, right)
}
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 - left wins
return left
}
// mergeFieldByUpdatedAt resolves conflicts by picking the value from the side
// with the latest updated_at timestamp
func mergeFieldByUpdatedAt(base, left, right, leftUpdatedAt, rightUpdatedAt string) string {
// Standard 3-way merge for non-conflict cases
if base == left && base != right {
return right
}
if base == right && base != left {
return left
}
if left == right {
return left
}
// True conflict: both sides changed to different values
// Pick the value from the side with the latest updated_at
if isTimeAfter(leftUpdatedAt, rightUpdatedAt) {
return left
}
return right
}
// mergeNotes handles notes merging - on conflict, concatenate both sides
func mergeNotes(base, left, right string) string {
// Standard 3-way merge for non-conflict cases
if base == left && base != right {
return right
}
if base == right && base != left {
return left
}
if left == right {
return left
}
// True conflict: both sides changed to different values - concatenate
if left == "" {
return right
}
if right == "" {
return left
}
return left + "\n\n---\n\n" + right
}
// mergePriority handles priority merging - on conflict, higher priority wins (lower number)
// Special case: 0 is treated as "unset/no priority" due to Go's zero value.
// Any explicitly set priority (!=0) wins over 0. (bd-d0t fix, bd-1kf fix)
func mergePriority(base, left, right int) int {
// Standard 3-way merge for non-conflict cases
if base == left && base != right {
return right
}
if base == right && base != left {
return left
}
if left == right {
return left
}
// True conflict: both sides changed to different values
// bd-d0t fix: Treat 0 as "unset" - explicitly set priority wins over unset
// bd-1kf fix: Use != 0 instead of > 0 to handle negative priorities
if left == 0 && right != 0 {
return right // right has explicit priority, left is unset
}
if right == 0 && left != 0 {
return left // left has explicit priority, right is unset
}
// Both have explicit priorities (or both are 0) - higher priority wins (lower number = more urgent)
if left < right {
return left
}
return right
}
// isTimeAfter returns true if t1 is after t2
func isTimeAfter(t1, t2 string) bool {
if t1 == "" {
return false
}
if t2 == "" {
return true
}
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)
}
// Handle parse errors consistently with maxTime:
// - Valid timestamp beats invalid
// - If both invalid, prefer left (t1) for consistency
if err1 != nil && err2 != nil {
return true // both invalid, prefer left
}
if err1 != nil {
return false // t1 invalid, t2 valid - t2 wins
}
if err2 != nil {
return true // t1 valid, t2 invalid - t1 wins
}
// Both valid - compare. On exact tie, left wins for consistency with IssueType rule (bd-8nz)
// Using !time2.After(time1) returns true when t1 > t2 OR t1 == t2
return !time2.After(time1)
}
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 []Dependency) []Dependency {
seen := make(map[string]bool)
var result []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
}