WIP: Implement content-hash based collision resolution (bd-89)
- Replace timestamp-based collision scoring with deterministic content hashing - Add hashIssueContent() using SHA-256 of all substantive fields - Modify ScoreCollisions to compare hashes and set RemapIncoming flag - Update RemapCollisions to handle both directions (remap incoming OR existing) - Add CollisionDetail.RemapIncoming field to control which version gets remapped - Add unit tests for hash function and deterministic collision resolution Status: Hash-based resolution works correctly, but TestTwoCloneCollision still fails due to missing rename detection. After Clone B resolves collision, Clone A needs to recognize its issue was remapped to a different ID. Next: Add content-based rename detection during import to prevent re-resolving already-resolved collisions. Progress on bd-86. Amp-Thread-ID: https://ampcode.com/threads/T-b19b49e8-b52a-463d-b052-8a526a500260 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
90
internal/storage/sqlite/collision_hash_test.go
Normal file
90
internal/storage/sqlite/collision_hash_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestHashIssueContent(t *testing.T) {
|
||||
issue1 := &types.Issue{
|
||||
Title: "Issue from clone A",
|
||||
Description: "",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "open",
|
||||
}
|
||||
|
||||
issue2 := &types.Issue{
|
||||
Title: "Issue from clone B",
|
||||
Description: "",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "open",
|
||||
}
|
||||
|
||||
hash1 := hashIssueContent(issue1)
|
||||
hash2 := hashIssueContent(issue2)
|
||||
|
||||
// Hashes should be different
|
||||
if hash1 == hash2 {
|
||||
t.Errorf("Expected different hashes, got same: %s", hash1)
|
||||
}
|
||||
|
||||
// Hashes should be deterministic
|
||||
hash1Again := hashIssueContent(issue1)
|
||||
if hash1 != hash1Again {
|
||||
t.Errorf("Hash not deterministic: %s != %s", hash1, hash1Again)
|
||||
}
|
||||
|
||||
t.Logf("Hash A: %s", hash1)
|
||||
t.Logf("Hash B: %s", hash2)
|
||||
t.Logf("A < B: %v (B wins if true)", hash1 < hash2)
|
||||
}
|
||||
|
||||
func TestScoreCollisions_Deterministic(t *testing.T) {
|
||||
existingIssue := &types.Issue{
|
||||
ID: "test-1",
|
||||
Title: "Issue from clone B",
|
||||
Description: "",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "open",
|
||||
}
|
||||
|
||||
incomingIssue := &types.Issue{
|
||||
ID: "test-1",
|
||||
Title: "Issue from clone A",
|
||||
Description: "",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "open",
|
||||
}
|
||||
|
||||
collision := &CollisionDetail{
|
||||
ID: "test-1",
|
||||
ExistingIssue: existingIssue,
|
||||
IncomingIssue: incomingIssue,
|
||||
}
|
||||
|
||||
// Run scoring
|
||||
err := ScoreCollisions(nil, nil, []*CollisionDetail{collision}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreCollisions failed: %v", err)
|
||||
}
|
||||
|
||||
existingHash := hashIssueContent(existingIssue)
|
||||
incomingHash := hashIssueContent(incomingIssue)
|
||||
|
||||
t.Logf("Existing hash (B): %s", existingHash)
|
||||
t.Logf("Incoming hash (A): %s", incomingHash)
|
||||
t.Logf("Existing < Incoming: %v", existingHash < incomingHash)
|
||||
|
||||
// Clone B has lower hash, so it should win
|
||||
// This means: RemapIncoming should be TRUE (remap incoming A, keep existing B)
|
||||
if !collision.RemapIncoming {
|
||||
t.Errorf("Expected RemapIncoming=true (remap incoming A, keep existing B with lower hash), got false")
|
||||
} else {
|
||||
t.Logf("✓ Correct: RemapIncoming=true, will remap incoming 'clone A' and keep existing 'clone B'")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user