Files
beads/cmd/bd/doctor/migration.go
Jordan Hubbard aa2ea48bf2 feat: add FreeBSD release builds (#832)
* feat: add FreeBSD release builds

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* chore: allow manual release dispatch

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: stabilize release workflow on fork

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: clean zig download artifact

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: use valid zig target for freebsd arm

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: disable freebsd arm release build

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: switch freebsd build to pure go

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: skip release publishing on forks

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: satisfy golangci-lint for release PR

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-01-01 10:51:51 -08:00

276 lines
6.9 KiB
Go

package doctor
import (
"bufio"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/syncbranch"
)
// PendingMigration represents a single pending migration
type PendingMigration struct {
Name string // e.g., "hash-ids", "tombstones", "sync"
Description string // e.g., "Convert sequential IDs to hash-based IDs"
Command string // e.g., "bd migrate hash-ids"
Priority int // 1 = critical, 2 = recommended, 3 = optional
}
// DetectPendingMigrations detects all pending migrations for a beads directory
func DetectPendingMigrations(path string) []PendingMigration {
var pending []PendingMigration
// Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
// Skip if .beads doesn't exist
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return pending
}
// Check for sequential IDs (hash-ids migration)
if needsHashIDsMigration(beadsDir) {
pending = append(pending, PendingMigration{
Name: "hash-ids",
Description: "Convert sequential IDs to hash-based IDs",
Command: "bd migrate hash-ids",
Priority: 2,
})
}
// Check for legacy deletions.jsonl (tombstones migration)
if needsTombstonesMigration(beadsDir) {
pending = append(pending, PendingMigration{
Name: "tombstones",
Description: "Convert deletions.jsonl to inline tombstones",
Command: "bd migrate tombstones",
Priority: 2,
})
}
// Check for missing sync-branch config (sync migration)
if needsSyncMigration(path) {
pending = append(pending, PendingMigration{
Name: "sync",
Description: "Configure sync branch for multi-clone setup",
Command: "bd migrate sync beads-sync",
Priority: 3,
})
}
// Check for database version mismatch (main migrate command)
if versionMismatch := checkDatabaseVersionMismatch(beadsDir); versionMismatch != "" {
pending = append(pending, PendingMigration{
Name: "database",
Description: versionMismatch,
Command: "bd migrate",
Priority: 1,
})
}
return pending
}
// CheckPendingMigrations returns a doctor check summarizing all pending migrations
func CheckPendingMigrations(path string) DoctorCheck {
pending := DetectPendingMigrations(path)
if len(pending) == 0 {
return DoctorCheck{
Name: "Pending Migrations",
Status: StatusOK,
Message: "None required",
Category: CategoryMaintenance,
}
}
// Build message with count
message := fmt.Sprintf("%d available", len(pending))
// Build detail with list of migrations
var details []string
var fixes []string
for _, m := range pending {
priority := ""
switch m.Priority {
case 1:
priority = " [critical]"
case 2:
priority = " [recommended]"
case 3:
priority = " [optional]"
}
details = append(details, fmt.Sprintf("• %s: %s%s", m.Name, m.Description, priority))
fixes = append(fixes, m.Command)
}
// Determine status based on highest priority migration
status := StatusOK
for _, m := range pending {
if m.Priority == 1 {
status = StatusError
break
} else if m.Priority == 2 && status != StatusError {
status = StatusWarning
}
}
return DoctorCheck{
Name: "Pending Migrations",
Status: status,
Message: message,
Detail: strings.Join(details, "\n"),
Fix: strings.Join(fixes, "\n"),
Category: CategoryMaintenance,
}
}
// needsHashIDsMigration checks if the database uses sequential IDs
func needsHashIDsMigration(beadsDir string) bool {
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// Skip if no database
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return false
}
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
if err != nil {
return false
}
defer db.Close()
// Get sample of issues
rows, err := db.Query("SELECT id FROM issues ORDER BY created_at LIMIT 10")
if err != nil {
return false
}
defer rows.Close()
var issueIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
issueIDs = append(issueIDs, id)
}
}
if len(issueIDs) == 0 {
return false
}
// Returns true if NOT using hash-based IDs (i.e., using sequential)
return !DetectHashBasedIDs(db, issueIDs)
}
// needsTombstonesMigration checks if deletions.jsonl exists with entries
func needsTombstonesMigration(beadsDir string) bool {
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
info, err := os.Stat(deletionsPath)
if err != nil {
return false // File doesn't exist
}
if info.Size() == 0 {
return false // Empty file
}
// Count non-empty lines
file, err := os.Open(deletionsPath) //nolint:gosec
if err != nil {
return false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if len(scanner.Bytes()) > 0 {
return true // Has at least one entry
}
}
return false
}
// needsSyncMigration checks if sync-branch should be configured
func needsSyncMigration(repoPath string) bool {
// Check if already configured
if syncbranch.GetFromYAML() != "" {
return false
}
// Check if we're in a git repository
_, err := git.GetGitDir()
if err != nil {
return false
}
// Check if has remote (multi-clone indicator)
return hasGitRemote(repoPath)
}
// hasGitRemote checks if the repository has a git remote
func hasGitRemote(repoPath string) bool {
cmd := exec.Command("git", "remote")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return false
}
return len(strings.TrimSpace(string(output))) > 0
}
// checkDatabaseVersionMismatch returns a description if database version is old
func checkDatabaseVersionMismatch(beadsDir string) string {
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// Skip if no database
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return ""
}
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
if err != nil {
return ""
}
defer db.Close()
// Get stored version
var storedVersion string
err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&storedVersion)
if err != nil {
if strings.Contains(err.Error(), "no such table") {
return "Database schema needs update (pre-metadata table)"
}
// No version stored
return ""
}
// Note: We can't compare to current version here since we don't have access
// to the Version variable from main package. The individual check does this.
// This function is just for detecting obviously old databases.
if storedVersion == "" || storedVersion == "unknown" {
return "Database version unknown"
}
return ""
}