feat(sync): add safety check enhancements and merge fixes

- Add forensic logging for mass deletions (bd-lsa): log vanished issue IDs and titles
- Add sync.require_confirmation_on_mass_delete config option (bd-4u8)
- Fix priority merge to treat 0 as "unset" (bd-d0t)
- Fix timestamp tie-breaker to prefer left/local (bd-8nz)
- Add warning log when extraction fails during safety check (bd-feh)
- Refactor safety warnings to return in PullResult (bd-7z4)
- Add TestSafetyCheckMassDeletion integration tests (bd-cnn)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-02 21:48:18 -08:00
parent c93b755344
commit f531691440
6 changed files with 443 additions and 18 deletions

View File

@@ -426,6 +426,8 @@ func mergeNotes(base, left, right string) string {
}
// 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)
func mergePriority(base, left, right int) int {
// Standard 3-way merge for non-conflict cases
if base == left && base != right {
@@ -438,7 +440,16 @@ func mergePriority(base, left, right int) int {
return left
}
// True conflict: both sides changed to different values
// Higher priority wins (lower number = more urgent)
// bd-d0t fix: Treat 0 as "unset" - explicitly set priority wins over unset
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
}
@@ -477,9 +488,9 @@ func isTimeAfter(t1, t2 string) bool {
return true // t1 valid, t2 invalid - t1 wins
}
// Both valid - compare. On exact tie, return false (right wins for now)
// TODO: Consider preferring left on tie for consistency with IssueType rule
return time1.After(time2)
// 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 {

View File

@@ -337,10 +337,10 @@ func TestIsTimeAfter(t *testing.T) {
expected: false,
},
{
name: "identical timestamps - right wins (false)",
name: "identical timestamps - left wins (bd-8nz)",
t1: "2024-01-01T00:00:00Z",
t2: "2024-01-01T00:00:00Z",
expected: false,
expected: true,
},
{
name: "t1 invalid, t2 valid - t2 wins",
@@ -483,6 +483,99 @@ func TestMerge3Way_SimpleUpdates(t *testing.T) {
})
}
// TestMergePriority tests priority merging including bd-d0t fix
func TestMergePriority(t *testing.T) {
tests := []struct {
name string
base int
left int
right int
expected int
}{
{
name: "no changes",
base: 2,
left: 2,
right: 2,
expected: 2,
},
{
name: "left changed",
base: 2,
left: 1,
right: 2,
expected: 1,
},
{
name: "right changed",
base: 2,
left: 2,
right: 3,
expected: 3,
},
{
name: "both changed to same value",
base: 2,
left: 1,
right: 1,
expected: 1,
},
{
name: "conflict - higher priority wins (lower number)",
base: 2,
left: 3,
right: 1,
expected: 1,
},
// bd-d0t fix: 0 is treated as "unset"
{
name: "bd-d0t: left unset (0), right has explicit priority",
base: 2,
left: 0,
right: 3,
expected: 3, // explicit priority wins over unset
},
{
name: "bd-d0t: left has explicit priority, right unset (0)",
base: 2,
left: 3,
right: 0,
expected: 3, // explicit priority wins over unset
},
{
name: "bd-d0t: both unset (0)",
base: 2,
left: 0,
right: 0,
expected: 0,
},
{
name: "bd-d0t: base unset, left sets priority, right unchanged",
base: 0,
left: 1,
right: 0,
expected: 1, // left changed from 0 to 1
},
{
name: "bd-d0t: base unset, right sets priority, left unchanged",
base: 0,
left: 0,
right: 2,
expected: 2, // right changed from 0 to 2
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergePriority(tt.base, tt.left, tt.right)
if result != tt.expected {
t.Errorf("mergePriority(%d, %d, %d) = %d, want %d",
tt.base, tt.left, tt.right, result, tt.expected)
}
})
}
}
// TestMerge3Way_AutoResolve tests auto-resolution of conflicts
func TestMerge3Way_AutoResolve(t *testing.T) {
t.Run("conflicting title changes - latest updated_at wins", func(t *testing.T) {