From 7796f5c7f5461e097e66760b452b42ffbb7662aa Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 23 Nov 2025 18:09:24 -0800 Subject: [PATCH] feat: Auto-migrate database on CLI version bump (bd-jgxi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When CLI is upgraded (e.g., 0.24.0 → 0.24.1), the database version is now automatically updated to match the CLI version during PersistentPreRun. This fixes the recurring UX issue where bd doctor shows version mismatch after every CLI upgrade. Implementation: - Added autoMigrateOnVersionBump() function in version_tracking.go - Calls after trackBdVersion() in PersistentPreRun - Best-effort and silent failures to avoid disrupting commands - Only updates bd_version metadata field - Includes comprehensive test coverage Changes: - cmd/bd/main.go: Call autoMigrateOnVersionBump() in PersistentPreRun - cmd/bd/version_tracking.go: Implement auto-migration logic - cmd/bd/version_tracking_test.go: Add tests for auto-migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .beads/beads.jsonl | 2 +- cmd/bd/main.go | 4 + cmd/bd/version_tracking.go | 83 ++++++++++++++ cmd/bd/version_tracking_test.go | 187 ++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 1 deletion(-) diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 2ee991bd..4b2a7326 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -507,7 +507,7 @@ {"id":"bd-iye7","content_hash":"1554b026ccacde081eb05d3889943d95ae9c75a21d3f06c346c57cbe2391dc46","title":"Add path normalization to getMultiRepoJSONLPaths()","description":"From bd-xo6b code review: getMultiRepoJSONLPaths() does not handle non-standard paths correctly.\n\nProblems:\n- No tilde expansion: ~/repos/foo treated as literal path\n- No absolute path conversion: ../other-repo breaks if working directory changes\n- No duplicate detection: If Primary=. and Additional=[.], same JSONL processed twice\n- No empty string handling: Empty paths create invalid /.beads/issues.jsonl\n\nImpact:\nConfig with tilde or relative paths will fail\n\nFix needed:\n1. Use filepath.Abs() for all paths\n2. Add tilde expansion via os.UserHomeDir()\n3. Deduplicate paths (use map to track seen paths)\n4. Filter out empty strings\n5. Validate paths exist and are readable\n\nFiles:\n- cmd/bd/deletion_tracking.go:333-358 (getMultiRepoJSONLPaths function)","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-06T19:31:51.882743-08:00","updated_at":"2025-11-06T19:35:41.246311-08:00","closed_at":"2025-11-06T19:35:41.246311-08:00","source_repo":".","dependencies":[{"issue_id":"bd-iye7","depends_on_id":"bd-xo6b","type":"discovered-from","created_at":"2025-11-06T19:32:12.267906-08:00","created_by":"daemon"}]} {"id":"bd-j3zt","content_hash":"531ad51101f41375a93d66b8d22105ce7c4913261db78b662bb759e802bc01e2","title":"Fix mypy errors in beads-mcp","description":"Running `mypy .` in `integrations/beads-mcp` reports 287 errors. These should be addressed to improve type safety and code quality.","status":"open","priority":3,"issue_type":"task","created_at":"2025-11-20T18:53:28.557708-05:00","updated_at":"2025-11-20T18:53:28.557708-05:00","source_repo":"."} {"id":"bd-j7e2","content_hash":"aeb3aec5ebb3b7554949f7161f58408c445983c993aaa5b31e4df93b083cf19c","title":"RPC diagnostics: BD_RPC_DEBUG timing logs","description":"Add lightweight diagnostic logging for RPC connection attempts:\n- BD_RPC_DEBUG=1 prints to stderr:\n - Socket path being dialed\n - Socket exists check result \n - Dial start/stop time\n - Connection outcome\n- Improve bd daemon --status messaging when lock not held\n\nThis helps field triage of connection issues without verbose daemon logs.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-07T16:42:12.772364-08:00","updated_at":"2025-11-07T22:07:17.346817-08:00","closed_at":"2025-11-07T21:29:32.243458-08:00","source_repo":".","dependencies":[{"issue_id":"bd-j7e2","depends_on_id":"bd-ndyz","type":"discovered-from","created_at":"2025-11-07T16:42:12.773714-08:00","created_by":"daemon"}]} -{"id":"bd-jgxi","content_hash":"9af80db7f04944bbc55f2d77e3576028c553c936e9d40266ccbf169eec47eb9f","title":"Auto-migrate database on CLI version bump","description":"When CLI is upgraded (e.g., 0.24.0 → 0.24.1), database version becomes stale. Add auto-migration in PersistentPreRun or daemon startup. Check dbVersion != CLIVersion and run bd migrate automatically. Fixes recurring UX issue where bd doctor shows version mismatch after every CLI upgrade.","status":"open","priority":0,"issue_type":"feature","created_at":"2025-11-21T23:16:09.004619-08:00","updated_at":"2025-11-21T23:16:27.229388-08:00","source_repo":".","dependencies":[{"issue_id":"bd-jgxi","depends_on_id":"bd-tbz3","type":"parent-child","created_at":"2025-11-21T23:16:09.005513-08:00","created_by":"daemon"}]} +{"id":"bd-jgxi","content_hash":"6ff94901125572693d3cff52afcd14f2d44b6baeb553dee149291eb32512c187","title":"Auto-migrate database on CLI version bump","description":"When CLI is upgraded (e.g., 0.24.0 → 0.24.1), database version becomes stale. Add auto-migration in PersistentPreRun or daemon startup. Check dbVersion != CLIVersion and run bd migrate automatically. Fixes recurring UX issue where bd doctor shows version mismatch after every CLI upgrade.","status":"in_progress","priority":0,"issue_type":"feature","created_at":"2025-11-21T23:16:09.004619-08:00","updated_at":"2025-11-23T18:05:46.209438-08:00","source_repo":".","dependencies":[{"issue_id":"bd-jgxi","depends_on_id":"bd-tbz3","type":"parent-child","created_at":"2025-11-21T23:16:09.005513-08:00","created_by":"daemon"}]} {"id":"bd-jijf","content_hash":"9ecadb3d67b00337d8822ace5378edfe9b3baaa4e64a9e7edc5a2b43d82d9caf","title":"Fix: --parent flag doesn't create parent-child dependency","description":"When using `bd create --parent \u003cid\u003e`, the code generates a hierarchical child ID (e.g., bd-123.1) but never creates a parent-child dependency. This causes `bd epic status` to show zero children even though child issues exist.\n\nRoot cause: create.go generates child ID using store.GetNextChildID() but never calls store.AddDependency() with type parent-child.\n\nFix: After creating the issue when parentID is set, automatically add a parent-child dependency linking child -\u003e parent.","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-11-15T13:15:22.138854-08:00","updated_at":"2025-11-15T13:18:29.301788-08:00","closed_at":"2025-11-15T13:18:29.301788-08:00","source_repo":"."} {"id":"bd-jjua","content_hash":"40e73380589198a2e43bc484c7d55dd1d3bef620dbc1529ddaf54ca9282284e4","title":"Auto-invoke 3-way merge for JSONL conflicts","description":"Currently when git pull encounters merge conflicts in .beads/issues.jsonl, the post-merge hook fails with an error message pointing users to manual resolution or the beads-merge tool.\n\nThis is a poor user experience - the conflict detection is working, but we should automatically invoke the advanced 3-way merging instead of just telling users about it.\n\n**Current behavior:**\n- Detect conflict markers in JSONL\n- Display error with manual resolution options\n- Exit with failure\n\n**Desired behavior:**\n- Detect conflict markers in JSONL\n- Automatically invoke beads-merge 3-way merge\n- Only fail if automatic merge cannot resolve the conflicts\n\n**Reference:**\n- beads-merge tool: https://github.com/neongreen/mono/tree/main/beads-merge\n- Error occurs in post-merge hook during bd sync after git pull","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-11-08T03:09:18.258708-08:00","updated_at":"2025-11-08T03:15:55.529652-08:00","closed_at":"2025-11-08T03:15:55.529652-08:00","source_repo":".","comments":[{"id":32,"issue_id":"bd-jjua","author":"stevey","text":"Implemented automatic 3-way merge resolution for JSONL conflicts.\n\n**Changes Made:**\n\n1. **Modified conflict detection in cmd/bd/import.go (lines 105-152)**\n - When git conflict markers are detected, instead of immediately failing, the system now attempts automatic resolution\n - Calls new `attemptAutoMerge()` function to invoke bd merge tool\n - If auto-merge succeeds, restarts import with the merged JSONL\n - If auto-merge fails, falls back to displaying manual resolution instructions\n\n2. **Added attemptAutoMerge() function (lines 469-585)**\n - Extracts the three git conflict stages: base (:1), ours/left (:2), theirs/right (:3)\n - Creates temporary files for each version\n - Invokes `bd merge` command to perform intelligent 3-way merge\n - Writes merged result back to original file\n - Auto-stages the resolved file with git add\n\n**How it works:**\n- When git pull creates conflicts in .beads/issues.jsonl\n- The post-merge hook runs `bd sync --import-only`\n- Import detects conflict markers on line scan\n- Automatically extracts conflict versions from git\n- Runs bd merge tool with field-level merge intelligence\n- If successful, continues import seamlessly\n- Only fails if conflicts cannot be auto-resolved\n\n**Benefits:**\n- Zero user intervention for most JSONL conflicts\n- Leverages existing bd merge 3-way merge logic\n- Maintains data integrity with field-level merging\n- Graceful fallback to manual resolution when needed\n\n**Testing:**\n- Code builds successfully\n- Ready for real-world testing on next git pull conflict\n\nThe solution transforms the error into an automatic resolution step, significantly improving user experience.","created_at":"2025-11-23T01:48:48Z"},{"id":33,"issue_id":"bd-jjua","author":"stevey","text":"**Discovery: Git merge driver was already configured but not being triggered**\n\nThe 3-way merge tool was properly vendored and `bd init` does configure the git merge driver:\n- `git config merge.beads.driver \"bd merge %A %O %L %R\"`\n- `.gitattributes` entry for `.beads/beads.jsonl merge=beads`\n\nThis should have prevented conflicts entirely by auto-invoking `bd merge` during git merge operations.\n\n**Root Cause:**\nHowever, the automatic merge driver doesn't help when conflicts reach the import stage, which happens in the post-merge hook flow:\n1. Git pull encounters conflicts\n2. Post-merge hook runs `bd sync --import-only`\n3. Import reads the JSONL file and detects conflict markers\n4. Previous behavior: fail with error message\n\nThe merge driver prevents conflicts during git operations, but if conflicts somehow make it through (or if the merge driver itself produces conflicts that it can't resolve), the import process needed fallback handling.\n\n**Our Solution:**\nAdded automatic 3-way merge invocation at the import stage as a safety net. This provides defense-in-depth:\n- Primary: git merge driver prevents most conflicts\n- Fallback: import auto-merge handles any that slip through\n\n**Bonus Discovery:**\nFound that `.beads/issues.jsonl` is a zombie file that keeps reappearing despite multiple removal attempts in git history. Renamed it to `.beads/issues.jsonl.zombie-do-not-use` with a warning message. The canonical file is `.beads/beads.jsonl`.","created_at":"2025-11-23T01:48:48Z"}]} {"id":"bd-jo38","content_hash":"05e0df789df0a8056258cc1594c3f695d77bb735f2b2ae694d8fbb7c14c51bc9","title":"Add WaitGroup tracking to FileWatcher goroutines","description":"FileWatcher spawns goroutines without WaitGroup tracking, causing race condition on shutdown.\n\nLocation: cmd/bd/daemon_watcher.go:123-182, 215-291\n\nProblem:\n- Goroutines spawned without sync.WaitGroup\n- Close() cancels context but doesn't wait for goroutines to exit\n- Race condition: goroutine may access fw.debouncer during Close() cleanup\n- No guarantee goroutine stopped before fw.watcher.Close() is called\n\nSolution:\n- Add sync.WaitGroup field to FileWatcher\n- Track goroutines with wg.Add(1) and defer wg.Done()\n- Call wg.Wait() in Close() before cleanup\n\nImpact: Race condition on daemon shutdown; potential panic\n\nEffort: 2 hours","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-16T14:51:38.591371-08:00","updated_at":"2025-11-16T15:04:00.466334-08:00","closed_at":"2025-11-16T15:04:00.466334-08:00","source_repo":"."} diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 61d1a455..23314f89 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -302,6 +302,10 @@ var rootCmd = &cobra.Command{ // Best-effort tracking - failures are silent trackBdVersion() + // Auto-migrate database on version bump (bd-jgxi) + // Best-effort migration - failures are silent to avoid disrupting commands + autoMigrateOnVersionBump() + // Initialize daemon status socketPath := getSocketPath() daemonStatus = DaemonStatus{ diff --git a/cmd/bd/version_tracking.go b/cmd/bd/version_tracking.go index a104b998..8bf9fe3c 100644 --- a/cmd/bd/version_tracking.go +++ b/cmd/bd/version_tracking.go @@ -1,10 +1,14 @@ package main import ( + "context" "fmt" + "os" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/debug" + "github.com/steveyegge/beads/internal/storage/sqlite" ) // trackBdVersion checks if bd version has changed since last run and updates metadata.json. @@ -112,3 +116,82 @@ func maybeShowUpgradeNotification() { fmt.Println("💡 Run 'bd upgrade review' to see what changed") fmt.Println() } + +// autoMigrateOnVersionBump automatically migrates the database when CLI version changes. +// This function is best-effort - failures are silent to avoid disrupting commands. +// Called from PersistentPreRun after trackBdVersion(). +// +// bd-jgxi: Auto-migrate database on CLI version bump +func autoMigrateOnVersionBump() { + // Only migrate if version upgrade was detected + if !versionUpgradeDetected { + return + } + + // Find the beads directory + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + // No .beads directory - nothing to migrate + return + } + + // Load config to get database path + cfg, err := configfile.Load(beadsDir) + if err != nil || cfg == nil { + // Config load failed or doesn't exist - skip migration + debug.Logf("auto-migrate: skipping migration, config load failed: %v", err) + return + } + + // Get database path + dbPath := cfg.DatabasePath(beadsDir) + + // Check if database exists + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + // No database file - nothing to migrate + debug.Logf("auto-migrate: skipping migration, database does not exist: %s", dbPath) + return + } + + // Open database to check current version + ctx := context.Background() + store, err := sqlite.New(ctx, dbPath) + if err != nil { + // Failed to open database - skip migration + debug.Logf("auto-migrate: failed to open database: %v", err) + return + } + + // Get current database version + dbVersion, err := store.GetMetadata(ctx, "bd_version") + if err != nil { + // Failed to read version - skip migration + debug.Logf("auto-migrate: failed to read database version: %v", err) + _ = store.Close() + return + } + + // Check if migration is needed + if dbVersion == Version { + // Database is already at current version + debug.Logf("auto-migrate: database already at version %s", Version) + _ = store.Close() + return + } + + // Perform migration: update database version + debug.Logf("auto-migrate: migrating database from %s to %s", dbVersion, Version) + if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { + // Migration failed - log and continue + debug.Logf("auto-migrate: failed to update database version: %v", err) + _ = store.Close() + return + } + + // Close database + if err := store.Close(); err != nil { + debug.Logf("auto-migrate: warning: failed to close database: %v", err) + } + + debug.Logf("auto-migrate: successfully migrated database to version %s", Version) +} diff --git a/cmd/bd/version_tracking_test.go b/cmd/bd/version_tracking_test.go index c2554fa7..e6b5c4f5 100644 --- a/cmd/bd/version_tracking_test.go +++ b/cmd/bd/version_tracking_test.go @@ -1,11 +1,14 @@ package main import ( + "context" "os" "path/filepath" "testing" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/storage/sqlite" ) func TestGetVersionsSince(t *testing.T) { @@ -308,3 +311,187 @@ func TestMaybeShowUpgradeNotification(t *testing.T) { t.Error("Should not change acknowledged state on subsequent calls") } } + +func TestAutoMigrateOnVersionBump_NoUpgrade(t *testing.T) { + // Save original state + origUpgradeDetected := versionUpgradeDetected + defer func() { + versionUpgradeDetected = origUpgradeDetected + }() + + // Reset state - no upgrade detected + versionUpgradeDetected = false + + // Should return early without doing anything + autoMigrateOnVersionBump() + + // Test passes if no panic occurs +} + +func TestAutoMigrateOnVersionBump_NoDatabase(t *testing.T) { + // Create temp .beads directory without a database + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads: %v", err) + } + + // Change to temp directory + origWd, _ := os.Getwd() + defer os.Chdir(origWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + + // Create metadata.json + cfg := configfile.DefaultConfig() + cfg.LastBdVersion = "0.22.0" + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Save original state + origUpgradeDetected := versionUpgradeDetected + defer func() { + versionUpgradeDetected = origUpgradeDetected + }() + + // Simulate version upgrade + versionUpgradeDetected = true + + // Should handle gracefully when database doesn't exist + autoMigrateOnVersionBump() + + // Test passes if no panic occurs +} + +func TestAutoMigrateOnVersionBump_MigratesVersion(t *testing.T) { + // Create temp .beads directory + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads: %v", err) + } + + // Change to temp directory + origWd, _ := os.Getwd() + defer os.Chdir(origWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + + // Create metadata.json + cfg := configfile.DefaultConfig() + cfg.LastBdVersion = "0.22.0" + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Create database with old version + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + ctx := context.Background() + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + + // Set old database version + oldVersion := "0.22.0" + if err := store.SetMetadata(ctx, "bd_version", oldVersion); err != nil { + t.Fatalf("Failed to set old version: %v", err) + } + _ = store.Close() + + // Save original state + origUpgradeDetected := versionUpgradeDetected + defer func() { + versionUpgradeDetected = origUpgradeDetected + }() + + // Simulate version upgrade + versionUpgradeDetected = true + + // Call auto-migration + autoMigrateOnVersionBump() + + // Verify database version was updated + store, err = sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer store.Close() + + newVersion, err := store.GetMetadata(ctx, "bd_version") + if err != nil { + t.Fatalf("Failed to read database version: %v", err) + } + + if newVersion != Version { + t.Errorf("Database version not updated: got %q, want %q", newVersion, Version) + } +} + +func TestAutoMigrateOnVersionBump_AlreadyMigrated(t *testing.T) { + // Create temp .beads directory + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads: %v", err) + } + + // Change to temp directory + origWd, _ := os.Getwd() + defer os.Chdir(origWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp dir: %v", err) + } + + // Create metadata.json + cfg := configfile.DefaultConfig() + cfg.LastBdVersion = Version + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Create database with current version + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + ctx := context.Background() + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + + // Set current database version + if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { + t.Fatalf("Failed to set version: %v", err) + } + _ = store.Close() + + // Save original state + origUpgradeDetected := versionUpgradeDetected + defer func() { + versionUpgradeDetected = origUpgradeDetected + }() + + // Simulate version upgrade + versionUpgradeDetected = true + + // Call auto-migration - should be a no-op + autoMigrateOnVersionBump() + + // Verify database version is still current + store, err = sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer store.Close() + + currentVersion, err := store.GetMetadata(ctx, "bd_version") + if err != nil { + t.Fatalf("Failed to read database version: %v", err) + } + + if currentVersion != Version { + t.Errorf("Database version changed unexpectedly: got %q, want %q", currentVersion, Version) + } +}