From f24573a5f81d6c545fb71a4d9be39f8a9d071ee9 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 26 Oct 2025 20:42:18 -0700 Subject: [PATCH] Enforce canonical database naming (beads.db) - bd-165 - Added CanonicalDatabaseName constant (beads.db) and LegacyDatabaseNames list - Updated bd init to use canonical name via constant - Added daemon validation to reject non-canonical database names - Updated bd migrate to use canonical name constant - Enhanced FindDatabasePath to warn when using legacy database names - All database discovery now prefers beads.db with backward compatibility Closes bd-165 --- .beads/bd.jsonl | 2 +- beads.go | 71 ++++++++++++++++++++++++++++++----------------- cmd/bd/daemon.go | 14 +++++++++- cmd/bd/init.go | 3 +- cmd/bd/migrate.go | 5 ++-- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/.beads/bd.jsonl b/.beads/bd.jsonl index 32bdaa87..6b45a732 100644 --- a/.beads/bd.jsonl +++ b/.beads/bd.jsonl @@ -70,7 +70,7 @@ {"id":"bd-161","title":"Implement daemon JSONL import (fix NO-OP stub)","description":"## Critical Bug\n\nThe daemon's sync loop calls importToJSONLWithStore() but this function is a NO-OP stub that returns nil without importing any changes.\n\n## Location\n\n**File**: cmd/bd/daemon.go:791-797\n\nCurrent implementation:\n```go\nfunc importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPath string) error {\n // TODO Phase 4: Implement direct import for daemon\n // Currently a no-op - daemon doesn't import git changes into DB\n return nil\n}\n```\n\n## Impact\n\nThis is the PRIMARY cause of bd-160. When the daemon:\n1. Exports DB → JSONL\n2. Commits changes\n3. Pulls from remote (gets other clone's changes)\n4. Calls importToJSONLWithStore() ← **Does nothing!**\n5. Pushes commits (overwrites remote with stale state)\n\nResult: Perpetual divergence between clones.\n\n## Implementation Approach\n\nReplace the NO-OP with actual import logic:\n\n```go\nfunc importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPath string) error {\n // Read JSONL file\n file, err := os.Open(jsonlPath)\n if err != nil {\n return fmt.Errorf(\"failed to open JSONL: %w\", err)\n }\n defer file.Close()\n \n // Parse all issues\n var issues []*types.Issue\n scanner := bufio.NewScanner(file)\n for scanner.Scan() {\n var issue types.Issue\n if err := json.Unmarshal(scanner.Bytes(), \u0026issue); err != nil {\n return fmt.Errorf(\"failed to parse issue: %w\", err)\n }\n issues = append(issues, \u0026issue)\n }\n \n if err := scanner.Err(); err != nil {\n return fmt.Errorf(\"failed to read JSONL: %w\", err)\n }\n \n // Use existing import logic with auto-conflict resolution\n opts := ImportOptions{\n ResolveCollisions: true, // Auto-resolve ID conflicts\n DryRun: false,\n SkipUpdate: false,\n Strict: false,\n }\n \n _, err = importIssuesCore(ctx, \"\", store, issues, opts)\n return err\n}\n```\n\n## Testing\n\nAfter implementation, test with:\n```bash\n# Create two clones\ngit init repo1 \u0026\u0026 cd repo1 \u0026\u0026 bd init \u0026\u0026 bd daemon\nbd new \"Issue A\"\ngit add . \u0026\u0026 git commit -m \"init\"\n\ncd .. \u0026\u0026 git clone repo1 repo2 \u0026\u0026 cd repo2 \u0026\u0026 bd init \u0026\u0026 bd daemon\n\n# Make changes in repo1\ncd ../repo1 \u0026\u0026 bd new \"Issue B\"\n\n# Wait for daemon sync, then check repo2\nsleep 10\ncd ../repo2 \u0026\u0026 bd list # Should show both Issue A and B\n```\n\n## Success Criteria\n\n- Daemon imports remote changes after git pull\n- Issue count converges across clones within one sync cycle\n- No manual intervention needed\n- Existing collision resolution logic handles conflicts\n\n## Estimated Effort\n\n30-60 minutes\n\n## Priority\n\nP0 - This is the critical path fix for bd-160","status":"closed","priority":0,"issue_type":"task","created_at":"2025-10-26T19:53:55.313039-07:00","updated_at":"2025-10-26T20:04:41.902916-07:00","closed_at":"2025-10-26T20:04:41.902916-07:00","dependencies":[{"issue_id":"bd-161","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:53:55.3136-07:00","created_by":"daemon"}]} {"id":"bd-162","title":"Add database integrity checks to sync operations","description":"## Problem\n\nWhen databases diverge (due to the import NO-OP bug or race conditions), there are no safety checks to detect or prevent catastrophic data loss.\n\nNeed integrity checks before/after sync operations to catch divergence early.\n\n## Implementation Locations\n\n**Pre-export checks** (cmd/bd/daemon.go:948, sync.go:108):\n- Before exportToJSONLWithStore()\n- Before exportToJSONL()\n\n**Post-import checks** (cmd/bd/daemon.go:985):\n- After importToJSONLWithStore()\n\n## Checks to Implement\n\n### 1. Database vs JSONL Count Divergence\n\nBefore export:\n```go\nfunc validatePreExport(store storage.Storage, jsonlPath string) error {\n dbIssues, _ := store.SearchIssues(ctx, \"\", types.IssueFilter{})\n dbCount := len(dbIssues)\n \n jsonlCount, _ := countIssuesInJSONL(jsonlPath)\n \n if dbCount == 0 \u0026\u0026 jsonlCount \u003e 0 {\n return fmt.Errorf(\"refusing to export empty DB over %d issues in JSONL\", jsonlCount)\n }\n \n divergencePercent := math.Abs(float64(dbCount-jsonlCount)) / float64(jsonlCount) * 100\n if divergencePercent \u003e 50 {\n log.Printf(\"WARNING: DB has %d issues, JSONL has %d (%.1f%% divergence)\", \n dbCount, jsonlCount, divergencePercent)\n log.Printf(\"This suggests sync failure - investigate before proceeding\")\n }\n \n return nil\n}\n```\n\n### 2. Duplicate ID Detection\n\n```go\nfunc checkDuplicateIDs(store storage.Storage) error {\n // Query for duplicate IDs\n rows, _ := db.Query(`\n SELECT id, COUNT(*) as cnt \n FROM issues \n GROUP BY id \n HAVING cnt \u003e 1\n `)\n \n var duplicates []string\n for rows.Next() {\n var id string\n var count int\n rows.Scan(\u0026id, \u0026count)\n duplicates = append(duplicates, fmt.Sprintf(\"%s (x%d)\", id, count))\n }\n \n if len(duplicates) \u003e 0 {\n return fmt.Errorf(\"database corruption: duplicate IDs: %v\", duplicates)\n }\n return nil\n}\n```\n\n### 3. Orphaned Dependencies\n\n```go\nfunc checkOrphanedDeps(store storage.Storage) ([]string, error) {\n // Find dependencies pointing to non-existent issues\n rows, _ := db.Query(`\n SELECT DISTINCT d.depends_on_id \n FROM dependencies d \n LEFT JOIN issues i ON d.depends_on_id = i.id \n WHERE i.id IS NULL\n `)\n \n var orphaned []string\n for rows.Next() {\n var id string\n rows.Scan(\u0026id)\n orphaned = append(orphaned, id)\n }\n \n if len(orphaned) \u003e 0 {\n log.Printf(\"WARNING: Found %d orphaned dependencies: %v\", len(orphaned), orphaned)\n }\n \n return orphaned, nil\n}\n```\n\n### 4. Post-Import Validation\n\nAfter import, verify:\n```go\nfunc validatePostImport(before, after int) error {\n if after \u003c before {\n return fmt.Errorf(\"import reduced issue count: %d → %d (data loss!)\", before, after)\n }\n if after == before {\n log.Printf(\"Import complete: no changes\")\n } else {\n log.Printf(\"Import complete: %d → %d issues (+%d)\", before, after, after-before)\n }\n return nil\n}\n```\n\n## Integration Points\n\nAdd to daemon sync loop (daemon.go:920-999):\n```go\n// Before export\nif err := validatePreExport(store, jsonlPath); err != nil {\n log.log(\"Pre-export validation failed: %v\", err)\n return\n}\n\n// Export...\n\n// Before import\nbeforeCount := countDBIssues(store)\n\n// Import...\n\n// After import\nafterCount := countDBIssues(store)\nif err := validatePostImport(beforeCount, afterCount); err != nil {\n log.log(\"Post-import validation failed: %v\", err)\n}\n```\n\n## Testing\n\nCreate test scenarios:\n1. Empty DB, non-empty JSONL → should error\n2. Duplicate IDs in DB → should error\n3. Orphaned dependencies → should warn\n4. Import reduces count → should error\n\n## Success Criteria\n\n- Catches divergence \u003e50% before export\n- Detects duplicate IDs\n- Reports orphaned dependencies\n- Validates import doesn't lose data\n- All checks logged clearly\n\n## Estimated Effort\n\n2-3 hours\n\n## Priority\n\nP0 - Safety checks prevent data loss during sync","status":"closed","priority":0,"issue_type":"task","created_at":"2025-10-26T19:54:22.558861-07:00","updated_at":"2025-10-26T20:17:37.981054-07:00","closed_at":"2025-10-26T20:17:37.981054-07:00","dependencies":[{"issue_id":"bd-162","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:54:22.55941-07:00","created_by":"daemon"}]} {"id":"bd-164","title":"Fix timestamp-only export deduplication (bd-159)","description":"## Problem\n\nExport deduplication logic is supposed to skip timestamp-only changes, but it's not working. This causes:\n- Spurious git commits every sync cycle\n- Increased race condition window\n- Harder to detect real changes\n- Amplifies bd-160 sync issues\n\nRelated to bd-159.\n\n## Location\n\n**File**: cmd/bd/export.go:236-246\n\nCurrent code clears dirty flags for all exported issues:\n```go\nif output == \"\" || output == findJSONLPath() {\n if err := store.ClearDirtyIssuesByID(ctx, exportedIDs); err != nil {\n fmt.Fprintf(os.Stderr, \"Warning: failed to clear dirty issues: %v\\n\", err)\n }\n clearAutoFlushState()\n}\n```\n\nProblem: No check whether issue actually changed (beyond timestamps).\n\n## Root Cause\n\nIssues are marked dirty on ANY update, including:\n- Timestamp updates (UpdatedAt field)\n- No-op updates (same values written)\n- Database reopens (sqlite WAL journal replays)\n\n## Implementation Approach\n\n### 1. Add Content Hash to dirty_issues Table\n\n```sql\nALTER TABLE dirty_issues ADD COLUMN content_hash TEXT;\n```\n\nThe hash should exclude timestamp fields:\n```go\nfunc computeIssueContentHash(issue *types.Issue) string {\n // Clone issue and zero out timestamps\n normalized := *issue\n normalized.CreatedAt = time.Time{}\n normalized.UpdatedAt = time.Time{}\n \n // Serialize to JSON\n data, _ := json.Marshal(normalized)\n \n // SHA256 hash\n hash := sha256.Sum256(data)\n return hex.EncodeToString(hash[:])\n}\n```\n\n### 2. Track Previous Export State\n\nStore issue snapshots in issue_snapshots table (already exists):\n```go\nfunc saveExportSnapshot(ctx context.Context, store storage.Storage, issue *types.Issue) error {\n snapshot := \u0026types.IssueSnapshot{\n IssueID: issue.ID,\n SnapshotAt: time.Now(),\n Title: issue.Title,\n Description: issue.Description,\n Status: issue.Status,\n // ... all fields except timestamps\n }\n return store.SaveSnapshot(ctx, snapshot)\n}\n```\n\n### 3. Deduplicate During Export\n\nIn export.go:\n```go\n// Before encoding each issue\nif shouldSkipExport(ctx, store, issue) {\n skippedCount++\n continue\n}\n\nfunc shouldSkipExport(ctx context.Context, store storage.Storage, issue *types.Issue) bool {\n // Get last exported snapshot\n snapshot, err := store.GetLatestSnapshot(ctx, issue.ID)\n if err != nil || snapshot == nil {\n return false // No snapshot, must export\n }\n \n // Compare content hash\n currentHash := computeIssueContentHash(issue)\n snapshotHash := computeSnapshotHash(snapshot)\n \n if currentHash == snapshotHash {\n // Timestamp-only change, skip\n log.Printf(\"Skipping %s (timestamp-only change)\", issue.ID)\n return true\n }\n \n return false\n}\n```\n\n### 4. Update on Real Export\n\nOnly save snapshot when actually exporting:\n```go\nfor _, issue := range issues {\n if shouldSkipExport(ctx, store, issue) {\n continue\n }\n \n if err := encoder.Encode(issue); err != nil {\n return err\n }\n \n // Save snapshot of exported state\n saveExportSnapshot(ctx, store, issue)\n exportedIDs = append(exportedIDs, issue.ID)\n}\n```\n\n## Alternative: Simpler Approach\n\nIf snapshot complexity is too much, use a simpler hash:\n\n```go\n// In dirty_issues table, store hash when marking dirty\nfunc markIssueDirty(ctx context.Context, issueID string, issue *types.Issue) error {\n hash := computeIssueContentHash(issue)\n \n _, err := db.Exec(`\n INSERT INTO dirty_issues (issue_id, content_hash) \n VALUES (?, ?)\n ON CONFLICT(issue_id) DO UPDATE SET content_hash = ?\n `, issueID, hash, hash)\n \n return err\n}\n\n// During export, check if hash changed\nfunc hasRealChanges(ctx context.Context, store storage.Storage, issue *types.Issue) bool {\n var storedHash string\n err := db.QueryRow(\"SELECT content_hash FROM dirty_issues WHERE issue_id = ?\", issue.ID).Scan(\u0026storedHash)\n if err != nil {\n return true // No stored hash, export it\n }\n \n currentHash := computeIssueContentHash(issue)\n return currentHash != storedHash\n}\n```\n\n## Testing\n\nTest cases:\n1. Update issue timestamp only → no export\n2. Update issue title → export\n3. Multiple timestamp updates → single export\n4. Database reopen → no spurious exports\n\nValidation:\n```bash\n# Start daemon, wait 1 hour\nbd daemon --interval 5s\nsleep 3600\n\n# Check git log - should be 0 commits\ngit log --since=\"1 hour ago\" --oneline | wc -l # expect: 0\n```\n\n## Success Criteria\n\n- Zero spurious exports for timestamp-only changes\n- Real changes still exported immediately\n- No performance regression\n- bd-159 resolved\n\n## Estimated Effort\n\n2-3 hours\n\n## Priority\n\nP0 - Prevents noise that amplifies bd-160 sync issues","notes":"## Implementation Complete (2025-10-26)\n\nSuccessfully implemented timestamp-only export deduplication using export_hashes table approach.\n\n### ✅ All Changes Completed:\n\n1. **Export hash tracking (bd-164):**\n - Created `export_hashes` table in schema (schema.go:123-129)\n - Added migration `migrateExportHashesTable()` in sqlite.go\n - Implemented `GetExportHash()` and `SetExportHash()` in hash.go\n - Added interface methods to storage.go\n\n2. **Export deduplication logic:**\n - Updated `shouldSkipExport()` to query export_hashes table\n - Modified export loop to call `SetExportHash()` after successful export\n - Export reports \"Skipped N issue(s) with timestamp-only changes\"\n\n3. **Reverted incorrect hash tracking in dirty_issues:**\n - Removed content_hash column from dirty_issues table (schema + migration)\n - Simplified `MarkIssueDirty()` - no longer fetches issues or computes hashes\n - Simplified `MarkIssuesDirty()` - no longer needs issue fetch\n - Simplified `markIssuesDirtyTx()` - removed store parameter\n - Updated all callers in dependencies.go to remove store parameter\n\n4. **Testing:**\n - Timestamp-only updates (updated_at change) → skipped ✓\n - Real content changes (priority change) → exported ✓\n - export_hashes table populated correctly ✓\n - dirty_issues cleared after successful export ✓\n\n### Design:\n- **dirty_issues table**: Tracks \"needs export\" flag (cleared after export)\n- **export_hashes table**: Tracks \"last exported state\" (persists for comparison)\n\nThis clean separation avoids lifecycle complexity and prevents spurious exports.\n\n### Files Modified:\n- cmd/bd/export.go (hash computation, skip logic, SetExportHash calls)\n- internal/storage/storage.go (GetExportHash/SetExportHash interface)\n- internal/storage/sqlite/schema.go (export_hashes table)\n- internal/storage/sqlite/hash.go (GetExportHash/SetExportHash implementation)\n- internal/storage/sqlite/dirty.go (simplified, hash tracking removed)\n- internal/storage/sqlite/sqlite.go (migration)\n- internal/storage/sqlite/dependencies.go (markIssuesDirtyTx calls updated)\n\nBuild successful, all tests passed.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T19:54:58.248715-07:00","updated_at":"2025-10-26T20:34:43.345317-07:00","closed_at":"2025-10-26T20:34:43.345317-07:00","dependencies":[{"issue_id":"bd-164","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:54:58.24935-07:00","created_by":"daemon"},{"issue_id":"bd-164","depends_on_id":"bd-159","type":"related","created_at":"2025-10-26T19:54:58.249718-07:00","created_by":"daemon"}]} -{"id":"bd-165","title":"Enforce canonical database naming (beads.db)","description":"## Problem\n\nCurrently, different clones can use different database filenames (bd.db, beads.db, issues.db), causing incompatibility when attempting to sync.\n\nExample from bd-160:\n- ~/src/beads uses bd.db\n- ~/src/fred/beads uses beads.db\n- Sync fails because they're fundamentally different databases\n\n## Solution\n\nEnforce a single canonical database name: **beads.db**\n\n## Implementation\n\n### 1. Define Canonical Name\n\n**File**: beads.go or constants.go (create if needed)\n\n```go\npackage beads\n\n// CanonicalDatabaseName is the required database filename for all beads repositories\nconst CanonicalDatabaseName = \"beads.db\"\n\n// LegacyDatabaseNames are old names that should be migrated\nvar LegacyDatabaseNames = []string{\"bd.db\", \"issues.db\", \"bugs.db\"}\n```\n\n### 2. Update bd init Command\n\n**File**: cmd/bd/init.go\n\n```go\nfunc runInit(cmd *cobra.Command, args []string) error {\n beadsDir := \".beads\"\n os.MkdirAll(beadsDir, 0755)\n \n dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)\n \n // Check for legacy databases\n for _, legacy := range beads.LegacyDatabaseNames {\n legacyPath := filepath.Join(beadsDir, legacy)\n if exists(legacyPath) {\n fmt.Printf(\"Found legacy database: %s\\n\", legacy)\n fmt.Printf(\"Migrating to canonical name: %s\\n\", beads.CanonicalDatabaseName)\n \n // Rename to canonical\n if err := os.Rename(legacyPath, dbPath); err != nil {\n return fmt.Errorf(\"migration failed: %w\", err)\n }\n fmt.Printf(\"✓ Migrated %s → %s\\n\", legacy, beads.CanonicalDatabaseName)\n }\n }\n \n // Create new database if doesn't exist\n if !exists(dbPath) {\n store, err := sqlite.New(dbPath)\n if err != nil {\n return err\n }\n defer store.Close()\n \n // Initialize with version metadata\n store.SetMetadata(context.Background(), \"bd_version\", Version)\n store.SetMetadata(context.Background(), \"db_name\", beads.CanonicalDatabaseName)\n }\n \n // ... rest of init\n}\n```\n\n### 3. Validate on Daemon Start\n\n**File**: cmd/bd/daemon.go:1076-1095\n\nUpdate the existing multiple-DB check:\n```go\n// Check for multiple .db files\nbeadsDir := filepath.Dir(daemonDBPath)\nmatches, err := filepath.Glob(filepath.Join(beadsDir, \"*.db\"))\nif err == nil \u0026\u0026 len(matches) \u003e 1 {\n log.log(\"Error: Multiple database files found:\")\n for _, match := range matches {\n log.log(\" - %s\", filepath.Base(match))\n }\n log.log(\"\")\n log.log(\"Beads requires a single canonical database: %s\", beads.CanonicalDatabaseName)\n log.log(\"Run 'bd init' to migrate legacy databases\")\n os.Exit(1)\n}\n\n// Validate using canonical name\nif filepath.Base(daemonDBPath) != beads.CanonicalDatabaseName {\n log.log(\"Error: Non-canonical database name: %s\", filepath.Base(daemonDBPath))\n log.log(\"Expected: %s\", beads.CanonicalDatabaseName)\n log.log(\"Run 'bd init' to migrate to canonical name\")\n os.Exit(1)\n}\n```\n\n### 4. Add Migration Command\n\n**File**: cmd/bd/migrate.go (create new)\n\n```go\nvar migrateCmd = \u0026cobra.Command{\n Use: \"migrate\",\n Short: \"Migrate database to canonical naming and schema\",\n Run: func(cmd *cobra.Command, args []string) {\n beadsDir := \".beads\"\n \n // Find current database\n var currentDB string\n for _, name := range append(beads.LegacyDatabaseNames, beads.CanonicalDatabaseName) {\n path := filepath.Join(beadsDir, name)\n if exists(path) {\n currentDB = path\n break\n }\n }\n \n if currentDB == \"\" {\n fmt.Println(\"No database found\")\n return\n }\n \n targetPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)\n \n if currentDB == targetPath {\n fmt.Println(\"Database already using canonical name\")\n return\n }\n \n // Backup first\n backupPath := currentDB + \".backup\"\n copyFile(currentDB, backupPath)\n fmt.Printf(\"Created backup: %s\\n\", backupPath)\n \n // Rename\n if err := os.Rename(currentDB, targetPath); err != nil {\n fmt.Fprintf(os.Stderr, \"Migration failed: %v\\n\", err)\n os.Exit(1)\n }\n \n fmt.Printf(\"✓ Migrated: %s → %s\\n\", filepath.Base(currentDB), beads.CanonicalDatabaseName)\n \n // Update metadata\n store, _ := sqlite.New(targetPath)\n defer store.Close()\n store.SetMetadata(context.Background(), \"db_name\", beads.CanonicalDatabaseName)\n },\n}\n```\n\n### 5. Update FindDatabasePath\n\n**File**: beads.go (or wherever FindDatabasePath is defined)\n\n```go\nfunc FindDatabasePath() string {\n beadsDir := findBeadsDir()\n if beadsDir == \"\" {\n return \"\"\n }\n \n // First try canonical name\n canonical := filepath.Join(beadsDir, CanonicalDatabaseName)\n if exists(canonical) {\n return canonical\n }\n \n // Check for legacy names (warn user)\n for _, legacy := range LegacyDatabaseNames {\n path := filepath.Join(beadsDir, legacy)\n if exists(path) {\n fmt.Fprintf(os.Stderr, \"WARNING: Using legacy database name: %s\\n\", legacy)\n fmt.Fprintf(os.Stderr, \"Run 'bd migrate' to upgrade to canonical name: %s\\n\", CanonicalDatabaseName)\n return path\n }\n }\n \n return \"\"\n}\n```\n\n## Testing\n\n```bash\n# Test migration\nmkdir test-repo \u0026\u0026 cd test-repo \u0026\u0026 git init\nmkdir .beads\nsqlite3 .beads/bd.db \"CREATE TABLE test (id int);\"\n\nbd init # Should detect and migrate bd.db → beads.db\n\n# Verify\nls .beads/*.db # Should only show beads.db\n\n# Test daemon rejection\nsqlite3 .beads/old.db \"CREATE TABLE test (id int);\"\nbd daemon # Should error: multiple databases found\n\n# Test clean init\nrm -rf test-repo2 \u0026\u0026 mkdir test-repo2 \u0026\u0026 cd test-repo2\nbd init # Should create .beads/beads.db directly\n```\n\n## Rollout Strategy\n\n1. Add migration logic to bd init\n2. Update FindDatabasePath to warn on legacy names\n3. Add 'bd migrate' command for manual migration\n4. Update docs to specify canonical name\n5. Add daemon validation after 2 releases\n\n## Success Criteria\n\n- All new repositories use beads.db\n- bd init auto-migrates legacy names\n- bd daemon rejects non-canonical names\n- Clear migration path for existing users\n- No data loss during migration\n\n## Estimated Effort\n\n3-4 hours\n\n## Priority\n\nP0 - Critical for multi-clone compatibility","status":"open","priority":0,"issue_type":"task","created_at":"2025-10-26T19:55:39.056716-07:00","updated_at":"2025-10-26T19:55:39.056716-07:00","dependencies":[{"issue_id":"bd-165","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:55:39.057336-07:00","created_by":"daemon"}]} +{"id":"bd-165","title":"Enforce canonical database naming (beads.db)","description":"## Problem\n\nCurrently, different clones can use different database filenames (bd.db, beads.db, issues.db), causing incompatibility when attempting to sync.\n\nExample from bd-160:\n- ~/src/beads uses bd.db\n- ~/src/fred/beads uses beads.db\n- Sync fails because they're fundamentally different databases\n\n## Solution\n\nEnforce a single canonical database name: **beads.db**\n\n## Implementation\n\n### 1. Define Canonical Name\n\n**File**: beads.go or constants.go (create if needed)\n\n```go\npackage beads\n\n// CanonicalDatabaseName is the required database filename for all beads repositories\nconst CanonicalDatabaseName = \"beads.db\"\n\n// LegacyDatabaseNames are old names that should be migrated\nvar LegacyDatabaseNames = []string{\"bd.db\", \"issues.db\", \"bugs.db\"}\n```\n\n### 2. Update bd init Command\n\n**File**: cmd/bd/init.go\n\n```go\nfunc runInit(cmd *cobra.Command, args []string) error {\n beadsDir := \".beads\"\n os.MkdirAll(beadsDir, 0755)\n \n dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)\n \n // Check for legacy databases\n for _, legacy := range beads.LegacyDatabaseNames {\n legacyPath := filepath.Join(beadsDir, legacy)\n if exists(legacyPath) {\n fmt.Printf(\"Found legacy database: %s\\n\", legacy)\n fmt.Printf(\"Migrating to canonical name: %s\\n\", beads.CanonicalDatabaseName)\n \n // Rename to canonical\n if err := os.Rename(legacyPath, dbPath); err != nil {\n return fmt.Errorf(\"migration failed: %w\", err)\n }\n fmt.Printf(\"✓ Migrated %s → %s\\n\", legacy, beads.CanonicalDatabaseName)\n }\n }\n \n // Create new database if doesn't exist\n if !exists(dbPath) {\n store, err := sqlite.New(dbPath)\n if err != nil {\n return err\n }\n defer store.Close()\n \n // Initialize with version metadata\n store.SetMetadata(context.Background(), \"bd_version\", Version)\n store.SetMetadata(context.Background(), \"db_name\", beads.CanonicalDatabaseName)\n }\n \n // ... rest of init\n}\n```\n\n### 3. Validate on Daemon Start\n\n**File**: cmd/bd/daemon.go:1076-1095\n\nUpdate the existing multiple-DB check:\n```go\n// Check for multiple .db files\nbeadsDir := filepath.Dir(daemonDBPath)\nmatches, err := filepath.Glob(filepath.Join(beadsDir, \"*.db\"))\nif err == nil \u0026\u0026 len(matches) \u003e 1 {\n log.log(\"Error: Multiple database files found:\")\n for _, match := range matches {\n log.log(\" - %s\", filepath.Base(match))\n }\n log.log(\"\")\n log.log(\"Beads requires a single canonical database: %s\", beads.CanonicalDatabaseName)\n log.log(\"Run 'bd init' to migrate legacy databases\")\n os.Exit(1)\n}\n\n// Validate using canonical name\nif filepath.Base(daemonDBPath) != beads.CanonicalDatabaseName {\n log.log(\"Error: Non-canonical database name: %s\", filepath.Base(daemonDBPath))\n log.log(\"Expected: %s\", beads.CanonicalDatabaseName)\n log.log(\"Run 'bd init' to migrate to canonical name\")\n os.Exit(1)\n}\n```\n\n### 4. Add Migration Command\n\n**File**: cmd/bd/migrate.go (create new)\n\n```go\nvar migrateCmd = \u0026cobra.Command{\n Use: \"migrate\",\n Short: \"Migrate database to canonical naming and schema\",\n Run: func(cmd *cobra.Command, args []string) {\n beadsDir := \".beads\"\n \n // Find current database\n var currentDB string\n for _, name := range append(beads.LegacyDatabaseNames, beads.CanonicalDatabaseName) {\n path := filepath.Join(beadsDir, name)\n if exists(path) {\n currentDB = path\n break\n }\n }\n \n if currentDB == \"\" {\n fmt.Println(\"No database found\")\n return\n }\n \n targetPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)\n \n if currentDB == targetPath {\n fmt.Println(\"Database already using canonical name\")\n return\n }\n \n // Backup first\n backupPath := currentDB + \".backup\"\n copyFile(currentDB, backupPath)\n fmt.Printf(\"Created backup: %s\\n\", backupPath)\n \n // Rename\n if err := os.Rename(currentDB, targetPath); err != nil {\n fmt.Fprintf(os.Stderr, \"Migration failed: %v\\n\", err)\n os.Exit(1)\n }\n \n fmt.Printf(\"✓ Migrated: %s → %s\\n\", filepath.Base(currentDB), beads.CanonicalDatabaseName)\n \n // Update metadata\n store, _ := sqlite.New(targetPath)\n defer store.Close()\n store.SetMetadata(context.Background(), \"db_name\", beads.CanonicalDatabaseName)\n },\n}\n```\n\n### 5. Update FindDatabasePath\n\n**File**: beads.go (or wherever FindDatabasePath is defined)\n\n```go\nfunc FindDatabasePath() string {\n beadsDir := findBeadsDir()\n if beadsDir == \"\" {\n return \"\"\n }\n \n // First try canonical name\n canonical := filepath.Join(beadsDir, CanonicalDatabaseName)\n if exists(canonical) {\n return canonical\n }\n \n // Check for legacy names (warn user)\n for _, legacy := range LegacyDatabaseNames {\n path := filepath.Join(beadsDir, legacy)\n if exists(path) {\n fmt.Fprintf(os.Stderr, \"WARNING: Using legacy database name: %s\\n\", legacy)\n fmt.Fprintf(os.Stderr, \"Run 'bd migrate' to upgrade to canonical name: %s\\n\", CanonicalDatabaseName)\n return path\n }\n }\n \n return \"\"\n}\n```\n\n## Testing\n\n```bash\n# Test migration\nmkdir test-repo \u0026\u0026 cd test-repo \u0026\u0026 git init\nmkdir .beads\nsqlite3 .beads/bd.db \"CREATE TABLE test (id int);\"\n\nbd init # Should detect and migrate bd.db → beads.db\n\n# Verify\nls .beads/*.db # Should only show beads.db\n\n# Test daemon rejection\nsqlite3 .beads/old.db \"CREATE TABLE test (id int);\"\nbd daemon # Should error: multiple databases found\n\n# Test clean init\nrm -rf test-repo2 \u0026\u0026 mkdir test-repo2 \u0026\u0026 cd test-repo2\nbd init # Should create .beads/beads.db directly\n```\n\n## Rollout Strategy\n\n1. Add migration logic to bd init\n2. Update FindDatabasePath to warn on legacy names\n3. Add 'bd migrate' command for manual migration\n4. Update docs to specify canonical name\n5. Add daemon validation after 2 releases\n\n## Success Criteria\n\n- All new repositories use beads.db\n- bd init auto-migrates legacy names\n- bd daemon rejects non-canonical names\n- Clear migration path for existing users\n- No data loss during migration\n\n## Estimated Effort\n\n3-4 hours\n\n## Priority\n\nP0 - Critical for multi-clone compatibility","status":"closed","priority":0,"issue_type":"task","created_at":"2025-10-26T19:55:39.056716-07:00","updated_at":"2025-10-26T20:42:12.175028-07:00","closed_at":"2025-10-26T20:42:12.175028-07:00","dependencies":[{"issue_id":"bd-165","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:55:39.057336-07:00","created_by":"daemon"}]} {"id":"bd-166","title":"Add database fingerprinting and validation","description":"## Problem\n\nWhen multiple clones exist, there's no validation that they're actually clones of the same repository. Different repos can accidentally share databases, causing data corruption.\n\nNeed database fingerprinting to ensure clones belong to the same logical repository.\n\n## Solution\n\nAdd repository fingerprint to database metadata and validate on daemon start.\n\n## Implementation\n\n### 1. Compute Repository ID\n\n**File**: pkg/fingerprint.go (create new)\n\n```go\npackage beads\n\nimport (\n \"crypto/sha256\"\n \"encoding/hex\"\n \"fmt\"\n \"os/exec\"\n)\n\n// ComputeRepoID generates a unique identifier for this git repository\nfunc ComputeRepoID() (string, error) {\n // Get git remote URL (canonical repo identifier)\n cmd := exec.Command(\"git\", \"config\", \"--get\", \"remote.origin.url\")\n output, err := cmd.Output()\n if err != nil {\n // No remote configured, use local path\n cmd = exec.Command(\"git\", \"rev-parse\", \"--show-toplevel\")\n output, err = cmd.Output()\n if err != nil {\n return \"\", fmt.Errorf(\"not a git repository\")\n }\n }\n \n repoURL := strings.TrimSpace(string(output))\n \n // Normalize URL (remove .git suffix, https vs git@, etc.)\n repoURL = normalizeGitURL(repoURL)\n \n // SHA256 hash for privacy (don't expose repo URL in database)\n hash := sha256.Sum256([]byte(repoURL))\n return hex.EncodeToString(hash[:16]), nil // Use first 16 bytes\n}\n\nfunc normalizeGitURL(url string) string {\n // Convert git@github.com:user/repo.git → github.com/user/repo\n // Convert https://github.com/user/repo.git → github.com/user/repo\n url = strings.TrimSuffix(url, \".git\")\n url = strings.ReplaceAll(url, \"git@\", \"\")\n url = strings.ReplaceAll(url, \"https://\", \"\")\n url = strings.ReplaceAll(url, \"http://\", \"\")\n url = strings.ReplaceAll(url, \":\", \"/\")\n return url\n}\n\n// GetCloneID generates a unique ID for this specific clone (not shared with other clones)\nfunc GetCloneID() string {\n // Use hostname + path for uniqueness\n hostname, _ := os.Hostname()\n path, _ := os.Getwd()\n hash := sha256.Sum256([]byte(hostname + \":\" + path))\n return hex.EncodeToString(hash[:8])\n}\n```\n\n### 2. Store Fingerprint on Init\n\n**File**: cmd/bd/init.go\n\n```go\nfunc runInit(cmd *cobra.Command, args []string) error {\n // ... create database ...\n \n // Compute and store repo ID\n repoID, err := beads.ComputeRepoID()\n if err != nil {\n fmt.Fprintf(os.Stderr, \"Warning: could not compute repo ID: %v\\n\", err)\n } else {\n if err := store.SetMetadata(ctx, \"repo_id\", repoID); err != nil {\n return fmt.Errorf(\"failed to set repo_id: %w\", err)\n }\n fmt.Printf(\"Repository ID: %s\\n\", repoID[:8])\n }\n \n // Store clone ID\n cloneID := beads.GetCloneID()\n if err := store.SetMetadata(ctx, \"clone_id\", cloneID); err != nil {\n return fmt.Errorf(\"failed to set clone_id: %w\", err)\n }\n fmt.Printf(\"Clone ID: %s\\n\", cloneID)\n \n // Store creation timestamp\n if err := store.SetMetadata(ctx, \"created_at\", time.Now().Format(time.RFC3339)); err != nil {\n return fmt.Errorf(\"failed to set created_at: %w\", err)\n }\n \n return nil\n}\n```\n\n### 3. Validate on Database Open\n\n**File**: cmd/bd/daemon.go (in runDaemonLoop)\n\n```go\nfunc validateDatabaseFingerprint(store storage.Storage) error {\n ctx := context.Background()\n \n // Get stored repo ID\n storedRepoID, err := store.GetMetadata(ctx, \"repo_id\")\n if err != nil \u0026\u0026 err.Error() != \"metadata key not found: repo_id\" {\n return fmt.Errorf(\"failed to read repo_id: %w\", err)\n }\n \n // If no repo_id, this is a legacy database - set it now\n if storedRepoID == \"\" {\n repoID, err := beads.ComputeRepoID()\n if err != nil {\n log.log(\"Warning: could not compute repo ID: %v\", err)\n return nil // Non-fatal for backward compat\n }\n \n log.log(\"Legacy database detected, setting repo_id: %s\", repoID[:8])\n if err := store.SetMetadata(ctx, \"repo_id\", repoID); err != nil {\n return fmt.Errorf(\"failed to set repo_id: %w\", err)\n }\n return nil\n }\n \n // Validate repo ID matches\n currentRepoID, err := beads.ComputeRepoID()\n if err != nil {\n log.log(\"Warning: could not compute current repo ID: %v\", err)\n return nil // Non-fatal\n }\n \n if storedRepoID != currentRepoID {\n return fmt.Errorf(`\nDATABASE MISMATCH DETECTED!\n\nThis database belongs to a different repository:\n Database repo ID: %s\n Current repo ID: %s\n\nThis usually means:\n 1. You copied a .beads directory from another repo (don't do this!)\n 2. Git remote URL changed (run 'bd migrate' to update)\n 3. Database corruption\n\nSolutions:\n - If remote URL changed: bd migrate --update-repo-id\n - If wrong database: rm -rf .beads \u0026\u0026 bd init\n - If correct database: BEADS_IGNORE_REPO_MISMATCH=1 bd daemon\n`, storedRepoID[:8], currentRepoID[:8])\n }\n \n return nil\n}\n\n// In runDaemonLoop, after opening database:\nif err := validateDatabaseFingerprint(store); err != nil {\n if os.Getenv(\"BEADS_IGNORE_REPO_MISMATCH\") != \"1\" {\n log.log(\"Error: %v\", err)\n os.Exit(1)\n }\n log.log(\"Warning: repo mismatch ignored (BEADS_IGNORE_REPO_MISMATCH=1)\")\n}\n```\n\n### 4. Add Update Command for Remote Changes\n\n**File**: cmd/bd/migrate.go\n\n```go\nvar updateRepoID bool\n\nfunc init() {\n migrateCmd.Flags().BoolVar(\u0026updateRepoID, \"update-repo-id\", false, \n \"Update repository ID (use after changing git remote)\")\n}\n\n// In migrate command:\nif updateRepoID {\n newRepoID, err := beads.ComputeRepoID()\n if err != nil {\n fmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n os.Exit(1)\n }\n \n oldRepoID, _ := store.GetMetadata(ctx, \"repo_id\")\n \n fmt.Printf(\"Updating repository ID:\\n\")\n fmt.Printf(\" Old: %s\\n\", oldRepoID[:8])\n fmt.Printf(\" New: %s\\n\", newRepoID[:8])\n \n if err := store.SetMetadata(ctx, \"repo_id\", newRepoID); err != nil {\n fmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n os.Exit(1)\n }\n \n fmt.Println(\"✓ Repository ID updated\")\n}\n```\n\n## Metadata Schema\n\nAdd to `metadata` table:\n\n| Key | Value | Description |\n|-----|-------|-------------|\n| repo_id | sha256(git_remote)[..16] | Repository fingerprint |\n| clone_id | sha256(hostname:path)[..8] | Clone-specific ID |\n| created_at | RFC3339 timestamp | Database creation time |\n| db_name | \"beads.db\" | Canonical database name |\n| bd_version | \"v0.x.x\" | Schema version |\n\n## Testing\n\n```bash\n# Test repo ID generation\ncd /tmp/test-repo \u0026\u0026 git init\ngit remote add origin https://github.com/user/repo.git\nbd init\nbd show-meta repo_id # Should show consistent hash\n\n# Test mismatch detection\ncd /tmp/other-repo \u0026\u0026 git init\ngit remote add origin https://github.com/other/repo.git\ncp -r /tmp/test-repo/.beads /tmp/other-repo/\nbd daemon # Should error: repo mismatch\n\n# Test migration\ngit remote set-url origin https://github.com/user/new-repo.git\nbd migrate --update-repo-id # Should update successfully\n```\n\n## Success Criteria\n\n- New databases automatically get repo_id\n- Daemon validates repo_id on start\n- Clear error messages on mismatch\n- Migration path for remote URL changes\n- Legacy databases automatically fingerprinted\n\n## Estimated Effort\n\n3-4 hours\n\n## Priority\n\nP0 - Prevents accidental database mixing across repos","status":"open","priority":0,"issue_type":"task","created_at":"2025-10-26T19:56:18.53693-07:00","updated_at":"2025-10-26T19:56:18.53693-07:00","dependencies":[{"issue_id":"bd-166","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:56:18.537546-07:00","created_by":"daemon"}]} {"id":"bd-167","title":"Implement version tracking for issues","description":"## Problem\n\nWhen two clones modify the same issue concurrently, there's no way to detect or handle the conflict properly. Last writer wins arbitrarily, losing data.\n\nNeed version tracking to implement proper conflict detection and resolution.\n\n## Solution\n\nAdd version counter and last-modified metadata to issues for Last-Writer-Wins (LWW) conflict resolution.\n\n## Database Schema Changes\n\n**File**: internal/storage/sqlite/schema.go\n\n```sql\n-- Add version tracking columns to issues table\nALTER TABLE issues ADD COLUMN version INTEGER DEFAULT 1 NOT NULL;\nALTER TABLE issues ADD COLUMN modified_by TEXT DEFAULT '' NOT NULL;\nALTER TABLE issues ADD COLUMN modified_at DATETIME;\n\n-- Create index for version-based queries\nCREATE INDEX IF NOT EXISTS idx_issues_version ON issues(id, version);\n\n-- Store modification history\nCREATE TABLE IF NOT EXISTS issue_versions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n issue_id TEXT NOT NULL,\n version INTEGER NOT NULL,\n modified_by TEXT NOT NULL,\n modified_at DATETIME NOT NULL,\n snapshot BLOB NOT NULL, -- JSON snapshot of issue at this version\n FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,\n UNIQUE(issue_id, version)\n);\n\nCREATE INDEX IF NOT EXISTS idx_issue_versions_lookup \n ON issue_versions(issue_id, version);\n```\n\n## Implementation\n\n### 1. Update Issue Type\n\n**File**: internal/types/issue.go\n\n```go\ntype Issue struct {\n ID string `json:\"id\"`\n Title string `json:\"title\"`\n Description string `json:\"description\"`\n Status Status `json:\"status\"`\n Priority int `json:\"priority\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n \n // Version tracking (new fields)\n Version int `json:\"version\"` // Incremented on each update\n ModifiedBy string `json:\"modified_by\"` // Clone ID that made the change\n ModifiedAt time.Time `json:\"modified_at\"` // When the change was made\n \n // ... rest of fields\n}\n```\n\n### 2. Increment Version on Update\n\n**File**: internal/storage/sqlite/sqlite.go\n\n```go\nfunc (s *SQLiteStorage) UpdateIssue(ctx context.Context, issue *types.Issue) error {\n // Get current version from database\n var currentVersion int\n var currentModifiedAt time.Time\n err := s.db.QueryRowContext(ctx, `\n SELECT version, modified_at \n FROM issues \n WHERE id = ?\n `, issue.ID).Scan(\u0026currentVersion, \u0026currentModifiedAt)\n \n if err != nil \u0026\u0026 err != sql.ErrNoRows {\n return fmt.Errorf(\"failed to get current version: %w\", err)\n }\n \n // Detect conflict: incoming version is stale\n if issue.Version \u003e 0 \u0026\u0026 issue.Version \u003c currentVersion {\n return \u0026ConflictError{\n IssueID: issue.ID,\n LocalVersion: currentVersion,\n RemoteVersion: issue.Version,\n LocalModified: currentModifiedAt,\n RemoteModified: issue.ModifiedAt,\n }\n }\n \n // No conflict or local is newer: increment version\n issue.Version = currentVersion + 1\n issue.ModifiedBy = getCloneID() // From fingerprinting\n issue.ModifiedAt = time.Now()\n \n // Save version snapshot before updating\n if err := s.saveVersionSnapshot(ctx, issue); err != nil {\n // Non-fatal warning\n log.Printf(\"Warning: failed to save version snapshot: %v\", err)\n }\n \n // Perform update\n _, err = s.db.ExecContext(ctx, `\n UPDATE issues SET\n title = ?,\n description = ?,\n status = ?,\n priority = ?,\n updated_at = ?,\n version = ?,\n modified_by = ?,\n modified_at = ?\n WHERE id = ?\n `, issue.Title, issue.Description, issue.Status, issue.Priority,\n issue.UpdatedAt, issue.Version, issue.ModifiedBy, issue.ModifiedAt,\n issue.ID)\n \n return err\n}\n\nfunc (s *SQLiteStorage) saveVersionSnapshot(ctx context.Context, issue *types.Issue) error {\n snapshot, _ := json.Marshal(issue)\n \n _, err := s.db.ExecContext(ctx, `\n INSERT INTO issue_versions (issue_id, version, modified_by, modified_at, snapshot)\n VALUES (?, ?, ?, ?, ?)\n `, issue.ID, issue.Version, issue.ModifiedBy, issue.ModifiedAt, snapshot)\n \n return err\n}\n```\n\n### 3. Conflict Detection on Import\n\n**File**: cmd/bd/import_core.go\n\n```go\ntype ConflictError struct {\n IssueID string\n LocalVersion int\n RemoteVersion int\n LocalModified time.Time\n RemoteModified time.Time\n LocalIssue *types.Issue\n RemoteIssue *types.Issue\n}\n\nfunc (e *ConflictError) Error() string {\n return fmt.Sprintf(\"conflict on %s: local v%d (modified %s) vs remote v%d (modified %s)\",\n e.IssueID, e.LocalVersion, e.LocalModified, e.RemoteVersion, e.RemoteModified)\n}\n\nfunc detectVersionConflict(local, remote *types.Issue) *ConflictError {\n // No conflict if same version\n if local.Version == remote.Version {\n return nil\n }\n \n // Remote is newer - no conflict\n if remote.Version \u003e local.Version {\n return nil\n }\n \n // Local is newer - remote is stale\n if remote.Version \u003c local.Version {\n // Check if concurrent modification (both diverged from same base)\n if local.ModifiedAt.Sub(remote.ModifiedAt).Abs() \u003c 1*time.Minute {\n return \u0026ConflictError{\n IssueID: local.ID,\n LocalVersion: local.Version,\n RemoteVersion: remote.Version,\n LocalModified: local.ModifiedAt,\n RemoteModified: remote.ModifiedAt,\n LocalIssue: local,\n RemoteIssue: remote,\n }\n }\n }\n \n return nil\n}\n```\n\n### 4. Conflict Resolution Strategies\n\n```go\ntype ConflictStrategy int\n\nconst (\n StrategyLWW ConflictStrategy = iota // Last Writer Wins (use newest modified_at)\n StrategyHighestVersion // Use highest version number\n StrategyInteractive // Prompt user\n StrategyMerge // Three-way merge (future)\n)\n\nfunc resolveConflict(conflict *ConflictError, strategy ConflictStrategy) (*types.Issue, error) {\n switch strategy {\n case StrategyLWW:\n if conflict.RemoteModified.After(conflict.LocalModified) {\n return conflict.RemoteIssue, nil\n }\n return conflict.LocalIssue, nil\n \n case StrategyHighestVersion:\n if conflict.RemoteVersion \u003e conflict.LocalVersion {\n return conflict.RemoteIssue, nil\n }\n return conflict.LocalIssue, nil\n \n case StrategyInteractive:\n return promptUserForResolution(conflict)\n \n default:\n return nil, fmt.Errorf(\"unknown conflict strategy: %v\", strategy)\n }\n}\n```\n\n## Migration for Existing Databases\n\n**File**: cmd/bd/migrate.go\n\n```go\nfunc migrateToVersionTracking(store storage.Storage) error {\n ctx := context.Background()\n \n // Add columns if not exist\n _, err := db.Exec(`\n ALTER TABLE issues ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1 NOT NULL\n `)\n if err != nil {\n return err\n }\n \n _, err = db.Exec(`\n ALTER TABLE issues ADD COLUMN IF NOT EXISTS modified_by TEXT DEFAULT ''\n `)\n if err != nil {\n return err\n }\n \n _, err = db.Exec(`\n ALTER TABLE issues ADD COLUMN IF NOT EXISTS modified_at DATETIME\n `)\n if err != nil {\n return err\n }\n \n // Backfill modified_at from updated_at\n _, err = db.Exec(`\n UPDATE issues SET modified_at = updated_at WHERE modified_at IS NULL\n `)\n \n return err\n}\n```\n\n## Testing\n\n```bash\n# Test version increment\nbd create \"Test issue\"\nbd show bd-1 --json | jq .version # Should be 1\nbd update bd-1 --title \"Updated\"\nbd show bd-1 --json | jq .version # Should be 2\n\n# Test conflict detection\n# Clone A: modify bd-1\ncd repo-a \u0026\u0026 bd update bd-1 --title \"A's version\"\n# Clone B: modify bd-1\ncd repo-b \u0026\u0026 bd update bd-1 --title \"B's version\"\n\n# Sync\ncd repo-a \u0026\u0026 bd sync # Should detect conflict\n```\n\n## Success Criteria\n\n- All issues have version numbers\n- Version increments on each update\n- Conflicts detected when importing stale versions\n- Version history preserved in issue_versions table\n- Migration works for existing databases\n\n## Estimated Effort\n\n4-5 hours\n\n## Priority\n\nP1 - Enables proper conflict detection (required before three-way merge)","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T19:57:01.745351-07:00","updated_at":"2025-10-26T19:57:01.745351-07:00","dependencies":[{"issue_id":"bd-167","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:57:01.746071-07:00","created_by":"daemon"}]} {"id":"bd-168","title":"Add three-way merge conflict detection and resolution","description":"## Problem\n\nWhen version tracking detects a conflict (two clones modified same issue), we need intelligent merge logic instead of just picking a winner.\n\nDepends on: bd-167 (version tracking)\n\n## Solution\n\nImplement three-way merge algorithm using git merge-base concept:\n- Base: Last common version\n- Local: Current database state\n- Remote: Incoming JSONL state\n\n## Three-Way Merge Algorithm\n\n### 1. Find Merge Base\n\n**File**: internal/merge/merge.go (create new)\n\n```go\npackage merge\n\nimport (\n \"github.com/steveyegge/beads/internal/types\"\n)\n\n// FindMergeBase finds the last common version between local and remote\nfunc FindMergeBase(store storage.Storage, issueID string, localVersion, remoteVersion int) (*types.Issue, error) {\n // Get version history from issue_versions table\n baseVersion := min(localVersion, remoteVersion)\n \n var snapshot []byte\n err := db.QueryRow(`\n SELECT snapshot FROM issue_versions \n WHERE issue_id = ? AND version = ?\n ORDER BY version DESC LIMIT 1\n `, issueID, baseVersion).Scan(\u0026snapshot)\n \n if err != nil {\n return nil, fmt.Errorf(\"merge base not found: %w\", err)\n }\n \n var base types.Issue\n json.Unmarshal(snapshot, \u0026base)\n return \u0026base, nil\n}\n```\n\n### 2. Detect Conflict Type\n\n```go\ntype ConflictType int\n\nconst (\n NoConflict ConflictType = iota\n ModifyModify // Both modified same field\n ModifyDelete // One modified, one deleted\n CreateCreate // Both created same ID (ID collision)\n AutoMergeable // Different fields modified\n)\n\ntype FieldConflict struct {\n Field string\n BaseValue interface{}\n LocalValue interface{}\n RemoteValue interface{}\n}\n\ntype MergeResult struct {\n Type ConflictType\n Merged *types.Issue\n Conflicts []FieldConflict\n}\n\nfunc ThreeWayMerge(base, local, remote *types.Issue) (*MergeResult, error) {\n result := \u0026MergeResult{\n Type: NoConflict,\n Merged: \u0026types.Issue{},\n }\n \n // Copy base as starting point\n *result.Merged = *base\n \n // Check each field\n conflicts := []FieldConflict{}\n \n // Title\n if local.Title != base.Title \u0026\u0026 remote.Title != base.Title {\n if local.Title != remote.Title {\n conflicts = append(conflicts, FieldConflict{\n Field: \"title\",\n BaseValue: base.Title,\n LocalValue: local.Title,\n RemoteValue: remote.Title,\n })\n } else {\n result.Merged.Title = local.Title // Same change\n }\n } else if local.Title != base.Title {\n result.Merged.Title = local.Title // Only local changed\n } else if remote.Title != base.Title {\n result.Merged.Title = remote.Title // Only remote changed\n }\n \n // Description\n if local.Description != base.Description \u0026\u0026 remote.Description != base.Description {\n if local.Description != remote.Description {\n // Try smart merge for text fields\n merged, conflict := mergeText(base.Description, local.Description, remote.Description)\n if conflict {\n conflicts = append(conflicts, FieldConflict{\n Field: \"description\",\n BaseValue: base.Description,\n LocalValue: local.Description,\n RemoteValue: remote.Description,\n })\n } else {\n result.Merged.Description = merged\n }\n }\n } else if local.Description != base.Description {\n result.Merged.Description = local.Description\n } else if remote.Description != base.Description {\n result.Merged.Description = remote.Description\n }\n \n // Status\n if local.Status != base.Status \u0026\u0026 remote.Status != base.Status {\n if local.Status != remote.Status {\n conflicts = append(conflicts, FieldConflict{\n Field: \"status\",\n BaseValue: base.Status,\n LocalValue: local.Status,\n RemoteValue: remote.Status,\n })\n }\n } else if local.Status != base.Status {\n result.Merged.Status = local.Status\n } else if remote.Status != base.Status {\n result.Merged.Status = remote.Status\n }\n \n // Priority (numeric: take higher)\n if local.Priority != base.Priority \u0026\u0026 remote.Priority != base.Priority {\n result.Merged.Priority = min(local.Priority, remote.Priority) // Lower number = higher priority\n } else if local.Priority != base.Priority {\n result.Merged.Priority = local.Priority\n } else if remote.Priority != base.Priority {\n result.Merged.Priority = remote.Priority\n }\n \n // Set conflict type\n if len(conflicts) \u003e 0 {\n result.Type = ModifyModify\n result.Conflicts = conflicts\n } else if hasChanges(base, result.Merged) {\n result.Type = AutoMergeable\n }\n \n return result, nil\n}\n```\n\n### 3. Smart Text Merging\n\n```go\n// mergeText attempts to merge text changes using line-based diff\nfunc mergeText(base, local, remote string) (string, bool) {\n // If one side didn't change, use the other\n if local == base {\n return remote, false\n }\n if remote == base {\n return local, false\n }\n \n // Both changed - try line-based merge\n baseLines := strings.Split(base, \"\\n\")\n localLines := strings.Split(local, \"\\n\")\n remoteLines := strings.Split(remote, \"\\n\")\n \n // Simple merge: if changes are in different lines, combine them\n merged, conflict := mergeLines(baseLines, localLines, remoteLines)\n return strings.Join(merged, \"\\n\"), conflict\n}\n\nfunc mergeLines(base, local, remote []string) ([]string, bool) {\n // Use Myers diff algorithm or simple LCS\n // For MVP, use simple strategy:\n // - If local added lines, keep them\n // - If remote added lines, keep them\n // - If both modified same line, conflict\n \n // This is a simplified implementation\n // Production would use a proper diff library\n \n if reflect.DeepEqual(local, remote) {\n return local, false // Same changes\n }\n \n // Different changes - conflict\n return local, true\n}\n```\n\n### 4. Conflict Resolution UI\n\n**File**: cmd/bd/import_core.go\n\n```go\nfunc handleMergeConflict(result *merge.MergeResult) (*types.Issue, error) {\n fmt.Fprintf(os.Stderr, \"\\n=== MERGE CONFLICT ===\\n\")\n fmt.Fprintf(os.Stderr, \"Issue: %s\\n\", result.Merged.ID)\n fmt.Fprintf(os.Stderr, \"Conflicts in %d field(s):\\n\\n\", len(result.Conflicts))\n \n for _, conflict := range result.Conflicts {\n fmt.Fprintf(os.Stderr, \"Field: %s\\n\", conflict.Field)\n fmt.Fprintf(os.Stderr, \" Base: %v\\n\", conflict.BaseValue)\n fmt.Fprintf(os.Stderr, \" Local: %v\\n\", conflict.LocalValue)\n fmt.Fprintf(os.Stderr, \" Remote: %v\\n\", conflict.RemoteValue)\n \n fmt.Fprintf(os.Stderr, \"\\nChoose resolution:\\n\")\n fmt.Fprintf(os.Stderr, \" 1) Use local\\n\")\n fmt.Fprintf(os.Stderr, \" 2) Use remote\\n\")\n fmt.Fprintf(os.Stderr, \" 3) Edit manually\\n\")\n fmt.Fprintf(os.Stderr, \"Choice: \")\n \n var choice int\n fmt.Scanln(\u0026choice)\n \n switch choice {\n case 1:\n setField(result.Merged, conflict.Field, conflict.LocalValue)\n case 2:\n setField(result.Merged, conflict.Field, conflict.RemoteValue)\n case 3:\n // Open editor with conflict markers\n edited := editWithConflictMarkers(conflict)\n setField(result.Merged, conflict.Field, edited)\n }\n }\n \n return result.Merged, nil\n}\n```\n\n### 5. Auto-Merge Strategy\n\nFor non-interactive mode (daemon):\n\n```go\nfunc autoResolveConflict(result *merge.MergeResult, strategy string) *types.Issue {\n switch strategy {\n case \"local-wins\":\n for _, c := range result.Conflicts {\n setField(result.Merged, c.Field, c.LocalValue)\n }\n \n case \"remote-wins\":\n for _, c := range result.Conflicts {\n setField(result.Merged, c.Field, c.RemoteValue)\n }\n \n case \"newest-wins\":\n // Use ModifiedAt timestamp\n for _, c := range result.Conflicts {\n if result.Merged.ModifiedAt.After(remoteModifiedAt) {\n setField(result.Merged, c.Field, c.LocalValue)\n } else {\n setField(result.Merged, c.Field, c.RemoteValue)\n }\n }\n }\n \n return result.Merged\n}\n```\n\n## Integration with Import\n\n**File**: cmd/bd/import_core.go\n\n```go\nfunc importIssuesCore(ctx context.Context, dbPath string, store storage.Storage, issues []*types.Issue, opts ImportOptions) (*ImportResult, error) {\n // ...\n \n for _, remoteIssue := range issues {\n localIssue, err := store.GetIssue(ctx, remoteIssue.ID)\n \n if err == nil {\n // Issue exists - check for conflict\n if localIssue.Version != remoteIssue.Version {\n // Get merge base\n base, err := merge.FindMergeBase(store, remoteIssue.ID, \n localIssue.Version, remoteIssue.Version)\n \n if err != nil {\n // No merge base - use LWW\n if localIssue.ModifiedAt.After(remoteIssue.ModifiedAt) {\n continue // Keep local\n } else {\n store.UpdateIssue(ctx, remoteIssue) // Use remote\n }\n } else {\n // Three-way merge\n result, err := merge.ThreeWayMerge(base, localIssue, remoteIssue)\n if err != nil {\n return nil, err\n }\n \n if result.Type == merge.AutoMergeable {\n // Clean merge\n store.UpdateIssue(ctx, result.Merged)\n result.Updated++\n } else if result.Type == merge.ModifyModify {\n // Conflict - need resolution\n if opts.Interactive {\n merged, _ := handleMergeConflict(result)\n store.UpdateIssue(ctx, merged)\n } else {\n // Auto-resolve\n merged := autoResolveConflict(result, \"newest-wins\")\n store.UpdateIssue(ctx, merged)\n result.Conflicts++\n }\n }\n }\n }\n }\n }\n \n return result, nil\n}\n```\n\n## Testing\n\n```bash\n# Test auto-merge (different fields)\ncd repo-a \u0026\u0026 bd update bd-1 --title \"New title\"\ncd repo-b \u0026\u0026 bd update bd-1 --description \"New desc\"\ncd repo-a \u0026\u0026 bd sync \u0026\u0026 cd ../repo-b \u0026\u0026 bd sync\n# Expected: Both changes merged\n\n# Test conflict (same field)\ncd repo-a \u0026\u0026 bd update bd-2 --title \"A's title\"\ncd repo-b \u0026\u0026 bd update bd-2 --title \"B's title\"\ncd repo-a \u0026\u0026 bd sync \u0026\u0026 cd ../repo-b \u0026\u0026 bd sync\n# Expected: Conflict detected, newest wins\n```\n\n## Success Criteria\n\n- Different field changes auto-merge successfully\n- Same field conflicts detected\n- Interactive resolution for manual sync\n- Auto-resolution for daemon (newest-wins)\n- All merges preserve version history\n\n## Estimated Effort\n\n6-8 hours\n\n## Priority\n\nP1 - Enables intelligent conflict resolution","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T19:57:51.037146-07:00","updated_at":"2025-10-26T19:57:51.037146-07:00","dependencies":[{"issue_id":"bd-168","depends_on_id":"bd-160","type":"blocks","created_at":"2025-10-26T19:57:51.0378-07:00","created_by":"daemon"},{"issue_id":"bd-168","depends_on_id":"bd-167","type":"blocks","created_at":"2025-10-26T19:57:51.03819-07:00","created_by":"daemon"}]} diff --git a/beads.go b/beads.go index 37583c87..6b0e5fdd 100644 --- a/beads.go +++ b/beads.go @@ -19,6 +19,12 @@ import ( "github.com/steveyegge/beads/internal/types" ) +// CanonicalDatabaseName is the required database filename for all beads repositories +const CanonicalDatabaseName = "beads.db" + +// LegacyDatabaseNames are old names that should be migrated +var LegacyDatabaseNames = []string{"bd.db", "issues.db", "bugs.db"} + // Issue represents a tracked work item with metadata, dependencies, and status. type ( Issue = types.Issue @@ -184,38 +190,53 @@ func findDatabaseInTree() string { } // Fall back to canonical beads.db for backward compatibility - canonicalDB := filepath.Join(beadsDir, "beads.db") + canonicalDB := filepath.Join(beadsDir, CanonicalDatabaseName) if _, err := os.Stat(canonicalDB); err == nil { - return canonicalDB + return canonicalDB } // Found .beads/ directory, look for *.db files matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) if err == nil && len(matches) > 0 { - // Filter out backup files - var validDBs []string - for _, match := range matches { - baseName := filepath.Base(match) - // Skip backup files (e.g., beads.db.backup, bd.db.backup) - if filepath.Ext(baseName) != ".backup" { - validDBs = append(validDBs, match) - } - } - - if len(validDBs) > 1 { - // Multiple databases found - this is ambiguous - // Print error to stderr but return the first one for backward compatibility - fmt.Fprintf(os.Stderr, "Warning: Multiple database files found in %s:\n", beadsDir) - for _, db := range validDBs { - fmt.Fprintf(os.Stderr, " - %s\n", filepath.Base(db)) - } - fmt.Fprintf(os.Stderr, "Run 'bd init' to migrate to beads.db or manually remove old databases.\n\n") - } - - if len(validDBs) > 0 { - return validDBs[0] - } + // Filter out backup files + var validDBs []string + for _, match := range matches { + baseName := filepath.Base(match) + // Skip backup files (e.g., beads.db.backup, bd.db.backup) + if filepath.Ext(baseName) != ".backup" { + validDBs = append(validDBs, match) } + } + + if len(validDBs) > 1 { + // Multiple databases found - this is ambiguous + // Print error to stderr but return the first one for backward compatibility + fmt.Fprintf(os.Stderr, "Warning: Multiple database files found in %s:\n", beadsDir) + for _, db := range validDBs { + fmt.Fprintf(os.Stderr, " - %s\n", filepath.Base(db)) + } + fmt.Fprintf(os.Stderr, "Run 'bd init' to migrate to %s or manually remove old databases.\n\n", CanonicalDatabaseName) + } + + if len(validDBs) > 0 { + // Check if using legacy name and warn + dbName := filepath.Base(validDBs[0]) + if dbName != CanonicalDatabaseName { + isLegacy := false + for _, legacy := range LegacyDatabaseNames { + if dbName == legacy { + isLegacy = true + break + } + } + if isLegacy { + fmt.Fprintf(os.Stderr, "WARNING: Using legacy database name: %s\n", dbName) + fmt.Fprintf(os.Stderr, "Run 'bd migrate' to upgrade to canonical name: %s\n\n", CanonicalDatabaseName) + } + } + return validDBs[0] + } + } } // Move up one directory diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 2301fe56..6b9558e4 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -1162,11 +1162,23 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p for _, db := range validDBs { log.log(" - %s", filepath.Base(db)) } - log.log("Run 'bd init' to migrate to beads.db or manually remove old databases") + log.log("") + log.log("Beads requires a single canonical database: %s", beads.CanonicalDatabaseName) + log.log("Run 'bd init' to migrate legacy databases") os.Exit(1) } } + // Validate using canonical name + dbBaseName := filepath.Base(daemonDBPath) + if dbBaseName != beads.CanonicalDatabaseName { + log.log("Error: Non-canonical database name: %s", dbBaseName) + log.log("Expected: %s", beads.CanonicalDatabaseName) + log.log("") + log.log("Run 'bd init' to migrate to canonical name") + os.Exit(1) + } + log.log("Using database: %s", daemonDBPath) store, err := sqlite.New(daemonDBPath) diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 39d39077..729ad150 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -9,6 +9,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/storage/sqlite" ) @@ -48,7 +49,7 @@ and database file. Optionally specify a custom issue prefix.`, // Use global dbPath if set via --db flag or BEADS_DB env var, otherwise default to .beads/beads.db initDBPath := dbPath if initDBPath == "" { - initDBPath = filepath.Join(".beads", "beads.db") + initDBPath = filepath.Join(".beads", beads.CanonicalDatabaseName) } // Migrate old database files if they exist diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go index d99b72cc..1d0622ef 100644 --- a/cmd/bd/migrate.go +++ b/cmd/bd/migrate.go @@ -10,6 +10,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads" "github.com/steveyegge/beads/internal/storage/sqlite" _ "modernc.org/sqlite" ) @@ -73,7 +74,7 @@ This command: } // Check if beads.db exists and is current - targetPath := filepath.Join(beadsDir, "beads.db") + targetPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) var currentDB *dbInfo var oldDBs []*dbInfo @@ -271,7 +272,7 @@ This command: if jsonOutput { outputJSON(map[string]interface{}{ "status": "success", - "current_database": "beads.db", + "current_database": beads.CanonicalDatabaseName, "version": Version, "migrated": needsMigration, "version_updated": needsVersionUpdate,