Files
beads/cmd/bd/migrate.go
Steve Yegge 79b7a48a73 Add database fingerprinting and validation (bd-166)
- Add fingerprint.go with robust URL canonicalization
  - Handles git@, ssh://, https://, http://, file://, and local paths
  - Normalizes URLs to produce consistent repo_id across formats
  - Clone ID uses git repo root for stability

- Update init.go to store repo_id and clone_id metadata
  - repo_id: SHA256 hash of canonical git remote URL
  - clone_id: SHA256 hash of hostname + repo root path

- Add daemon validation to prevent database mismatches
  - Validates repo_id on daemon start
  - Fails on legacy databases (requires explicit migration)
  - Clear error messages with actionable solutions

- Add migrate --update-repo-id command
  - Updates repo_id after remote URL changes
  - Confirmation prompt (can bypass with --yes)
  - Supports --dry-run

Prevents accidental database mixing across repos and provides
migration path for remote URL changes or bd upgrades.

Closes bd-166

Amp-Thread-ID: https://ampcode.com/threads/T-a9d9dab1-5808-4f62-93ea-75a16cca978b
Co-authored-by: Amp <amp@ampcode.com>
2025-10-26 21:15:57 -07:00

502 lines
13 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads"
"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")
updateRepoID, _ := cmd.Flags().GetBool("update-repo-id")
// Handle --update-repo-id first
if updateRepoID {
handleUpdateRepoID(dryRun, autoYes)
return
}
// 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.CanonicalDatabaseName)
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.CanonicalDatabaseName,
"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 handleUpdateRepoID(dryRun bool, autoYes bool) {
// Find database
foundDB := beads.FindDatabasePath()
if foundDB == "" {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "no_database",
"message": "No beads database found. Run 'bd init' first.",
})
} else {
fmt.Fprintf(os.Stderr, "Error: no beads database found\n")
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n")
}
os.Exit(1)
}
// Compute new repo ID
newRepoID, err := beads.ComputeRepoID()
if err != nil {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "compute_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to compute repository ID: %v\n", err)
}
os.Exit(1)
}
// Open database
store, err := sqlite.New(foundDB)
if err != nil {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "open_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
}
os.Exit(1)
}
defer store.Close()
// Get old repo ID
ctx := context.Background()
oldRepoID, err := store.GetMetadata(ctx, "repo_id")
if err != nil && err.Error() != "metadata key not found: repo_id" {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "read_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to read repo_id: %v\n", err)
}
os.Exit(1)
}
oldDisplay := "none"
if len(oldRepoID) >= 8 {
oldDisplay = oldRepoID[:8]
}
if dryRun {
if jsonOutput {
outputJSON(map[string]interface{}{
"dry_run": true,
"old_repo_id": oldDisplay,
"new_repo_id": newRepoID[:8],
})
} else {
fmt.Println("Dry run mode - no changes will be made")
fmt.Printf("Would update repository ID:\n")
fmt.Printf(" Old: %s\n", oldDisplay)
fmt.Printf(" New: %s\n", newRepoID[:8])
}
return
}
// Prompt for confirmation if repo_id exists and differs
if oldRepoID != "" && oldRepoID != newRepoID && !autoYes && !jsonOutput {
fmt.Printf("WARNING: Changing repository ID can break sync if other clones exist.\n\n")
fmt.Printf("Current repo ID: %s\n", oldDisplay)
fmt.Printf("New repo ID: %s\n\n", newRepoID[:8])
fmt.Printf("Continue? [y/N] ")
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" {
fmt.Println("Cancelled")
return
}
}
// Update repo ID
if err := store.SetMetadata(ctx, "repo_id", newRepoID); err != nil {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "update_failed",
"message": err.Error(),
})
} else {
fmt.Fprintf(os.Stderr, "Error: failed to update repo_id: %v\n", err)
}
os.Exit(1)
}
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "success",
"old_repo_id": oldDisplay,
"new_repo_id": newRepoID[:8],
})
} else {
color.Green("✓ Repository ID updated\n\n")
fmt.Printf(" Old: %s\n", oldDisplay)
fmt.Printf(" New: %s\n", newRepoID[:8])
}
}
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")
migrateCmd.Flags().Bool("update-repo-id", false, "Update repository ID (use after changing git remote)")
rootCmd.AddCommand(migrateCmd)
}