fix(merge): proper 3-way merge for dependencies - removals win (bd-ndye)
CRITICAL: Fixed dependency resurrection bug that caused removed/orphaned dependencies to keep coming back after sync. Root cause: mergeDependencies() was doing a union (additive only) and completely ignored the base parameter. This meant any dependency present in either left or right would be included, even if it was intentionally removed. Fix: Proper 3-way merge where REMOVALS ARE AUTHORITATIVE: - If dep was in base and removed by left OR right → stays removed - If dep wasn't in base and added by left OR right → included - If dep was in base and both still have it → included This fixes months of issues with orphaned parent-child relationships being resurrected during git sync operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -9,13 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [0.30.6] - 2025-12-18
|
## [0.30.6] - 2025-12-18
|
||||||
|
|
||||||
## [0.30.6] - 2025-12-18
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`bd graph` dependency counts** (bd-6v2) - Graph command shows dependency counts using subgraph formatting
|
- **`bd graph` dependency counts** (bd-6v2) - Graph command shows dependency counts using subgraph formatting
|
||||||
- **`types.StatusPinned`** - New status for persistent beads that survive cleanup
|
- **`types.StatusPinned`** - New status for persistent beads that survive cleanup
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CRITICAL: Dependency resurrection bug** (bd-ndye) - Fixed 3-way merge to respect dependency removals
|
||||||
|
- `mergeDependencies` was using union (additive-only) instead of proper 3-way merge
|
||||||
|
- Now removals are authoritative: if either left or right removes a dep, it stays removed
|
||||||
|
- This prevented orphaned parent-child relationships from being permanently removed
|
||||||
|
|
||||||
## [0.30.5] - 2025-12-18
|
## [0.30.5] - 2025-12-18
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ var versionChanges = []VersionChange{
|
|||||||
Changes: []string{
|
Changes: []string{
|
||||||
"bd graph command shows dependency counts using subgraph formatting (bd-6v2)",
|
"bd graph command shows dependency counts using subgraph formatting (bd-6v2)",
|
||||||
"types.StatusPinned for persistent beads that survive cleanup",
|
"types.StatusPinned for persistent beads that survive cleanup",
|
||||||
|
"CRITICAL: Fixed dependency resurrection bug in 3-way merge (bd-ndye) - removals now win",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+75
-11
@@ -575,8 +575,8 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
|
|||||||
result.ClosedAt = ""
|
result.ClosedAt = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge dependencies - combine and deduplicate
|
// Merge dependencies - proper 3-way merge where removals win (bd-ndye)
|
||||||
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
|
result.Dependencies = mergeDependencies(base.Dependencies, left.Dependencies, right.Dependencies)
|
||||||
|
|
||||||
// bd-1sn: If status became tombstone via mergeStatus safety fallback,
|
// bd-1sn: If status became tombstone via mergeStatus safety fallback,
|
||||||
// copy tombstone fields from whichever side has them
|
// copy tombstone fields from whichever side has them
|
||||||
@@ -792,23 +792,87 @@ func maxTime(t1, t2 string) string {
|
|||||||
return t2
|
return t2
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeDependencies(left, right []Dependency) []Dependency {
|
// mergeDependencies performs a proper 3-way merge of dependencies (bd-ndye)
|
||||||
seen := make(map[string]bool)
|
// Key principle: REMOVALS ARE AUTHORITATIVE
|
||||||
var result []Dependency
|
// - If dep was in base and removed by left OR right → exclude (removal wins)
|
||||||
|
// - If dep wasn't in base and added by left OR right → include
|
||||||
|
// - If dep was in base and both still have it → include
|
||||||
|
func mergeDependencies(base, left, right []Dependency) []Dependency {
|
||||||
|
// Build sets for O(1) lookup
|
||||||
|
depKey := func(dep Dependency) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSet := make(map[string]bool)
|
||||||
|
for _, dep := range base {
|
||||||
|
baseSet[depKey(dep)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
leftSet := make(map[string]bool)
|
||||||
|
leftDeps := make(map[string]Dependency)
|
||||||
for _, dep := range left {
|
for _, dep := range left {
|
||||||
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
key := depKey(dep)
|
||||||
if !seen[key] {
|
leftSet[key] = true
|
||||||
seen[key] = true
|
leftDeps[key] = dep
|
||||||
result = append(result, dep)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rightSet := make(map[string]bool)
|
||||||
|
rightDeps := make(map[string]Dependency)
|
||||||
for _, dep := range right {
|
for _, dep := range right {
|
||||||
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
key := depKey(dep)
|
||||||
|
rightSet[key] = true
|
||||||
|
rightDeps[key] = dep
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique keys
|
||||||
|
allKeys := make(map[string]bool)
|
||||||
|
for k := range baseSet {
|
||||||
|
allKeys[k] = true
|
||||||
|
}
|
||||||
|
for k := range leftSet {
|
||||||
|
allKeys[k] = true
|
||||||
|
}
|
||||||
|
for k := range rightSet {
|
||||||
|
allKeys[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []Dependency
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for key := range allKeys {
|
||||||
|
inBase := baseSet[key]
|
||||||
|
inLeft := leftSet[key]
|
||||||
|
inRight := rightSet[key]
|
||||||
|
|
||||||
|
// 3-way merge logic:
|
||||||
|
if inBase {
|
||||||
|
// Was in base - check if either side removed it
|
||||||
|
if !inLeft {
|
||||||
|
// Left removed it → don't include (left wins)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inRight {
|
||||||
|
// Right removed it → don't include (right wins)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Both still have it → include
|
||||||
|
} else {
|
||||||
|
// Wasn't in base - must have been added by left or right
|
||||||
|
if !inLeft && !inRight {
|
||||||
|
// Neither has it (shouldn't happen but handle gracefully)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// At least one side added it → include
|
||||||
|
}
|
||||||
|
|
||||||
if !seen[key] {
|
if !seen[key] {
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
|
// Prefer left's version of the dep (for any metadata differences)
|
||||||
|
if dep, ok := leftDeps[key]; ok {
|
||||||
result = append(result, dep)
|
result = append(result, dep)
|
||||||
|
} else if dep, ok := rightDeps[key]; ok {
|
||||||
|
result = append(result, dep)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,22 +128,25 @@ func TestMergeField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMergeDependencies tests dependency union and deduplication
|
// TestMergeDependencies tests 3-way dependency merge with removal semantics (bd-ndye)
|
||||||
func TestMergeDependencies(t *testing.T) {
|
func TestMergeDependencies(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
base []Dependency
|
||||||
left []Dependency
|
left []Dependency
|
||||||
right []Dependency
|
right []Dependency
|
||||||
expected []Dependency
|
expected []Dependency
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty both sides",
|
name: "empty all sides",
|
||||||
|
base: []Dependency{},
|
||||||
left: []Dependency{},
|
left: []Dependency{},
|
||||||
right: []Dependency{},
|
right: []Dependency{},
|
||||||
expected: []Dependency{},
|
expected: []Dependency{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only left has deps",
|
name: "left adds dep (not in base)",
|
||||||
|
base: []Dependency{},
|
||||||
left: []Dependency{
|
left: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
},
|
},
|
||||||
@@ -153,7 +156,8 @@ func TestMergeDependencies(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only right has deps",
|
name: "right adds dep (not in base)",
|
||||||
|
base: []Dependency{},
|
||||||
left: []Dependency{},
|
left: []Dependency{},
|
||||||
right: []Dependency{
|
right: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
@@ -163,7 +167,8 @@ func TestMergeDependencies(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "union of different deps",
|
name: "both add different deps (not in base)",
|
||||||
|
base: []Dependency{},
|
||||||
left: []Dependency{
|
left: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
},
|
},
|
||||||
@@ -176,38 +181,61 @@ func TestMergeDependencies(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deduplication of identical deps",
|
name: "left removes dep from base - REMOVAL WINS",
|
||||||
left: []Dependency{
|
base: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
},
|
},
|
||||||
|
left: []Dependency{}, // Left removed it
|
||||||
right: []Dependency{
|
right: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"}, // Different timestamp but same logical dep
|
|
||||||
},
|
|
||||||
expected: []Dependency{
|
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
},
|
},
|
||||||
|
expected: []Dependency{}, // Should be empty - removal wins
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple deps with dedup",
|
name: "right removes dep from base - REMOVAL WINS",
|
||||||
|
base: []Dependency{
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
|
},
|
||||||
|
left: []Dependency{
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
|
},
|
||||||
|
right: []Dependency{}, // Right removed it
|
||||||
|
expected: []Dependency{}, // Should be empty - removal wins
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both keep dep from base",
|
||||||
|
base: []Dependency{
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
|
},
|
||||||
left: []Dependency{
|
left: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
||||||
},
|
},
|
||||||
right: []Dependency{
|
right: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"},
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-4", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
||||||
},
|
},
|
||||||
expected: []Dependency{
|
expected: []Dependency{
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
},
|
||||||
{IssueID: "bd-1", DependsOnID: "bd-4", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
},
|
||||||
|
{
|
||||||
|
name: "complex: left removes one, right adds one",
|
||||||
|
base: []Dependency{
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
|
},
|
||||||
|
left: []Dependency{}, // Left removed bd-2
|
||||||
|
right: []Dependency{
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"}, // Right added bd-3
|
||||||
|
},
|
||||||
|
expected: []Dependency{
|
||||||
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"}, // Only the new one
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := mergeDependencies(tt.left, tt.right)
|
result := mergeDependencies(tt.base, tt.left, tt.right)
|
||||||
if len(result) != len(tt.expected) {
|
if len(result) != len(tt.expected) {
|
||||||
t.Errorf("mergeDependencies() returned %d deps, want %d", len(result), len(tt.expected))
|
t.Errorf("mergeDependencies() returned %d deps, want %d", len(result), len(tt.expected))
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user