Fix bd-pq5k: merge conflicts now prefer closed>open and deletion>modification

CHANGES:
1. Merge logic (internal/merge/merge.go):
   - Added mergeStatus() enforcing closed ALWAYS wins over open
   - Fixed closed_at handling: only set when status='closed'
   - Changed deletion handling: deletion ALWAYS wins over modification

2. Deletion tracking (cmd/bd/snapshot_manager.go):
   - Updated ComputeAcceptedDeletions to accept all merge deletions
   - Removed "unchanged locally" check (deletion wins regardless)

3. FK constraint helper (internal/storage/sqlite/util.go):
   - Added IsForeignKeyConstraintError() for bd-koab
   - Detects FK violations for graceful import handling

TESTS UPDATED:
- TestMergeStatus: comprehensive status merge tests
- TestIsForeignKeyConstraintError: FK constraint detection
- bd-pq5k test: validates no invalid state (status=open with closed_at)
- Deletion tests: reflect new deletion-wins behavior
- All tests pass ✓

This ensures issues never get stuck in invalid states and prevents
the insane situation where issues never die!

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-23 21:42:43 -08:00
parent b428254f89
commit d4f9a05bb2
8 changed files with 926 additions and 55 deletions

View File

@@ -297,22 +297,14 @@ func merge3Way(base, left, right []Issue) ([]Issue, []string) {
}
} else if inBase && inLeft && !inRight {
// Deleted in right, maybe modified in left
if issuesEqual(baseIssue, leftIssue) {
// 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, ""))
}
// 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, maybe modified in right
if issuesEqual(baseIssue, rightIssue) {
// 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))
}
// 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
result = append(result, leftIssue)
@@ -341,8 +333,8 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
// Merge notes
result.Notes = mergeField(base.Notes, left.Notes, right.Notes)
// Merge status
result.Status = mergeField(base.Status, left.Status, right.Status)
// Merge status - SPECIAL RULE: closed always wins over open
result.Status = mergeStatus(base.Status, left.Status, right.Status)
// Merge priority (as int)
if base.Priority == left.Priority && base.Priority != right.Priority {
@@ -362,8 +354,13 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
// Merge updated_at - take the max
result.UpdatedAt = maxTime(left.UpdatedAt, right.UpdatedAt)
// Merge closed_at - take the max
result.ClosedAt = maxTime(left.ClosedAt, right.ClosedAt)
// 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)
@@ -376,6 +373,17 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
return result, ""
}
func mergeStatus(base, left, right string) string {
// 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