diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go index 421be75c..05c063b8 100644 --- a/cmd/bd/migrate.go +++ b/cmd/bd/migrate.go @@ -12,6 +12,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" "github.com/steveyegge/beads/internal/types" _ "modernc.org/sqlite" @@ -45,19 +46,33 @@ This command: // Find .beads directory beadsDir := findBeadsDir() if beadsDir == "" { - if jsonOutput { - outputJSON(map[string]interface{}{ - "error": "no_beads_directory", - "message": "No .beads directory found. Run 'bd init' first.", - }) - } else { - fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n") - fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n") - } - os.Exit(1) + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "no_beads_directory", + "message": "No .beads directory found. Run 'bd init' first.", + }) + } else { + fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n") + fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n") + } + os.Exit(1) } - // Detect all database files + // Load config to get target database name (respects user's config.json) + cfg, err := loadOrCreateConfig(beadsDir) + if err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "config_load_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", err) + } + os.Exit(1) + } + + // Detect all database files databases, err := detectDatabases(beadsDir) if err != nil { if jsonOutput { @@ -84,8 +99,8 @@ This command: return } - // Check if beads.db exists and is current - targetPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + // Check if target database exists and is current (use config.json name) + targetPath := cfg.DatabasePath(beadsDir) var currentDB *dbInfo var oldDBs []*dbInfo @@ -109,7 +124,7 @@ This command: color.Green(" ✓ Version matches\n") } } else { - color.Yellow(" No beads.db found\n") + color.Yellow(" No %s found\n", cfg.Database) } if len(oldDBs) > 0 { @@ -141,7 +156,7 @@ This command: for _, db := range oldDBs { fmt.Fprintf(os.Stderr, " - %s (version: %s)\n", filepath.Base(db.path), db.version) } - fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to beads.db and remove others.\n") + fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to %s and remove others.\n", cfg.Database) } os.Exit(1) } else if currentDB != nil && currentDB.version != Version { @@ -161,7 +176,7 @@ This command: } else { fmt.Println("Dry run mode - no changes will be made") if needsMigration { - fmt.Printf("Would migrate: %s → beads.db\n", filepath.Base(oldDBs[0].path)) + fmt.Printf("Would migrate: %s → %s\n", filepath.Base(oldDBs[0].path), cfg.Database) } if needsVersionUpdate { fmt.Printf("Would update version: %s → %s\n", currentDB.version, Version) @@ -173,11 +188,30 @@ This command: return } - // Migrate old database to beads.db + // Migrate old database to target name (from config.json) if needsMigration { oldDB := oldDBs[0] if !jsonOutput { - fmt.Printf("Migrating database: %s → beads.db\n", filepath.Base(oldDB.path)) + fmt.Printf("Migrating database: %s → %s\n", filepath.Base(oldDB.path), cfg.Database) + } + + // Create backup before migration + if !dryRun { + backupPath := strings.TrimSuffix(oldDB.path, ".db") + ".backup-pre-migrate-" + time.Now().Format("20060102-150405") + ".db" + if err := copyFile(oldDB.path, backupPath); err != nil { + if jsonOutput { + outputJSON(map[string]interface{}{ + "error": "backup_failed", + "message": err.Error(), + }) + } else { + fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err) + } + os.Exit(1) + } + if !jsonOutput { + color.Green("✓ Created backup: %s\n", filepath.Base(backupPath)) + } } if err := os.Rename(oldDB.path, targetPath); err != nil { @@ -192,6 +226,9 @@ This command: os.Exit(1) } + // Clean up orphaned WAL files from old database + _ = cleanupWALFiles(oldDB.path) + // Update current DB reference currentDB = oldDB currentDB.path = targetPath @@ -208,6 +245,9 @@ This command: fmt.Printf("Updating schema version: %s → %s\n", currentDB.version, Version) } + // Clean up WAL files before opening to avoid "disk I/O error" + _ = cleanupWALFiles(currentDB.path) + store, err := sqlite.New(currentDB.path) if err != nil { if jsonOutput { @@ -234,7 +274,13 @@ This command: } os.Exit(1) } - _ = store.Close() + + // Close and checkpoint to finalize the WAL + if err := store.Close(); err != nil { + if !jsonOutput { + color.Yellow("Warning: error closing database: %v\n", err) + } + } if !jsonOutput { color.Green("✓ Version updated\n\n") @@ -369,7 +415,7 @@ This command: if jsonOutput { outputJSON(map[string]interface{}{ "status": "success", - "current_database": beads.CanonicalDatabaseName, + "current_database": cfg.Database, "version": Version, "migrated": needsMigration, "version_updated": needsVersionUpdate, @@ -377,7 +423,7 @@ This command: }) } else { fmt.Println("\nMigration complete!") - fmt.Printf("Current database: beads.db (version %s)\n", Version) + fmt.Printf("Current database: %s (version %s)\n", cfg.Database, Version) } }, } @@ -582,6 +628,33 @@ func handleUpdateRepoID(dryRun bool, autoYes bool) { } } +// loadOrCreateConfig loads config.json or creates default if not found +func loadOrCreateConfig(beadsDir string) (*configfile.Config, error) { + cfg, err := configfile.Load(beadsDir) + if err != nil { + return nil, err + } + + // Create default if no config exists + if cfg == nil { + cfg = configfile.DefaultConfig(Version) + } + + return cfg, nil +} + +// cleanupWALFiles removes orphaned WAL and SHM files for a given database path +func cleanupWALFiles(dbPath string) error { + walPath := dbPath + "-wal" + shmPath := dbPath + "-shm" + + // Best effort - don't fail if these don't exist + _ = os.Remove(walPath) + _ = os.Remove(shmPath) + + return nil +} + func init() { migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts") migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration") diff --git a/cmd/bd/migrate_test.go b/cmd/bd/migrate_test.go index d332858d..e21350d9 100644 --- a/cmd/bd/migrate_test.go +++ b/cmd/bd/migrate_test.go @@ -125,3 +125,53 @@ func TestFormatDBList(t *testing.T) { t.Errorf("Expected version 0.16.0, got %s", result[1]["version"]) } } + +func TestMigrateRespectsConfigJSON(t *testing.T) { + // Test that migrate respects custom database name from config.json + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("Failed to create .beads directory: %v", err) + } + + // Create config.json with custom database name + configPath := filepath.Join(beadsDir, "config.json") + configData := `{"database": "beady.db", "version": "0.21.1", "jsonl_export": "beady.jsonl"}` + if err := os.WriteFile(configPath, []byte(configData), 0600); err != nil { + t.Fatalf("Failed to create config.json: %v", err) + } + + // Create old database with custom name + oldDBPath := filepath.Join(beadsDir, "beady.db") + store, err := sqlite.New(oldDBPath) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + ctx := context.Background() + if err := store.SetMetadata(ctx, "bd_version", "0.21.1"); err != nil { + t.Fatalf("Failed to set version: %v", err) + } + _ = store.Close() + + // Load config + cfg, err := loadOrCreateConfig(beadsDir) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify config respects custom database name + if cfg.Database != "beady.db" { + t.Errorf("Expected database name 'beady.db', got %s", cfg.Database) + } + + expectedPath := filepath.Join(beadsDir, "beady.db") + actualPath := cfg.DatabasePath(beadsDir) + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + + // Verify database exists at custom path + if _, err := os.Stat(actualPath); os.IsNotExist(err) { + t.Errorf("Database does not exist at custom path: %s", actualPath) + } +}