From e7819737369c2a5e723e8d71fc408ef4d5b83584 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 30 Oct 2025 19:48:16 -0700 Subject: [PATCH] Update N-way collision tests to use hash-based IDs - Enable id_mode=hash in all test clones - Remove expectation of ID collisions (hash IDs prevent this) - Tests now properly demonstrate that hash IDs eliminate ID conflicts - Git JSONL merge conflicts still occur but are simpler to resolve - Related: bd-165 (hash-based ID implementation) Amp-Thread-ID: https://ampcode.com/threads/T-fe7c6698-371e-467a-a340-ed73f31bff6a Co-authored-by: Amp --- beads_nway_test.go | 23 ++---- beads_twoclone_test.go | 161 ++++++++++++++--------------------------- 2 files changed, 61 insertions(+), 123 deletions(-) diff --git a/beads_nway_test.go b/beads_nway_test.go index f4e8616c..807c593d 100644 --- a/beads_nway_test.go +++ b/beads_nway_test.go @@ -43,9 +43,9 @@ func TestTenCloneCollision(t *testing.T) { }) } -// testNCloneCollision is the generalized N-way collision test. -// It creates N clones, each creating an issue with the same ID but different content, -// then syncs them in the specified order and verifies convergence. +// testNCloneCollision is the generalized N-way convergence test. +// With hash-based IDs (bd-165), each clone creates an issue with a unique content-based ID. +// No collisions occur, so syncing should work cleanly without conflict resolution. func testNCloneCollision(t *testing.T, numClones int, syncOrder []string) { t.Helper() @@ -74,7 +74,7 @@ func testNCloneCollision(t *testing.T, numClones int, syncOrder []string) { cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name, bdPath) } - // Each clone creates issue with same ID but different content + // Each clone creates issue with different content (thus different hash-based ID) t.Logf("Creating issues in %d clones", numClones) for name, dir := range cloneDirs { createIssueInClone(t, dir, fmt.Sprintf("Issue from clone %s", name)) @@ -106,25 +106,14 @@ func testNCloneCollision(t *testing.T, numClones int, syncOrder []string) { } t.Logf("Verifying convergence: expecting %d issues", len(expectedTitles)) - allConverged := true for name, dir := range cloneDirs { titles := getTitlesFromClone(t, dir) if !compareTitleSets(titles, expectedTitles) { t.Errorf("Clone %s missing issues:\n Expected: %v\n Got: %v", name, sortedKeys(expectedTitles), sortedKeys(titles)) - allConverged = false } } - if !allConverged { - // This documents a known limitation: N-way collision resolution - // may hit UNIQUE constraint failures when multiple clones try to remap - // to the same target ID during convergence rounds. - // Example error: "failed to handle rename test-2 -> test-4: UNIQUE constraint failed" - t.Skip("KNOWN LIMITATION: N-way collisions may require additional resolution logic to avoid ID conflicts during convergence") - return - } - t.Logf("✓ All %d clones converged successfully", numClones) } @@ -158,6 +147,8 @@ func setupClone(t *testing.T, tmpDir, remoteDir, name, bdPath string) string { if name == "A" { t.Logf("Initializing beads in clone %s", name) runCmd(t, cloneDir, "./bd", "init", "--quiet", "--prefix", "test") + // Enable hash ID mode for collision-free IDs + runCmdWithEnv(t, cloneDir, map[string]string{"BEADS_NO_DAEMON": "1"}, "./bd", "config", "set", "id_mode", "hash") runCmd(t, cloneDir, "git", "add", ".beads") runCmd(t, cloneDir, "git", "commit", "-m", "Initialize beads") runCmd(t, cloneDir, "git", "push", "origin", "master") @@ -165,6 +156,8 @@ func setupClone(t *testing.T, tmpDir, remoteDir, name, bdPath string) string { // Other clones pull and initialize from JSONL runCmd(t, cloneDir, "git", "pull", "origin", "master") runCmd(t, cloneDir, "./bd", "init", "--quiet", "--prefix", "test") + // Enable hash ID mode (same as clone A) + runCmdWithEnv(t, cloneDir, map[string]string{"BEADS_NO_DAEMON": "1"}, "./bd", "config", "set", "id_mode", "hash") } // Install git hooks diff --git a/beads_twoclone_test.go b/beads_twoclone_test.go index d0bb6041..f29d7954 100644 --- a/beads_twoclone_test.go +++ b/beads_twoclone_test.go @@ -11,8 +11,8 @@ import ( "time" ) -// TestTwoCloneCollision demonstrates that beads does NOT work with the basic workflow -// of two independent clones filing issues simultaneously. +// TestTwoCloneCollision verifies that with hash-based IDs (bd-165), +// two independent clones can file issues simultaneously without collision. func TestTwoCloneCollision(t *testing.T) { tmpDir := t.TempDir() @@ -44,6 +44,8 @@ func TestTwoCloneCollision(t *testing.T) { // Initialize beads in clone A t.Log("Initializing beads in clone A") runCmd(t, cloneA, "./bd", "init", "--quiet", "--prefix", "test") + // Enable hash ID mode for collision-free IDs + runCmdWithEnv(t, cloneA, map[string]string{"BEADS_NO_DAEMON": "1"}, "./bd", "config", "set", "id_mode", "hash") // Commit the initial .beads directory from clone A runCmd(t, cloneA, "git", "add", ".beads") @@ -57,6 +59,8 @@ func TestTwoCloneCollision(t *testing.T) { // Initialize database in clone B from JSONL t.Log("Initializing database in clone B") runCmd(t, cloneB, "./bd", "init", "--quiet", "--prefix", "test") + // Enable hash ID mode (same as clone A) + runCmdWithEnv(t, cloneB, map[string]string{"BEADS_NO_DAEMON": "1"}, "./bd", "config", "set", "id_mode", "hash") // Install git hooks in both clones t.Log("Installing git hooks") @@ -79,11 +83,11 @@ func TestTwoCloneCollision(t *testing.T) { waitForDaemon(t, cloneA, 1*time.Second) waitForDaemon(t, cloneB, 1*time.Second) - // Clone A creates an issue + // Clone A creates an issue (hash ID based on content) t.Log("Clone A creating issue") runCmd(t, cloneA, "./bd", "create", "Issue from clone A", "-t", "task", "-p", "1", "--json") - // Clone B creates an issue (should get same ID since databases are independent) + // Clone B creates an issue with different content (will get different hash ID) t.Log("Clone B creating issue") runCmd(t, cloneB, "./bd", "create", "Issue from clone B", "-t", "task", "-p", "1", "--json") @@ -94,101 +98,18 @@ func TestTwoCloneCollision(t *testing.T) { // Wait for push to complete by polling git log waitForPush(t, cloneA, 2*time.Second) - // Clone B will conflict when syncing - t.Log("Clone B syncing (will conflict)") - syncBOut := runCmdOutputAllowError(t, cloneB, "./bd", "sync") - if !strings.Contains(syncBOut, "CONFLICT") && !strings.Contains(syncBOut, "Error") { - t.Log("Expected conflict during clone B sync, but got success. Output:") - t.Log(syncBOut) - } + // Clone B syncs (should work cleanly now - different IDs, no collision) + t.Log("Clone B syncing (should be clean)") + runCmd(t, cloneB, "./bd", "sync") - // Clone B needs to abort the rebase and resolve manually - t.Log("Clone B aborting rebase") - runCmdAllowError(t, cloneB, "git", "rebase", "--abort") + // Wait for sync to complete + waitForPush(t, cloneB, 2*time.Second) - // Pull with merge instead - t.Log("Clone B pulling with merge") - pullOut := runCmdOutputAllowError(t, cloneB, "git", "pull", "--no-rebase", "origin", "master") - if !strings.Contains(pullOut, "CONFLICT") { - t.Logf("Pull output: %s", pullOut) - } + // Clone A syncs to get clone B's issue + t.Log("Clone A syncing") + runCmd(t, cloneA, "./bd", "sync") - // Check if we have conflict markers in the JSONL - jsonlPath := filepath.Join(cloneB, ".beads", "issues.jsonl") - jsonlContent, _ := os.ReadFile(jsonlPath) - if strings.Contains(string(jsonlContent), "<<<<<<<") { - t.Log("JSONL has conflict markers - manually resolving") - // For this test, just take both issues (keep all non-marker lines) - var cleanLines []string - for _, line := range strings.Split(string(jsonlContent), "\n") { - if !strings.HasPrefix(line, "<<<<<<<") && - !strings.HasPrefix(line, "=======") && - !strings.HasPrefix(line, ">>>>>>>") { - if strings.TrimSpace(line) != "" { - cleanLines = append(cleanLines, line) - } - } - } - cleaned := strings.Join(cleanLines, "\n") + "\n" - if err := os.WriteFile(jsonlPath, []byte(cleaned), 0644); err != nil { - t.Fatalf("Failed to write cleaned JSONL: %v", err) - } - // Mark as resolved - runCmd(t, cloneB, "git", "add", ".beads/issues.jsonl") - runCmd(t, cloneB, "git", "commit", "-m", "Resolve merge conflict") - } - - // Force import with collision resolution in both - t.Log("Resolving collisions via import") - runCmd(t, cloneB, "./bd", "import", "-i", ".beads/issues.jsonl", "--resolve-collisions") - - // Push the resolved state from clone B - t.Log("Clone B pushing resolved state") - runCmd(t, cloneB, "git", "push", "origin", "master") - - // Clone A now tries to sync - will this work? - t.Log("Clone A syncing after clone B resolved collision") - syncAOut := runCmdOutputAllowError(t, cloneA, "./bd", "sync") - t.Logf("Clone A sync output:\n%s", syncAOut) - - // Check if clone A also hit a conflict - hasConflict := strings.Contains(syncAOut, "CONFLICT") || strings.Contains(syncAOut, "Error pulling") - - if hasConflict { - t.Log("✓ TEST PROVES THE PROBLEM: Clone A also hit a conflict when syncing!") - t.Log("This demonstrates that the basic two-clone workflow does NOT converge cleanly.") - t.Errorf("EXPECTED FAILURE: beads cannot handle two clones filing issues simultaneously") - return - } - - // Clone B needs to sync to pull Clone A's rename detection changes - t.Log("Clone B syncing to pull Clone A's rename changes") - syncBOut2 := runCmdOutputAllowError(t, cloneB, "./bd", "sync") - t.Logf("Clone B sync output:\n%s", syncBOut2) - - // Check if Clone B hit a conflict (expected if both clones applied rename) - if strings.Contains(syncBOut2, "CONFLICT") || strings.Contains(syncBOut2, "Error pulling") { - t.Log("Clone B hit merge conflict (expected - both clones applied rename)") - t.Log("Resolving via bd export - aborting rebase, taking our DB as truth") - runCmd(t, cloneB, "git", "rebase", "--abort") - - // Fetch remote changes without merging - runCmd(t, cloneB, "git", "fetch", "origin") - - // Use our JSONL (from our DB) by exporting and committing - runCmd(t, cloneB, "./bd", "export", "-o", ".beads/issues.jsonl") - runCmd(t, cloneB, "git", "add", ".beads/issues.jsonl") - runCmd(t, cloneB, "git", "commit", "-m", "Resolve conflict: use our DB state") - - // Force merge with ours strategy - runCmdOutputAllowError(t, cloneB, "git", "merge", "origin/master", "-X", "ours") - - // Push - runCmd(t, cloneB, "git", "push", "origin", "master") - } - - // If we somehow got here, check if things converged - // Check git status ignoring untracked files (the copied bd binary is expected) + // Check if things converged t.Log("Checking if git status is clean") statusA := runCmdOutputAllowError(t, cloneA, "git", "status", "--porcelain") statusB := runCmdOutputAllowError(t, cloneB, "git", "status", "--porcelain") @@ -208,20 +129,44 @@ func TestTwoCloneCollision(t *testing.T) { t.Log("Clone A final sync") runCmdOutputAllowError(t, cloneA, "./bd", "sync") - // Check if bd ready matches (comparing content, not timestamps) - readyA := runCmdOutputAllowError(t, cloneA, "./bd", "ready", "--json") - readyB := runCmdOutputAllowError(t, cloneB, "./bd", "ready", "--json") + // Verify both clones have both issues + listA := runCmdOutput(t, cloneA, "./bd", "list", "--json") + listB := runCmdOutput(t, cloneB, "./bd", "list", "--json") - // Compare semantic content, ignoring timestamp differences - // Timestamps are expected to differ since issues were created at different times - if !compareIssuesIgnoringTimestamps(t, readyA, readyB) { - t.Log("✓ TEST PROVES THE PROBLEM: Databases did not converge!") - t.Log("Even without conflicts, the two clones have different issue databases.") - t.Errorf("bd ready content differs:\nClone A:\n%s\n\nClone B:\n%s", readyA, readyB) - } else { - t.Log("✓ SUCCESS: Content converged! Both clones have identical semantic content.") - t.Log("(Timestamp differences are acceptable and expected)") + // Parse and check for both issue titles + var issuesA, issuesB []issueContent + if err := json.Unmarshal([]byte(listA[strings.Index(listA, "["):]), &issuesA); err != nil { + t.Fatalf("Failed to parse clone A issues: %v", err) } + if err := json.Unmarshal([]byte(listB[strings.Index(listB, "["):]), &issuesB); err != nil { + t.Fatalf("Failed to parse clone B issues: %v", err) + } + + if len(issuesA) != 2 { + t.Errorf("Clone A should have 2 issues, got %d", len(issuesA)) + } + if len(issuesB) != 2 { + t.Errorf("Clone B should have 2 issues, got %d", len(issuesB)) + } + + // Check that both issues are present in both clones + titlesA := make(map[string]bool) + for _, issue := range issuesA { + titlesA[issue.Title] = true + } + titlesB := make(map[string]bool) + for _, issue := range issuesB { + titlesB[issue.Title] = true + } + + if !titlesA["Issue from clone A"] || !titlesA["Issue from clone B"] { + t.Errorf("Clone A missing expected issues. Got: %v", sortedKeys(titlesA)) + } + if !titlesB["Issue from clone A"] || !titlesB["Issue from clone B"] { + t.Errorf("Clone B missing expected issues. Got: %v", sortedKeys(titlesB)) + } + + t.Log("✓ SUCCESS: Both clones converged with both issues using hash-based IDs!") } func installGitHooks(t *testing.T, repoDir string) {