Add bd migrate command for database upgrades (bd-164)
- Implement bd migrate command with detection, version checking, and cleanup - Update daemon to suggest bd migrate for version mismatches - Enhance CLI version warnings to recommend bd migrate - Add comprehensive tests for migration scenarios - Document migration workflow in QUICKSTART.md and AGENTS.md Completes bd-164 and epic bd-159 Amp-Thread-ID: https://ampcode.com/threads/T-34ea4682-8c48-44c2-8421-dc40f867773b Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
371
cmd/bd/migrate.go
Normal file
371
cmd/bd/migrate.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var migrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate database to current version",
|
||||
Long: `Detect and migrate database files to the current version.
|
||||
|
||||
This command:
|
||||
- Finds all .db files in .beads/
|
||||
- Checks schema versions
|
||||
- Migrates old databases to beads.db
|
||||
- Updates schema version metadata
|
||||
- Removes stale databases (with confirmation)`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
autoYes, _ := cmd.Flags().GetBool("yes")
|
||||
cleanup, _ := cmd.Flags().GetBool("cleanup")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Detect all database files
|
||||
databases, err := detectDatabases(beadsDir)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "detection_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(databases) == 0 {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "no_databases",
|
||||
"message": "No database files found in .beads/",
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "No database files found in %s\n", beadsDir)
|
||||
fmt.Fprintf(os.Stderr, "Run 'bd init' to create a new database.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if beads.db exists and is current
|
||||
targetPath := filepath.Join(beadsDir, "beads.db")
|
||||
var currentDB *dbInfo
|
||||
var oldDBs []*dbInfo
|
||||
|
||||
for _, db := range databases {
|
||||
if db.path == targetPath {
|
||||
currentDB = db
|
||||
} else {
|
||||
oldDBs = append(oldDBs, db)
|
||||
}
|
||||
}
|
||||
|
||||
// Print status
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Database migration status:\n\n")
|
||||
if currentDB != nil {
|
||||
fmt.Printf(" Current database: %s\n", filepath.Base(currentDB.path))
|
||||
fmt.Printf(" Schema version: %s\n", currentDB.version)
|
||||
if currentDB.version != Version {
|
||||
color.Yellow(" ⚠ Version mismatch (current: %s, expected: %s)\n", currentDB.version, Version)
|
||||
} else {
|
||||
color.Green(" ✓ Version matches\n")
|
||||
}
|
||||
} else {
|
||||
color.Yellow(" No beads.db found\n")
|
||||
}
|
||||
|
||||
if len(oldDBs) > 0 {
|
||||
fmt.Printf("\n Old databases found:\n")
|
||||
for _, db := range oldDBs {
|
||||
fmt.Printf(" - %s (version: %s)\n", filepath.Base(db.path), db.version)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Determine migration actions
|
||||
needsMigration := false
|
||||
needsVersionUpdate := false
|
||||
|
||||
if currentDB == nil && len(oldDBs) == 1 {
|
||||
// Migrate single old database to beads.db
|
||||
needsMigration = true
|
||||
} else if currentDB == nil && len(oldDBs) > 1 {
|
||||
// Multiple old databases - ambiguous
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "ambiguous_migration",
|
||||
"message": "Multiple old database files found",
|
||||
"databases": formatDBList(oldDBs),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: multiple old database files found:\n")
|
||||
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")
|
||||
}
|
||||
os.Exit(1)
|
||||
} else if currentDB != nil && currentDB.version != Version {
|
||||
// Update version metadata
|
||||
needsVersionUpdate = true
|
||||
}
|
||||
|
||||
// Perform migrations
|
||||
if dryRun {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"needs_migration": needsMigration,
|
||||
"needs_version_update": needsVersionUpdate,
|
||||
"old_databases": formatDBList(oldDBs),
|
||||
})
|
||||
} 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))
|
||||
}
|
||||
if needsVersionUpdate {
|
||||
fmt.Printf("Would update version: %s → %s\n", currentDB.version, Version)
|
||||
}
|
||||
if cleanup && len(oldDBs) > 0 {
|
||||
fmt.Printf("Would remove %d old database(s)\n", len(oldDBs))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Migrate old database to beads.db
|
||||
if needsMigration {
|
||||
oldDB := oldDBs[0]
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Migrating database: %s → beads.db\n", filepath.Base(oldDB.path))
|
||||
}
|
||||
|
||||
if err := os.Rename(oldDB.path, targetPath); err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "migration_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to migrate database: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Update current DB reference
|
||||
currentDB = oldDB
|
||||
currentDB.path = targetPath
|
||||
needsVersionUpdate = true
|
||||
|
||||
if !jsonOutput {
|
||||
color.Green("✓ Migration complete\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Update schema version if needed
|
||||
if needsVersionUpdate && currentDB != nil {
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Updating schema version: %s → %s\n", currentDB.version, Version)
|
||||
}
|
||||
|
||||
store, err := sqlite.New(currentDB.path)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "version_update_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := store.SetMetadata(ctx, "bd_version", Version); err != nil {
|
||||
_ = store.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "version_update_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to update version: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = store.Close()
|
||||
|
||||
if !jsonOutput {
|
||||
color.Green("✓ Version updated\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old databases
|
||||
if cleanup && len(oldDBs) > 0 {
|
||||
// If we migrated one database, remove it from the cleanup list
|
||||
if needsMigration {
|
||||
oldDBs = oldDBs[1:]
|
||||
}
|
||||
|
||||
if len(oldDBs) > 0 {
|
||||
if !autoYes && !jsonOutput {
|
||||
fmt.Printf("Found %d old database file(s):\n", len(oldDBs))
|
||||
for _, db := range oldDBs {
|
||||
fmt.Printf(" - %s (version: %s)\n", filepath.Base(db.path), db.version)
|
||||
}
|
||||
fmt.Print("\nRemove these files? [y/N] ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" {
|
||||
fmt.Println("Cleanup cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, db := range oldDBs {
|
||||
if err := os.Remove(db.path); err != nil {
|
||||
if !jsonOutput {
|
||||
color.Yellow("Warning: failed to remove %s: %v\n", filepath.Base(db.path), err)
|
||||
}
|
||||
} else if !jsonOutput {
|
||||
fmt.Printf("Removed %s\n", filepath.Base(db.path))
|
||||
}
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
color.Green("\n✓ Cleanup complete\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final status
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "success",
|
||||
"current_database": "beads.db",
|
||||
"version": Version,
|
||||
"migrated": needsMigration,
|
||||
"version_updated": needsVersionUpdate,
|
||||
"cleaned_up": cleanup && len(oldDBs) > 0,
|
||||
})
|
||||
} else {
|
||||
fmt.Println("\nMigration complete!")
|
||||
fmt.Printf("Current database: beads.db (version %s)\n", Version)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type dbInfo struct {
|
||||
path string
|
||||
version string
|
||||
}
|
||||
|
||||
func detectDatabases(beadsDir string) ([]*dbInfo, error) {
|
||||
pattern := filepath.Join(beadsDir, "*.db")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search for databases: %w", err)
|
||||
}
|
||||
|
||||
var databases []*dbInfo
|
||||
for _, match := range matches {
|
||||
// Skip backup files
|
||||
if strings.HasSuffix(match, ".backup.db") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if file exists and is readable
|
||||
info, err := os.Stat(match)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get version from database
|
||||
version := getDBVersion(match)
|
||||
databases = append(databases, &dbInfo{
|
||||
path: match,
|
||||
version: version,
|
||||
})
|
||||
}
|
||||
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
func getDBVersion(dbPath string) string {
|
||||
// Open database read-only
|
||||
db, err := sql.Open("sqlite", dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Try to read version from metadata table
|
||||
var version string
|
||||
err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&version)
|
||||
if err == nil {
|
||||
return version
|
||||
}
|
||||
|
||||
// Check if metadata table exists
|
||||
var tableName string
|
||||
err = db.QueryRow(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='metadata'
|
||||
`).Scan(&tableName)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return "pre-0.17.5"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
|
||||
|
||||
func formatDBList(dbs []*dbInfo) []map[string]string {
|
||||
result := make([]map[string]string, len(dbs))
|
||||
for i, db := range dbs {
|
||||
result[i] = map[string]string{
|
||||
"path": db.path,
|
||||
"name": filepath.Base(db.path),
|
||||
"version": db.version,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts")
|
||||
migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration")
|
||||
migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
|
||||
rootCmd.AddCommand(migrateCmd)
|
||||
}
|
||||
Reference in New Issue
Block a user