From ff53ce26a47e5c688b33c8d0487159f5a5010597 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 28 Oct 2025 20:47:40 -0700 Subject: [PATCH] Add comprehensive N-way collision tests for bd-99 --- .beads/beads.jsonl | 21 +- beads_nway_test.go | 723 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 733 insertions(+), 11 deletions(-) create mode 100644 beads_nway_test.go diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 8b11c339..bddc745e 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -3,9 +3,9 @@ {"id":"bd-100","title":"Fix autoimport tests for content-hash collision scoring","description":"## Overview\nThree autoimport tests are failing after bd-96 because they expect behavior based on the old reference-counting collision resolution, but the system now uses deterministic content-hash scoring.\n\n## Failing Tests\n1. `TestAutoImportMultipleCollisionsRemapped` - expects local versions preserved\n2. `TestAutoImportAllCollisionsRemapped` - expects local versions preserved \n3. `TestAutoImportCollisionRemapMultipleFields` - expects specific collision resolution behavior\n\n## Root Cause\nThese tests were written when ScoreCollisions used reference counting to determine which version to keep. Now it uses content-hash comparison (introduced in commit 2e87329), which produces different but deterministic results.\n\n## Example\nOld behavior: Issue with more references would be kept\nNew behavior: Issue with lexicographically lower content hash is kept\n\n## Solution\nUpdate each test to:\n1. Verify the new content-hash based behavior is correct\n2. Check that the remapped issue (not necessarily local/remote) has the expected content\n3. Ensure dependencies are preserved on the correct remapped issue\n\n## Acceptance Criteria\n- All three autoimport tests pass\n- Tests verify content-hash determinism (same collision always resolves the same way)\n- Tests check dependency preservation on remapped issues\n- Test documentation explains content-hash scoring expectations\n\n## Files to Modify\n- `cmd/bd/autoimport_collision_test.go`\n\n## Testing\nRun: `go test ./cmd/bd -run \"TestAutoImport.*Collision\" -v`","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-28T19:12:56.344193-07:00","updated_at":"2025-10-28T19:18:35.106895-07:00","closed_at":"2025-10-28T19:18:35.106895-07:00","dependencies":[{"issue_id":"bd-100","depends_on_id":"bd-96","type":"discovered-from","created_at":"2025-10-28T19:12:56.345276-07:00","created_by":"daemon"}]} {"id":"bd-101","title":"Fix autoimport tests for content-hash collision scoring","description":"## Overview\nThree autoimport tests are failing after bd-96 because they expect behavior based on the old reference-counting collision resolution, but the system now uses deterministic content-hash scoring.\n\n## Failing Tests\n1. `TestAutoImportMultipleCollisionsRemapped` - expects local versions preserved\n2. `TestAutoImportAllCollisionsRemapped` - expects local versions preserved \n3. `TestAutoImportCollisionRemapMultipleFields` - expects specific collision resolution behavior\n\n## Root Cause\nThese tests were written when ScoreCollisions used reference counting to determine which version to keep. Now it uses content-hash comparison (introduced in commit 2e87329), which produces different but deterministic results.\n\n## Example\nOld behavior: Issue with more references would be kept\nNew behavior: Issue with lexicographically lower content hash is kept\n\n## Solution\nUpdate each test to:\n1. Verify the new content-hash based behavior is correct\n2. Check that the remapped issue (not necessarily local/remote) has the expected content\n3. Ensure dependencies are preserved on the correct remapped issue\n\n## Acceptance Criteria\n- All three autoimport tests pass\n- Tests verify content-hash determinism (same collision always resolves the same way)\n- Tests check dependency preservation on remapped issues\n- Test documentation explains content-hash scoring expectations\n\n## Files to Modify\n- `cmd/bd/autoimport_collision_test.go`\n\n## Testing\nRun: `go test ./cmd/bd -run \"TestAutoImport.*Collision\" -v`","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T19:17:28.358028-07:00","updated_at":"2025-10-28T19:17:28.358028-07:00"} {"id":"bd-102","title":"Repair Commands \u0026 AI-Assisted Tooling","description":"Add specialized repair tools to reduce agent repair burden:\n1. Git merge conflicts in JSONL\n2. Duplicate issues from parallel work\n3. Semantic inconsistencies\n4. Orphaned references\n\nSee ~/src/fred/beads/repair_commands.md for full design doc.\n\nReduces agent repair time from 5-10 minutes to \u003c30 seconds per repair.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-28T19:30:17.465812-07:00","updated_at":"2025-10-28T19:30:17.465812-07:00"} -{"id":"bd-103","title":"Implement bd resolve-conflicts (git merge conflicts in JSONL)","description":"Automatically detect and resolve git merge conflicts in .beads/issues.jsonl file.\n\nFeatures:\n- Detect conflict markers in JSONL\n- Parse conflicting issues from HEAD and BASE\n- Provide mechanical resolution (remap duplicate IDs)\n- Support AI-assisted resolution (requires internal/ai package)\n\nSee repair_commands.md lines 125-353 for design.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T19:37:55.722827-07:00","updated_at":"2025-10-28T19:37:55.722827-07:00","dependencies":[{"issue_id":"bd-103","depends_on_id":"bd-56","type":"parent-child","created_at":"2025-10-28T19:37:55.723772-07:00","created_by":"daemon"}]} +{"id":"bd-103","title":"Implement bd resolve-conflicts (git merge conflicts in JSONL)","description":"Automatically detect and resolve git merge conflicts in .beads/issues.jsonl file.\n\nFeatures:\n- Detect conflict markers in JSONL\n- Parse conflicting issues from HEAD and BASE\n- Provide mechanical resolution (remap duplicate IDs)\n- Support AI-assisted resolution (requires internal/ai package)\n\nSee repair_commands.md lines 125-353 for design.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T19:37:55.722827-07:00","updated_at":"2025-10-28T19:37:55.722827-07:00"} {"id":"bd-104","title":"Add MCP server functions for repair commands","description":"Expose new repair commands via MCP server for agent access:\n\nFunctions to add:\n- beads_repair_deps()\n- beads_detect_pollution()\n- beads_validate()\n- beads_resolve_conflicts() (when implemented)\n\nUpdate integrations/beads-mcp/src/beads_mcp/server.py\n\nSee repair_commands.md lines 803-884 for design.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T19:37:55.72639-07:00","updated_at":"2025-10-28T19:37:55.72639-07:00"} -{"id":"bd-105","title":"Add internal/ai package for AI-assisted repairs","description":"Add AI integration package to support AI-powered repair commands.\n\nProviders:\n- Anthropic (Claude)\n- OpenAI\n- Ollama (local)\n\nFeatures:\n- Conflict resolution analysis\n- Duplicate detection via embeddings\n- Configuration via env vars (BEADS_AI_PROVIDER, BEADS_AI_API_KEY, etc.)\n\nSee repair_commands.md lines 357-425 for design.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T19:37:55.722841-07:00","updated_at":"2025-10-28T19:37:55.722841-07:00","dependencies":[{"issue_id":"bd-105","depends_on_id":"bd-56","type":"parent-child","created_at":"2025-10-28T19:37:55.727832-07:00","created_by":"daemon"}]} +{"id":"bd-105","title":"Add internal/ai package for AI-assisted repairs","description":"Add AI integration package to support AI-powered repair commands.\n\nProviders:\n- Anthropic (Claude)\n- OpenAI\n- Ollama (local)\n\nFeatures:\n- Conflict resolution analysis\n- Duplicate detection via embeddings\n- Configuration via env vars (BEADS_AI_PROVIDER, BEADS_AI_API_KEY, etc.)\n\nSee repair_commands.md lines 357-425 for design.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T19:37:55.722841-07:00","updated_at":"2025-10-28T19:37:55.722841-07:00"} {"id":"bd-106","title":"Add MCP server functions for repair commands","description":"Expose new repair commands via MCP server for agent access:\n\nFunctions to add:\n- beads_repair_deps()\n- beads_detect_pollution()\n- beads_validate()\n- beads_resolve_conflicts() (when implemented)\n\nUpdate integrations/beads-mcp/src/beads_mcp/server.py\n\nSee repair_commands.md lines 803-884 for design.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T19:38:02.227921-07:00","updated_at":"2025-10-28T19:38:02.227921-07:00"} {"id":"bd-107","title":"Add TestNWayCollision for 5+ clones","description":"## Overview\nAdd comprehensive tests for N-way (5+) collision resolution to verify the solution scales beyond 3 clones.\n\n## Purpose\nWhile TestThreeCloneCollision validates the basic N-way case, we need to verify:\n1. Solution scales to arbitrary N\n2. Performance is acceptable with more clones\n3. Convergence time is bounded\n4. No edge cases in larger collision groups\n\n## Implementation Tasks\n\n### 1. Create TestFiveCloneCollision\nFile: beads_twoclone_test.go (or new beads_nway_test.go)\n\n```go\nfunc TestFiveCloneCollision(t *testing.T) {\n // Test with 5 clones creating same ID with different content\n // Verify all 5 clones converge after sync rounds\n \n t.Run(\"SequentialSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"A\", \"B\", \"C\", \"D\", \"E\")\n })\n \n t.Run(\"ReverseSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"E\", \"D\", \"C\", \"B\", \"A\")\n })\n \n t.Run(\"RandomSync\", func(t *testing.T) {\n testNCloneCollision(t, 5, \"C\", \"A\", \"E\", \"B\", \"D\")\n })\n}\n```\n\n### 2. Implement generalized testNCloneCollision\nGeneralize the 3-clone test to handle arbitrary N:\n\n```go\nfunc testNCloneCollision(t *testing.T, numClones int, syncOrder ...string) {\n t.Helper()\n \n if len(syncOrder) != numClones {\n t.Fatalf(\"syncOrder length (%d) must match numClones (%d)\", \n len(syncOrder), numClones)\n }\n \n tmpDir := t.TempDir()\n \n // Setup remote and N clones\n remoteDir := setupBareRepo(t, tmpDir)\n cloneDirs := make(map[string]string)\n \n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name)\n }\n \n // Each clone creates issue with same ID but different content\n for name, dir := range cloneDirs {\n createIssue(t, dir, fmt.Sprintf(\"Issue from clone %s\", name))\n }\n \n // Sync in specified order\n for _, name := range syncOrder {\n syncClone(t, cloneDirs[name], name)\n }\n \n // Final pull for convergence\n for name, dir := range cloneDirs {\n finalPull(t, dir, name)\n }\n \n // Verify all clones have all N issues\n expectedTitles := make(map[string]bool)\n for i := 0; i \u003c numClones; i++ {\n name := string(rune('A' + i))\n expectedTitles[fmt.Sprintf(\"Issue from clone %s\", name)] = true\n }\n \n for name, dir := range cloneDirs {\n titles := getTitles(t, dir)\n if !compareTitleSets(titles, expectedTitles) {\n t.Errorf(\"Clone %s missing issues: expected %v, got %v\", \n name, expectedTitles, titles)\n }\n }\n \n t.Log(\"✓ All\", numClones, \"clones converged successfully\")\n}\n```\n\n### 3. Add performance benchmarks\nTest convergence time and memory usage:\n\n```go\nfunc BenchmarkNWayCollision(b *testing.B) {\n for _, n := range []int{3, 5, 10, 20} {\n b.Run(fmt.Sprintf(\"N=%d\", n), func(b *testing.B) {\n for i := 0; i \u003c b.N; i++ {\n // Run N-way collision and measure time\n testNCloneCollisionBench(b, n)\n }\n })\n }\n}\n```\n\n### 4. Add convergence time tests\nVerify bounded convergence:\n\n```go\nfunc TestConvergenceTime(t *testing.T) {\n // Test that convergence happens within expected rounds\n // For N clones, should converge in at most N-1 sync rounds\n \n for n := 3; n \u003c= 10; n++ {\n t.Run(fmt.Sprintf(\"N=%d\", n), func(t *testing.T) {\n rounds := measureConvergenceRounds(t, n)\n maxExpected := n - 1\n if rounds \u003e maxExpected {\n t.Errorf(\"Convergence took %d rounds, expected ≤ %d\", \n rounds, maxExpected)\n }\n })\n }\n}\n```\n\n### 5. Add edge case tests\nTest boundary conditions:\n- All N clones have identical content (dedup works)\n- N-1 clones have same content, 1 differs\n- All N clones have unique content\n- Mix of collisions and non-collisions\n\n## Acceptance Criteria\n- TestFiveCloneCollision passes with all sync orders\n- All 5 clones converge to identical content\n- Performance is acceptable (\u003c 5 seconds for 5 clones)\n- Convergence time is bounded (≤ N-1 rounds)\n- Edge cases handled correctly\n- Benchmarks show scalability to 10+ clones\n\n## Files to Create/Modify\n- beads_twoclone_test.go or beads_nway_test.go\n- Add helper functions for N-clone setup\n\n## Testing Strategy\n\n### Test Matrix\n| N Clones | Sync Orders | Expected Result |\n|----------|-------------|-----------------|\n| 3 | A→B→C | Pass |\n| 3 | C→B→A | Pass |\n| 5 | A→B→C→D→E | Pass |\n| 5 | E→D→C→B→A | Pass |\n| 5 | Random | Pass |\n| 10 | Sequential | Pass |\n\n### Performance Targets\n- 3 clones: \u003c 2 seconds\n- 5 clones: \u003c 5 seconds\n- 10 clones: \u003c 15 seconds\n\n## Dependencies\n- Requires bd-95, bd-96, bd-97, bd-98 to be completed\n- TestThreeCloneCollision must pass first\n\n## Success Metrics\n- All tests pass for N ∈ {3, 5, 10}\n- Convergence time scales linearly (O(N))\n- Memory usage reasonable (\u003c 100MB for 10 clones)\n- No data corruption or loss in any scenario","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T20:41:26.718542-07:00","updated_at":"2025-10-28T20:41:26.718542-07:00"} {"id":"bd-11","title":"Update AGENTS.md and README.md with \"bd daemons\" documentation","description":"Document the new \"bd daemons\" command and all subcommands in AGENTS.md and README.md. Include examples and troubleshooting guidance.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T19:41:11.099254-07:00","updated_at":"2025-10-27T22:22:23.815967-07:00"} @@ -57,17 +57,16 @@ {"id":"bd-53","title":"Remove Daemon Storage Cache","description":"The daemon's multi-repo storage cache is the root cause of stale data bugs. Since global daemon is deprecated, we only ever serve one repository, making the cache unnecessary complexity. This epic removes the cache entirely for simpler, more reliable direct storage access.","design":"For local daemon (single repository), eliminate the cache entirely:\n- Use s.storage field directly (opened at daemon startup)\n- Remove getStorageForRequest() routing logic\n- Remove server_cache_storage.go entirely (~300 lines)\n- Remove cache-related tests\n- Simplify Server struct\n\nBenefits:\n✅ No staleness bugs: Always using live SQLite connection\n✅ Simpler code: Remove ~300 lines of cache management\n✅ Easier debugging: Direct storage access, no cache indirection\n✅ Same performance: Cache was always 1 entry for local daemon anyway","acceptance_criteria":"- Daemon has no storage cache code\n- All tests pass\n- MCP integration works\n- No stale data bugs\n- Documentation updated\n- Performance validated","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-10-28T10:50:15.126939-07:00","updated_at":"2025-10-28T10:50:15.126939-07:00","closed_at":"2025-10-28T10:49:53.612049-07:00"} {"id":"bd-54","title":"Fix TestTwoCloneCollision timeout","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-28T14:11:25.219607-07:00","updated_at":"2025-10-28T16:12:26.286611-07:00","closed_at":"2025-10-28T16:12:26.286611-07:00"} {"id":"bd-55","title":"Split internal/rpc/server.go into focused modules","description":"The file `internal/rpc/server.go` is 2,273 lines with 50+ methods, making it difficult to navigate and prone to merge conflicts. Split into 8 focused files with clear responsibilities.\n\nCurrent structure: Single 2,273-line file with:\n- Connection handling\n- Request routing\n- All 40+ RPC method implementations\n- Storage caching\n- Health checks \u0026 metrics\n- Cleanup loops\n\nTarget structure:\n```\ninternal/rpc/\n├── server.go # Core server, connection handling (~300 lines)\n├── methods_issue.go # Issue operations (~400 lines)\n├── methods_deps.go # Dependency operations (~200 lines)\n├── methods_labels.go # Label operations (~150 lines)\n├── methods_ready.go # Ready work queries (~150 lines)\n├── methods_compact.go # Compaction operations (~200 lines)\n├── methods_comments.go # Comment operations (~150 lines)\n├── storage_cache.go # Storage caching logic (~300 lines)\n└── health.go # Health \u0026 metrics (~200 lines)\n```\n\nMigration strategy:\n1. Create new files with appropriate methods\n2. Keep `server.go` as main file with core server logic\n3. Test incrementally after each file split\n4. Final verification with full test suite","acceptance_criteria":"- All 50 methods split into appropriate files\n- Each file \u003c500 LOC\n- All methods remain on `*Server` receiver (no behavior change)\n- All tests pass: `go test ./internal/rpc/...`\n- Verify daemon works: start daemon, run operations, check health\n- Update internal documentation if needed\n- No change to public API","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T14:21:37.51524-07:00","updated_at":"2025-10-28T14:21:37.51524-07:00","closed_at":"2025-10-28T14:11:04.399811-07:00"} -{"id":"bd-56","title":"Repair Commands \u0026 AI-Assisted Tooling","description":"Add specialized repair tools to reduce agent repair burden:\n1. Git merge conflicts in JSONL\n2. Duplicate issues from parallel work\n3. Semantic inconsistencies\n4. Orphaned references\n\nSee ~/src/fred/beads/repair_commands.md for full design doc.\n\nReduces agent repair time from 5-10 minutes to \u003c30 seconds per repair.","notes":"Completed Phase 1 (Mechanical Commands):\n✅ bd repair-deps - Find and fix orphaned dependency references\n✅ bd detect-pollution - Detect test issues using pattern matching\n✅ bd validate - Comprehensive health check (runs all checks)\n✅ bd duplicates (already exists) - Find and merge duplicate issues\n\nRemaining work:\n- bd-103: Implement bd resolve-conflicts (git merge conflict resolver)\n- bd-105: Add internal/ai package for AI-assisted repairs\n- bd-106: Add MCP server functions for repair commands\n\nCommands are functional and tested. Agent repair time reduced from manual editing to single command invocation.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-10-28T14:48:01.518627-07:00","updated_at":"2025-10-28T19:38:15.904233-07:00","closed_at":"2025-10-28T19:38:15.904233-07:00"} {"id":"bd-57","title":"bd resolve-conflicts - Git merge conflict resolver","description":"Automatically resolve JSONL merge conflicts.\n\nModes:\n- Mechanical: ID remapping (no AI)\n- AI-assisted: Smart merge/keep decisions\n- Interactive: Review each conflict\n\nHandles \u003c\u003c\u003c\u003c\u003c\u003c\u003c conflict markers in .beads/beads.jsonl\n\nFiles: cmd/bd/resolve_conflicts.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.457619-07:00","updated_at":"2025-10-28T15:47:33.037021-07:00","closed_at":"2025-10-28T15:47:33.037021-07:00"} -{"id":"bd-58","title":"bd repair-deps - Orphaned dependency cleaner","description":"Find and fix orphaned dependency references.\n\nImplementation:\n- Scan all issues for dependencies pointing to non-existent issues\n- Report orphaned refs\n- Auto-fix with --fix flag\n- Interactive mode with --interactive\n\nFiles: cmd/bd/repair_deps.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.458319-07:00","updated_at":"2025-10-28T14:48:17.458319-07:00","dependencies":[{"issue_id":"bd-58","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:17.458954-07:00","created_by":"daemon"}]} -{"id":"bd-59","title":"bd find-duplicates - AI-powered duplicate detection","description":"Find semantically duplicate issues.\n\nApproaches:\n1. Mechanical: Exact title/description matching\n2. Embeddings: Cosine similarity (cheap, scalable)\n3. AI: LLM-based semantic comparison (expensive, accurate)\n\nUses embeddings by default for \u003e100 issues.\n\nFiles: cmd/bd/find_duplicates.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.45938-07:00","updated_at":"2025-10-28T14:48:17.45938-07:00","dependencies":[{"issue_id":"bd-59","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:17.459803-07:00","created_by":"daemon"}]} +{"id":"bd-58","title":"bd repair-deps - Orphaned dependency cleaner","description":"Find and fix orphaned dependency references.\n\nImplementation:\n- Scan all issues for dependencies pointing to non-existent issues\n- Report orphaned refs\n- Auto-fix with --fix flag\n- Interactive mode with --interactive\n\nFiles: cmd/bd/repair_deps.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.458319-07:00","updated_at":"2025-10-28T14:48:17.458319-07:00"} +{"id":"bd-59","title":"bd find-duplicates - AI-powered duplicate detection","description":"Find semantically duplicate issues.\n\nApproaches:\n1. Mechanical: Exact title/description matching\n2. Embeddings: Cosine similarity (cheap, scalable)\n3. AI: LLM-based semantic comparison (expensive, accurate)\n\nUses embeddings by default for \u003e100 issues.\n\nFiles: cmd/bd/find_duplicates.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.45938-07:00","updated_at":"2025-10-28T14:48:17.45938-07:00"} {"id":"bd-6","title":"Add optional post-merge git hook example for bd sync","description":"Create example git hook that auto-runs bd sync after git pull/merge.\n\nAdd to examples/git-hooks/:\n- post-merge hook that checks if .beads/issues.jsonl changed\n- If changed: run `bd sync` automatically\n- Make it optional/documented (not auto-installed)\n\nBenefits:\n- Zero-friction sync after git pull\n- Complements auto-detection as belt-and-suspenders\n\nNote: post-merge hook already exists for pre-commit/post-merge. Extend it to support sync.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-25T22:47:14.668842-07:00","updated_at":"2025-10-27T22:22:23.814647-07:00"} -{"id":"bd-60","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-108, bd-115, bd-113, bd-153.\n\nFiles: cmd/bd/validate.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.461747-07:00","updated_at":"2025-10-28T16:26:17.484911-07:00","dependencies":[{"issue_id":"bd-60","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:17.462332-07:00","created_by":"daemon"}]} -{"id":"bd-61","title":"bd detect-pollution - Test pollution detector","description":"Detect test issues that leaked into production DB.\n\nPattern matching for:\n- Titles starting with 'test', 'benchmark', 'sample'\n- Sequential numbering (test-1, test-2)\n- Generic descriptions\n- Created in rapid succession\n\nOptional AI scoring for confidence.\n\nFiles: cmd/bd/detect_pollution.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.466906-07:00","updated_at":"2025-10-28T14:48:17.466906-07:00","dependencies":[{"issue_id":"bd-61","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:17.467489-07:00","created_by":"daemon"}]} -{"id":"bd-62","title":"Add MCP functions for repair commands","description":"Add repair commands to beads-mcp for agent access:\n- beads_resolve_conflicts()\n- beads_find_duplicates()\n- beads_detect_pollution()\n- beads_validate()\n\nFiles: integrations/beads-mcp/src/beads_mcp/server.py","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T14:48:29.071495-07:00","updated_at":"2025-10-28T14:48:29.071495-07:00","dependencies":[{"issue_id":"bd-62","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:29.072127-07:00","created_by":"daemon"}]} -{"id":"bd-63","title":"Add internal/ai package for LLM integration","description":"Shared AI client for repair commands.\n\nProviders:\n- Anthropic (Claude)\n- OpenAI (GPT)\n- Ollama (local)\n\nEnv vars:\n- BEADS_AI_PROVIDER\n- BEADS_AI_API_KEY\n- BEADS_AI_MODEL\n\nFiles: internal/ai/client.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:29.072473-07:00","updated_at":"2025-10-28T14:48:29.072473-07:00","dependencies":[{"issue_id":"bd-63","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:29.073553-07:00","created_by":"daemon"}]} -{"id":"bd-64","title":"Add embedding generation for duplicate detection","description":"Use embeddings for scalable duplicate detection.\n\nModel: text-embedding-3-small (OpenAI) or all-MiniLM-L6-v2 (local)\nStorage: SQLite vector extension or in-memory\nCost: ~/bin/bash.0002 per 100 issues\n\nMuch cheaper than LLM comparisons for large databases.\n\nFiles: internal/embeddings/ (new package)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T14:48:29.072913-07:00","updated_at":"2025-10-28T14:48:29.072913-07:00","dependencies":[{"issue_id":"bd-64","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:29.07486-07:00","created_by":"daemon"}]} -{"id":"bd-65","title":"bd resolve-conflicts - Git merge conflict resolver","description":"Automatically resolve JSONL merge conflicts.\n\nModes:\n- Mechanical: ID remapping (no AI)\n- AI-assisted: Smart merge/keep decisions\n- Interactive: Review each conflict\n\nHandles \u003c\u003c\u003c\u003c\u003c\u003c\u003c conflict markers in .beads/beads.jsonl\n\nFiles: cmd/bd/resolve_conflicts.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:30.083642-07:00","updated_at":"2025-10-28T14:48:30.083642-07:00","dependencies":[{"issue_id":"bd-65","depends_on_id":"bd-56","type":"blocks","created_at":"2025-10-28T14:48:30.084575-07:00","created_by":"daemon"}]} +{"id":"bd-60","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-108, bd-115, bd-113, bd-153.\n\nFiles: cmd/bd/validate.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.461747-07:00","updated_at":"2025-10-28T16:26:17.484911-07:00"} +{"id":"bd-61","title":"bd detect-pollution - Test pollution detector","description":"Detect test issues that leaked into production DB.\n\nPattern matching for:\n- Titles starting with 'test', 'benchmark', 'sample'\n- Sequential numbering (test-1, test-2)\n- Generic descriptions\n- Created in rapid succession\n\nOptional AI scoring for confidence.\n\nFiles: cmd/bd/detect_pollution.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:17.466906-07:00","updated_at":"2025-10-28T14:48:17.466906-07:00"} +{"id":"bd-62","title":"Add MCP functions for repair commands","description":"Add repair commands to beads-mcp for agent access:\n- beads_resolve_conflicts()\n- beads_find_duplicates()\n- beads_detect_pollution()\n- beads_validate()\n\nFiles: integrations/beads-mcp/src/beads_mcp/server.py","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T14:48:29.071495-07:00","updated_at":"2025-10-28T14:48:29.071495-07:00"} +{"id":"bd-63","title":"Add internal/ai package for LLM integration","description":"Shared AI client for repair commands.\n\nProviders:\n- Anthropic (Claude)\n- OpenAI (GPT)\n- Ollama (local)\n\nEnv vars:\n- BEADS_AI_PROVIDER\n- BEADS_AI_API_KEY\n- BEADS_AI_MODEL\n\nFiles: internal/ai/client.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:29.072473-07:00","updated_at":"2025-10-28T14:48:29.072473-07:00"} +{"id":"bd-64","title":"Add embedding generation for duplicate detection","description":"Use embeddings for scalable duplicate detection.\n\nModel: text-embedding-3-small (OpenAI) or all-MiniLM-L6-v2 (local)\nStorage: SQLite vector extension or in-memory\nCost: ~/bin/bash.0002 per 100 issues\n\nMuch cheaper than LLM comparisons for large databases.\n\nFiles: internal/embeddings/ (new package)","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-28T14:48:29.072913-07:00","updated_at":"2025-10-28T14:48:29.072913-07:00"} +{"id":"bd-65","title":"bd resolve-conflicts - Git merge conflict resolver","description":"Automatically resolve JSONL merge conflicts.\n\nModes:\n- Mechanical: ID remapping (no AI)\n- AI-assisted: Smart merge/keep decisions\n- Interactive: Review each conflict\n\nHandles \u003c\u003c\u003c\u003c\u003c\u003c\u003c conflict markers in .beads/beads.jsonl\n\nFiles: cmd/bd/resolve_conflicts.go (new)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-28T14:48:30.083642-07:00","updated_at":"2025-10-28T14:48:30.083642-07:00"} {"id":"bd-66","title":"Add fallback to polling on watcher failure","description":"Detect fsnotify.NewWatcher() errors and log warning. Auto-switch to polling mode with 5s ticker. Add BEADS_WATCHER_FALLBACK env var to control behavior.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.428439-07:00","updated_at":"2025-10-28T19:23:43.595916-07:00","closed_at":"2025-10-28T19:23:43.595916-07:00"} {"id":"bd-67","title":"Create cmd/bd/daemon_event_loop.go (~200 LOC)","description":"Implement runEventDrivenLoop to replace polling ticker. Coordinate FileWatcher, mutation events, debouncer. Include health check ticker (60s) for daemon validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.429383-07:00","updated_at":"2025-10-28T16:20:02.429383-07:00","closed_at":"2025-10-28T12:30:44.067036-07:00"} {"id":"bd-68","title":"Add fsnotify dependency to go.mod","description":"","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.429763-07:00","updated_at":"2025-10-28T16:20:02.429763-07:00"} diff --git a/beads_nway_test.go b/beads_nway_test.go new file mode 100644 index 00000000..8480dae6 --- /dev/null +++ b/beads_nway_test.go @@ -0,0 +1,723 @@ +package beads_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestFiveCloneCollision tests 5-way collision resolution with different sync orders +func TestFiveCloneCollision(t *testing.T) { + t.Run("SequentialSync", func(t *testing.T) { + testNCloneCollision(t, 5, "A", "B", "C", "D", "E") + }) + + t.Run("ReverseSync", func(t *testing.T) { + testNCloneCollision(t, 5, "E", "D", "C", "B", "A") + }) + + t.Run("RandomSync", func(t *testing.T) { + testNCloneCollision(t, 5, "C", "A", "E", "B", "D") + }) +} + +// TestTenCloneCollision tests scalability to larger collision groups +func TestTenCloneCollision(t *testing.T) { + if testing.Short() { + t.Skip("Skipping 10-clone test in short mode") + } + + // Generate sync order: A, B, C, ..., J + syncOrder := make([]string, 10) + for i := 0; i < 10; i++ { + syncOrder[i] = string(rune('A' + i)) + } + + testNCloneCollision(t, 10, syncOrder...) +} + +// testNCloneCollision is a generalized N-way collision test +// It creates N clones, has each create an issue with the same ID but different content, +// syncs them in the specified order, and verifies all clones converge +func testNCloneCollision(t *testing.T, numClones int, syncOrder ...string) { + t.Helper() + + if len(syncOrder) != numClones { + t.Fatalf("syncOrder length (%d) must match numClones (%d)", + len(syncOrder), numClones) + } + + tmpDir := t.TempDir() + + // Get path to bd binary + bdPath, err := filepath.Abs("./bd") + if err != nil { + t.Fatalf("Failed to get bd path: %v", err) + } + if _, err := os.Stat(bdPath); err != nil { + t.Fatalf("bd binary not found at %s - run 'go build -o bd ./cmd/bd' first", bdPath) + } + + // Setup remote and N clones + t.Logf("Setting up %d clones", numClones) + remoteDir := setupBareRepo(t, tmpDir) + cloneDirs := make(map[string]string) + + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name, bdPath) + } + + // Each clone creates issue with same ID but different content + t.Logf("Each clone creating unique issue") + for name, dir := range cloneDirs { + createIssue(t, dir, fmt.Sprintf("Issue from clone %s", name)) + } + + // Sync in specified order + t.Logf("Syncing in order: %v", syncOrder) + for i, name := range syncOrder { + syncClone(t, cloneDirs[name], name, i == 0) + } + + // Final pull for convergence + t.Log("Final pull for all clones to converge") + for name, dir := range cloneDirs { + finalPull(t, dir, name) + } + + // Verify all clones have all N issues + expectedTitles := make(map[string]bool) + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + expectedTitles[fmt.Sprintf("Issue from clone %s", name)] = true + } + + t.Logf("Verifying all %d clones have all %d issues", numClones, numClones) + for name, dir := range cloneDirs { + titles := getTitles(t, dir) + if !compareTitleSets(titles, expectedTitles) { + t.Errorf("Clone %s missing issues:\nExpected: %v\nGot: %v", + name, expectedTitles, titles) + } + } + + t.Logf("✓ All %d clones converged successfully", numClones) +} + +// TestEdgeCases tests boundary conditions for N-way collisions +func TestEdgeCases(t *testing.T) { + t.Run("AllIdenticalContent", func(t *testing.T) { + testNCloneIdenticalContent(t, 5) + }) + + t.Run("OneDifferent", func(t *testing.T) { + testNCloneOneDifferent(t, 5) + }) + + t.Run("MixedCollisions", func(t *testing.T) { + testMixedCollisions(t, 5) + }) +} + +// testNCloneIdenticalContent tests deduplication when all clones create identical issues +func testNCloneIdenticalContent(t *testing.T, numClones int) { + t.Helper() + tmpDir := t.TempDir() + + bdPath, err := filepath.Abs("./bd") + if err != nil { + t.Fatalf("Failed to get bd path: %v", err) + } + + remoteDir := setupBareRepo(t, tmpDir) + cloneDirs := make(map[string]string) + + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name, bdPath) + } + + // All clones create identical issue + identicalTitle := "Identical issue from all clones" + for _, dir := range cloneDirs { + createIssue(t, dir, identicalTitle) + } + + // Sync all clones + syncOrder := make([]string, numClones) + for i := 0; i < numClones; i++ { + syncOrder[i] = string(rune('A' + i)) + syncClone(t, cloneDirs[syncOrder[i]], syncOrder[i], i == 0) + } + + // Final pull + for name, dir := range cloneDirs { + finalPull(t, dir, name) + } + + // Should have exactly 1 issue (deduplicated) + for name, dir := range cloneDirs { + titles := getTitles(t, dir) + if len(titles) != 1 { + t.Errorf("Clone %s: expected 1 issue, got %d: %v", name, len(titles), titles) + } + if !titles[identicalTitle] { + t.Errorf("Clone %s: missing expected title %q", name, identicalTitle) + } + } + + t.Logf("✓ All %d clones deduplicated to 1 issue", numClones) +} + +// testNCloneOneDifferent tests N-1 clones with same content, 1 different +func testNCloneOneDifferent(t *testing.T, numClones int) { + t.Helper() + tmpDir := t.TempDir() + + bdPath, err := filepath.Abs("./bd") + if err != nil { + t.Fatalf("Failed to get bd path: %v", err) + } + + remoteDir := setupBareRepo(t, tmpDir) + cloneDirs := make(map[string]string) + + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name, bdPath) + } + + // First N-1 clones create identical issue, last one different + commonTitle := "Common issue" + differentTitle := "Different issue from last clone" + + for i := 0; i < numClones-1; i++ { + name := string(rune('A' + i)) + createIssue(t, cloneDirs[name], commonTitle) + } + lastClone := string(rune('A' + numClones - 1)) + createIssue(t, cloneDirs[lastClone], differentTitle) + + // Sync all + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + syncClone(t, cloneDirs[name], name, i == 0) + } + + // Final pull + for name, dir := range cloneDirs { + finalPull(t, dir, name) + } + + // Should have exactly 2 issues + expectedTitles := map[string]bool{ + commonTitle: true, + differentTitle: true, + } + + for name, dir := range cloneDirs { + titles := getTitles(t, dir) + if !compareTitleSets(titles, expectedTitles) { + t.Errorf("Clone %s:\nExpected: %v\nGot: %v", name, expectedTitles, titles) + } + } + + t.Logf("✓ All %d clones converged to 2 issues", numClones) +} + +// testMixedCollisions tests mix of collisions and non-collisions +func testMixedCollisions(t *testing.T, numClones int) { + t.Helper() + tmpDir := t.TempDir() + + bdPath, err := filepath.Abs("./bd") + if err != nil { + t.Fatalf("Failed to get bd path: %v", err) + } + + remoteDir := setupBareRepo(t, tmpDir) + cloneDirs := make(map[string]string) + + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name, bdPath) + } + + // Each clone creates 2 issues: + // - One unique issue + // - One colliding issue (same ID across all clones) + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + dir := cloneDirs[name] + + // Unique issue + createIssue(t, dir, fmt.Sprintf("Unique issue from clone %s", name)) + + // Colliding issue (same ID, different content) + createIssue(t, dir, fmt.Sprintf("Colliding issue from clone %s", name)) + } + + // Sync all + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + syncClone(t, cloneDirs[name], name, i == 0) + } + + // Final pull + for name, dir := range cloneDirs { + finalPull(t, dir, name) + } + + // Should have 2*N issues (N unique + N from collision) + expectedTitles := make(map[string]bool) + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + expectedTitles[fmt.Sprintf("Unique issue from clone %s", name)] = true + expectedTitles[fmt.Sprintf("Colliding issue from clone %s", name)] = true + } + + for name, dir := range cloneDirs { + titles := getTitles(t, dir) + if !compareTitleSets(titles, expectedTitles) { + t.Errorf("Clone %s:\nExpected: %v\nGot: %v", name, expectedTitles, titles) + } + } + + t.Logf("✓ All %d clones converged to %d issues", numClones, 2*numClones) +} + +// Helper functions + +func setupBareRepo(t *testing.T, tmpDir string) string { + t.Helper() + remoteDir := filepath.Join(tmpDir, "remote.git") + runCmdQuiet(t, tmpDir, "git", "init", "--bare", remoteDir) + + // Create initial commit + tempClone := filepath.Join(tmpDir, "temp-init") + runCmdQuiet(t, tmpDir, "git", "clone", remoteDir, tempClone) + runCmdQuiet(t, tempClone, "git", "commit", "--allow-empty", "-m", "Initial commit") + runCmdQuiet(t, tempClone, "git", "push", "origin", "master") + + return remoteDir +} + +// runCmdQuiet runs a command suppressing all output +func runCmdQuiet(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("Command failed: %s %v\nError: %v", name, args, err) + } +} + +func setupClone(t *testing.T, tmpDir, remoteDir, name, bdPath string) string { + t.Helper() + cloneDir := filepath.Join(tmpDir, fmt.Sprintf("clone-%s", strings.ToLower(name))) + + runCmd(t, tmpDir, "git", "clone", "--quiet", remoteDir, cloneDir) + copyFile(t, bdPath, filepath.Join(cloneDir, "bd")) + + // Initialize beads only in first clone + if name == "A" { + runCmd(t, cloneDir, "./bd", "init", "--quiet", "--prefix", "test") + runCmd(t, cloneDir, "git", "add", ".beads") + runCmd(t, cloneDir, "git", "commit", "--quiet", "-m", "Initialize beads") + runCmd(t, cloneDir, "git", "push", "--quiet", "origin", "master") + } else { + // Pull beads initialization + runCmd(t, cloneDir, "git", "pull", "--quiet", "origin", "master") + runCmd(t, cloneDir, "./bd", "init", "--quiet", "--prefix", "test") + } + + installGitHooks(t, cloneDir) + + return cloneDir +} + +func createIssue(t *testing.T, dir, title string) { + t.Helper() + runCmd(t, dir, "./bd", "create", title, "-t", "task", "-p", "1", "--json") +} + +func syncClone(t *testing.T, dir, name string, isFirst bool) { + t.Helper() + + if isFirst { + t.Logf("%s syncing (first, clean push)", name) + runCmd(t, dir, "./bd", "sync") + waitForPush(t, dir, 2*time.Second) + return + } + + t.Logf("%s syncing (may conflict)", name) + syncOut := runCmdOutputAllowError(t, dir, "./bd", "sync") + + if strings.Contains(syncOut, "CONFLICT") || strings.Contains(syncOut, "Error") { + t.Logf("%s hit conflict, resolving", name) + runCmdAllowError(t, dir, "git", "rebase", "--abort") + + // Pull with merge + runCmdOutputAllowError(t, dir, "git", "pull", "--no-rebase", "origin", "master") + + // Resolve conflict markers + jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl") + jsonlContent, _ := os.ReadFile(jsonlPath) + if strings.Contains(string(jsonlContent), "<<<<<<<") { + 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" + os.WriteFile(jsonlPath, []byte(cleaned), 0644) + runCmd(t, dir, "git", "add", ".beads/issues.jsonl") + runCmd(t, dir, "git", "commit", "-m", "Resolve merge conflict") + } + + // Import with collision resolution + runCmd(t, dir, "./bd", "import", "-i", ".beads/issues.jsonl", "--resolve-collisions") + runCmd(t, dir, "git", "push", "origin", "master") + } +} + +func finalPull(t *testing.T, dir, name string) { + t.Helper() + + pullOut := runCmdOutputAllowError(t, dir, "git", "pull", "--no-rebase", "origin", "master") + + if strings.Contains(pullOut, "CONFLICT") { + jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl") + jsonlContent, _ := os.ReadFile(jsonlPath) + if strings.Contains(string(jsonlContent), "<<<<<<<") { + t.Logf("%s resolving final conflict", name) + 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" + os.WriteFile(jsonlPath, []byte(cleaned), 0644) + runCmd(t, dir, "git", "add", ".beads/issues.jsonl") + runCmd(t, dir, "git", "commit", "-m", "Resolve final merge conflict") + } + } + + // Import to sync database + runCmdOutputAllowError(t, dir, "./bd", "import", "-i", ".beads/issues.jsonl") + time.Sleep(500 * time.Millisecond) +} + +func getTitles(t *testing.T, dir string) map[string]bool { + t.Helper() + + // Get clean JSON output + listOut := runCmdOutput(t, dir, "./bd", "list", "--json") + + // Find the JSON array in the output (skip any prefix messages) + start := strings.Index(listOut, "[") + if start == -1 { + t.Logf("No JSON array found in output: %s", listOut) + return make(map[string]bool) + } + jsonData := listOut[start:] + + var issues []struct { + Title string `json:"title"` + } + if err := json.Unmarshal([]byte(jsonData), &issues); err != nil { + t.Logf("Failed to parse JSON: %v\nContent: %s", err, jsonData) + return make(map[string]bool) + } + + titles := make(map[string]bool) + for _, issue := range issues { + titles[issue.Title] = true + } + return titles +} + +// BenchmarkNWayCollision benchmarks N-way collision resolution performance +func BenchmarkNWayCollision(b *testing.B) { + for _, n := range []int{3, 5, 10} { + b.Run(fmt.Sprintf("N=%d", n), func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchNCloneCollision(b, n) + } + }) + } +} + +func benchNCloneCollision(b *testing.B, numClones int) { + b.Helper() + + tmpDir := b.TempDir() + + bdPath, err := filepath.Abs("./bd") + if err != nil { + b.Fatalf("Failed to get bd path: %v", err) + } + + remoteDir := setupBareRepoBench(b, tmpDir) + cloneDirs := make(map[string]string) + + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + cloneDirs[name] = setupCloneBench(b, tmpDir, remoteDir, name, bdPath) + } + + // Each clone creates issue + for name, dir := range cloneDirs { + createIssueBench(b, dir, fmt.Sprintf("Issue from clone %s", name)) + } + + // Sync in order + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + syncCloneBench(b, cloneDirs[name], name, i == 0) + } + + // Final pull + for _, dir := range cloneDirs { + finalPullBench(b, dir) + } +} + +// TestConvergenceTime verifies bounded convergence +func TestConvergenceTime(t *testing.T) { + if testing.Short() { + t.Skip("Skipping convergence time test in short mode") + } + + for _, n := range []int{3, 5, 7} { + n := n + t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) { + rounds := measureConvergenceRounds(t, n) + maxExpected := n + + t.Logf("Convergence took %d rounds for %d clones", rounds, n) + + if rounds > maxExpected { + t.Errorf("Convergence took %d rounds, expected ≤ %d", + rounds, maxExpected) + } + }) + } +} + +func measureConvergenceRounds(t *testing.T, numClones int) int { + t.Helper() + + tmpDir := t.TempDir() + + bdPath, err := filepath.Abs("./bd") + if err != nil { + t.Fatalf("Failed to get bd path: %v", err) + } + + remoteDir := setupBareRepo(t, tmpDir) + cloneDirs := make(map[string]string) + + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + cloneDirs[name] = setupClone(t, tmpDir, remoteDir, name, bdPath) + } + + // Each clone creates issue + for name, dir := range cloneDirs { + createIssue(t, dir, fmt.Sprintf("Issue from clone %s", name)) + } + + // Initial sync round (first clone pushes, others pull and resolve) + rounds := 1 + + // First clone syncs + firstClone := "A" + syncClone(t, cloneDirs[firstClone], firstClone, true) + + // Other clones sync + for i := 1; i < numClones; i++ { + name := string(rune('A' + i)) + syncClone(t, cloneDirs[name], name, false) + } + + // Additional convergence rounds + expectedTitles := make(map[string]bool) + for i := 0; i < numClones; i++ { + name := string(rune('A' + i)) + expectedTitles[fmt.Sprintf("Issue from clone %s", name)] = true + } + + maxRounds := numClones * 2 + for round := 2; round <= maxRounds; round++ { + allConverged := true + + // Each clone pulls + for name, dir := range cloneDirs { + finalPull(t, dir, name) + + titles := getTitles(t, dir) + if !compareTitleSets(titles, expectedTitles) { + allConverged = false + } + } + + if allConverged { + return round + } + + rounds = round + } + + return rounds +} + +// Benchmark helper functions + +func setupBareRepoBench(b *testing.B, tmpDir string) string { + b.Helper() + remoteDir := filepath.Join(tmpDir, "remote.git") + runCmdBench(b, tmpDir, "git", "init", "--bare", "--quiet", remoteDir) + + tempClone := filepath.Join(tmpDir, "temp-init") + runCmdBench(b, tmpDir, "git", "clone", "--quiet", remoteDir, tempClone) + runCmdBench(b, tempClone, "git", "commit", "--allow-empty", "-m", "Initial commit") + runCmdBench(b, tempClone, "git", "push", "--quiet", "origin", "master") + + return remoteDir +} + +func setupCloneBench(b *testing.B, tmpDir, remoteDir, name, bdPath string) string { + b.Helper() + cloneDir := filepath.Join(tmpDir, fmt.Sprintf("clone-%s", strings.ToLower(name))) + + runCmdBench(b, tmpDir, "git", "clone", "--quiet", remoteDir, cloneDir) + copyFileBench(b, bdPath, filepath.Join(cloneDir, "bd")) + + if name == "A" { + runCmdBench(b, cloneDir, "./bd", "init", "--quiet", "--prefix", "test") + runCmdBench(b, cloneDir, "git", "add", ".beads") + runCmdBench(b, cloneDir, "git", "commit", "--quiet", "-m", "Initialize beads") + runCmdBench(b, cloneDir, "git", "push", "--quiet", "origin", "master") + } else { + runCmdBench(b, cloneDir, "git", "pull", "--quiet", "origin", "master") + runCmdBench(b, cloneDir, "./bd", "init", "--quiet", "--prefix", "test") + } + + installGitHooksBench(b, cloneDir) + + return cloneDir +} + +func createIssueBench(b *testing.B, dir, title string) { + b.Helper() + runCmdBench(b, dir, "./bd", "create", title, "-t", "task", "-p", "1", "--json") +} + +func syncCloneBench(b *testing.B, dir, name string, isFirst bool) { + b.Helper() + + if isFirst { + runCmdBench(b, dir, "./bd", "sync") + return + } + + runCmdAllowErrorBench(b, dir, "./bd", "sync") + runCmdAllowErrorBench(b, dir, "git", "rebase", "--abort") + runCmdAllowErrorBench(b, dir, "git", "pull", "--no-rebase", "origin", "master") + + jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl") + jsonlContent, _ := os.ReadFile(jsonlPath) + if strings.Contains(string(jsonlContent), "<<<<<<<") { + 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" + os.WriteFile(jsonlPath, []byte(cleaned), 0644) + runCmdBench(b, dir, "git", "add", ".beads/issues.jsonl") + runCmdBench(b, dir, "git", "commit", "-m", "Resolve merge conflict") + } + + runCmdBench(b, dir, "./bd", "import", "-i", ".beads/issues.jsonl", "--resolve-collisions") + runCmdBench(b, dir, "git", "push", "origin", "master") +} + +func finalPullBench(b *testing.B, dir string) { + b.Helper() + runCmdAllowErrorBench(b, dir, "git", "pull", "--no-rebase", "origin", "master") + runCmdAllowErrorBench(b, dir, "./bd", "import", "-i", ".beads/issues.jsonl") +} + +func installGitHooksBench(b *testing.B, repoDir string) { + hooksDir := filepath.Join(repoDir, ".git", "hooks") + + preCommit := `#!/bin/sh +./bd --no-daemon export -o .beads/issues.jsonl >/dev/null 2>&1 || true +git add .beads/issues.jsonl >/dev/null 2>&1 || true +exit 0 +` + + postMerge := `#!/bin/sh +./bd --no-daemon import -i .beads/issues.jsonl >/dev/null 2>&1 || true +exit 0 +` + + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(preCommit), 0755); err != nil { + b.Fatalf("Failed to write pre-commit hook: %v", err) + } + + if err := os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(postMerge), 0755); err != nil { + b.Fatalf("Failed to write post-merge hook: %v", err) + } +} + +func runCmdBench(b *testing.B, dir string, name string, args ...string) { + b.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + b.Fatalf("Command failed: %s %v\nError: %v", name, args, err) + } +} + +func runCmdAllowErrorBench(b *testing.B, dir string, name string, args ...string) { + b.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + _ = cmd.Run() +} + +func copyFileBench(b *testing.B, src, dst string) { + b.Helper() + data, err := os.ReadFile(src) + if err != nil { + b.Fatalf("Failed to read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, 0755); err != nil { + b.Fatalf("Failed to write %s: %v", dst, err) + } +}