refactor(doctor): split doctor.go into modular package files (#653)
* refactor(doctor): split doctor.go into modular package files Split the 3,171-line doctor.go into logical sub-files within the cmd/bd/doctor/ package, reducing the main file to 834 lines (74% reduction). New package structure: - types.go: DoctorCheck struct and status constants - installation.go: CheckInstallation, CheckMultipleDatabases, CheckPermissions - git.go: CheckGitHooks, CheckMergeDriver, CheckSyncBranch* checks - database.go: CheckDatabaseVersion, CheckSchemaCompatibility, CheckDatabaseJSONLSync - version.go: CheckCLIVersion, CheckMetadataVersionTracking, CompareVersions - integrity.go: CheckIDFormat, CheckDependencyCycles, CheckTombstones - daemon.go: CheckDaemonStatus, CheckVersionMismatch - quick.go: Quick checks for sync-branch and hooks Updated tests to use exported doctor.CheckXxx() functions and doctor.StatusXxx constants. * fix(doctor): suppress gosec G204 false positives Add #nosec G204 comments to exec.Command calls in CheckSyncBranchHealth where variables come from trusted sources (config files or hardcoded values like "main"/"master"/"origin"), not untrusted user input.
This commit is contained in:
2192
cmd/bd/doctor.go
2192
cmd/bd/doctor.go
File diff suppressed because it is too large
Load Diff
@@ -2,21 +2,16 @@ package doctor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DoctorCheck represents a single diagnostic check result
|
||||
type DoctorCheck struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "ok", "warning", or "error"
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
|
||||
// CheckClaude returns Claude integration verification as a DoctorCheck
|
||||
func CheckClaude() DoctorCheck {
|
||||
// Check what's installed
|
||||
@@ -350,3 +345,153 @@ func CheckDocumentationBdPrimeReference(repoPath string) DoctorCheck {
|
||||
Detail: "Files: " + strings.Join(filesWithBdPrime, ", "),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckClaudePlugin checks if the beads Claude Code plugin is installed and up to date.
|
||||
func CheckClaudePlugin() DoctorCheck {
|
||||
// Check if running in Claude Code
|
||||
if os.Getenv("CLAUDECODE") != "1" {
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not running in Claude Code)",
|
||||
}
|
||||
}
|
||||
|
||||
// Get plugin version from installed_plugins.json
|
||||
pluginVersion, pluginInstalled, err := GetClaudePluginVersion()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to check plugin version",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if !pluginInstalled {
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusWarning,
|
||||
Message: "beads plugin not installed",
|
||||
Fix: "Install plugin: /plugin install beads@beads-marketplace",
|
||||
}
|
||||
}
|
||||
|
||||
// Query PyPI for latest MCP version
|
||||
latestMCPVersion, err := fetchLatestPyPIVersion("beads-mcp")
|
||||
if err != nil {
|
||||
// Network error - don't fail
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("version %s (unable to check for updates)", pluginVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
if latestMCPVersion == "" || pluginVersion == latestMCPVersion {
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("version %s (latest)", pluginVersion),
|
||||
}
|
||||
}
|
||||
|
||||
if CompareVersions(latestMCPVersion, pluginVersion) > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("version %s (latest: %s)", pluginVersion, latestMCPVersion),
|
||||
Fix: "Update plugin: /plugin update beads@beads-marketplace\nRestart Claude Code after update",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Claude Plugin",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("version %s", pluginVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// GetClaudePluginVersion returns the installed beads Claude plugin version.
|
||||
func GetClaudePluginVersion() (version string, installed bool, err error) {
|
||||
// Get user home directory (cross-platform)
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
|
||||
// Path to installed_plugins.json
|
||||
pluginPath := filepath.Join(homeDir, ".claude", "plugins", "installed_plugins.json")
|
||||
|
||||
// Read plugin file
|
||||
data, err := os.ReadFile(pluginPath) // #nosec G304 - path is controlled
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("unable to read plugin file: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON - handle nested structure
|
||||
var pluginData struct {
|
||||
Version int `json:"version"`
|
||||
Plugins map[string]struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &pluginData); err != nil {
|
||||
return "", false, fmt.Errorf("unable to parse plugin file: %w", err)
|
||||
}
|
||||
|
||||
// Look for beads plugin
|
||||
if plugin, ok := pluginData.Plugins["beads@beads-marketplace"]; ok {
|
||||
return plugin.Version, true, nil
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func fetchLatestPyPIVersion(packageName string) (string, error) {
|
||||
url := fmt.Sprintf("https://pypi.org/pypi/%s/json", packageName)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set User-Agent
|
||||
req.Header.Set("User-Agent", "beads-cli-doctor")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("pypi api returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Info struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"info"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return data.Info.Version, nil
|
||||
}
|
||||
|
||||
123
cmd/bd/doctor/daemon.go
Normal file
123
cmd/bd/doctor/daemon.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/steveyegge/beads/internal/daemon"
|
||||
)
|
||||
|
||||
// CheckDaemonStatus checks the health of the daemon for a workspace.
|
||||
// It checks for stale sockets, multiple daemons, and version mismatches.
|
||||
func CheckDaemonStatus(path string, cliVersion string) DoctorCheck {
|
||||
// Normalize path for reliable comparison (handles symlinks)
|
||||
wsNorm, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
// Fallback to absolute path if EvalSymlinks fails
|
||||
wsNorm, _ = filepath.Abs(path)
|
||||
}
|
||||
|
||||
// Use global daemon discovery (registry-based)
|
||||
daemons, err := daemon.DiscoverDaemons(nil)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to check daemon health",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to this workspace using normalized paths
|
||||
var workspaceDaemons []daemon.DaemonInfo
|
||||
for _, d := range daemons {
|
||||
dPath, err := filepath.EvalSymlinks(d.WorkspacePath)
|
||||
if err != nil {
|
||||
dPath, _ = filepath.Abs(d.WorkspacePath)
|
||||
}
|
||||
if dPath == wsNorm {
|
||||
workspaceDaemons = append(workspaceDaemons, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stale socket directly (catches cases where RPC failed so WorkspacePath is empty)
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
socketPath := filepath.Join(beadsDir, "bd.sock")
|
||||
if _, err := os.Stat(socketPath); err == nil {
|
||||
// Socket exists - try to connect
|
||||
if len(workspaceDaemons) == 0 {
|
||||
// Socket exists but no daemon found in registry - likely stale
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusWarning,
|
||||
Message: "Stale daemon socket detected",
|
||||
Detail: fmt.Sprintf("Socket exists at %s but daemon is not responding", socketPath),
|
||||
Fix: "Run 'bd daemons killall' to clean up stale sockets",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(workspaceDaemons) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusOK,
|
||||
Message: "No daemon running (will auto-start on next command)",
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if multiple daemons for same workspace
|
||||
if len(workspaceDaemons) > 1 {
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Multiple daemons detected for this workspace (%d)", len(workspaceDaemons)),
|
||||
Fix: "Run 'bd daemons killall' to clean up duplicate daemons",
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stale or version mismatched daemons
|
||||
for _, d := range workspaceDaemons {
|
||||
if !d.Alive {
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusWarning,
|
||||
Message: "Stale daemon detected",
|
||||
Detail: fmt.Sprintf("PID %d is not alive", d.PID),
|
||||
Fix: "Run 'bd daemons killall' to clean up stale daemons",
|
||||
}
|
||||
}
|
||||
|
||||
if d.Version != cliVersion {
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Version mismatch (daemon: %s, CLI: %s)", d.Version, cliVersion),
|
||||
Fix: "Run 'bd daemons killall' to restart daemons with current version",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Daemon running (PID %d, version %s)", workspaceDaemons[0].PID, workspaceDaemons[0].Version),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckVersionMismatch checks if the database version matches the CLI version.
|
||||
// Returns a warning message if there's a mismatch, or empty string if versions match or can't be read.
|
||||
func CheckVersionMismatch(db *sql.DB, cliVersion string) string {
|
||||
var dbVersion string
|
||||
err := db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&dbVersion)
|
||||
if err != nil {
|
||||
return "" // Can't read version, skip
|
||||
}
|
||||
|
||||
if dbVersion != "" && dbVersion != cliVersion {
|
||||
return fmt.Sprintf("Version mismatch (CLI: %s, database: %s)", cliVersion, dbVersion)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
608
cmd/bd/doctor/database.go
Normal file
608
cmd/bd/doctor/database.go
Normal file
@@ -0,0 +1,608 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// localConfig represents the config.yaml structure for no-db mode detection
|
||||
type localConfig struct {
|
||||
SyncBranch string `yaml:"sync-branch"`
|
||||
NoDb bool `yaml:"no-db"`
|
||||
}
|
||||
|
||||
// CheckDatabaseVersion checks the database version and migration status
|
||||
func CheckDatabaseVersion(path string, cliVersion string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Check metadata.json first for custom database name
|
||||
var dbPath string
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
} else {
|
||||
// Fall back to canonical database name
|
||||
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
}
|
||||
|
||||
// Check if database file exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Check if JSONL exists
|
||||
// Check canonical (issues.jsonl) first, then legacy (beads.jsonl)
|
||||
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
|
||||
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
|
||||
|
||||
var jsonlPath string
|
||||
if _, err := os.Stat(issuesJSONL); err == nil {
|
||||
jsonlPath = issuesJSONL
|
||||
} else if _, err := os.Stat(beadsJSONL); err == nil {
|
||||
jsonlPath = beadsJSONL
|
||||
}
|
||||
|
||||
if jsonlPath != "" {
|
||||
// JSONL exists but no database - check if this is no-db mode or fresh clone
|
||||
// Use proper YAML parsing to detect no-db mode (bd-r6k2)
|
||||
if isNoDbModeConfigured(beadsDir) {
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusOK,
|
||||
Message: "JSONL-only mode",
|
||||
Detail: "Using issues.jsonl (no SQLite database)",
|
||||
}
|
||||
}
|
||||
|
||||
// This is a fresh clone - JSONL exists but no database and not no-db mode
|
||||
// Count issues and detect prefix for helpful suggestion
|
||||
issueCount := countIssuesInJSONLFile(jsonlPath)
|
||||
prefix := detectPrefixFromJSONL(jsonlPath)
|
||||
|
||||
message := "Fresh clone detected (no database)"
|
||||
detail := fmt.Sprintf("Found %d issue(s) in JSONL that need to be imported", issueCount)
|
||||
fix := "Run 'bd init' to hydrate the database from JSONL"
|
||||
if prefix != "" {
|
||||
fix = fmt.Sprintf("Run 'bd init' to hydrate the database (detected prefix: %s)", prefix)
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusWarning,
|
||||
Message: message,
|
||||
Detail: detail,
|
||||
Fix: fix,
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusError,
|
||||
Message: "No beads.db found",
|
||||
Fix: "Run 'bd init' to create database",
|
||||
}
|
||||
}
|
||||
|
||||
// Get database version
|
||||
dbVersion := getDatabaseVersionFromPath(dbPath)
|
||||
|
||||
if dbVersion == "unknown" {
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusError,
|
||||
Message: "Unable to read database version",
|
||||
Detail: "Storage: SQLite",
|
||||
Fix: "Database may be corrupted. Try 'bd migrate'",
|
||||
}
|
||||
}
|
||||
|
||||
if dbVersion == "pre-0.17.5" {
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("version %s (very old)", dbVersion),
|
||||
Detail: "Storage: SQLite",
|
||||
Fix: "Run 'bd migrate' to upgrade database schema",
|
||||
}
|
||||
}
|
||||
|
||||
if dbVersion != cliVersion {
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, cliVersion),
|
||||
Detail: "Storage: SQLite",
|
||||
Fix: "Run 'bd migrate' to sync database with CLI version",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Database",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("version %s", dbVersion),
|
||||
Detail: "Storage: SQLite",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSchemaCompatibility checks if all required tables and columns are present
|
||||
func CheckSchemaCompatibility(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Check metadata.json first for custom database name
|
||||
var dbPath string
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
} else {
|
||||
// Fall back to canonical database name
|
||||
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
}
|
||||
|
||||
// If no database, skip this check
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatibility",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// Open database (bd-ckvw: This will run migrations and schema probe)
|
||||
// Note: We can't use the global 'store' because doctor can check arbitrary paths
|
||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)")
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatibility",
|
||||
Status: StatusError,
|
||||
Message: "Failed to open database",
|
||||
Detail: err.Error(),
|
||||
Fix: "Database may be corrupted. Try 'bd migrate' or restore from backup",
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run schema probe (defined in internal/storage/sqlite/schema_probe.go)
|
||||
// This is a simplified version since we can't import the internal package directly
|
||||
// Check all critical tables and columns
|
||||
criticalChecks := map[string][]string{
|
||||
"issues": {"id", "title", "content_hash", "external_ref", "compacted_at", "close_reason"},
|
||||
"dependencies": {"issue_id", "depends_on_id", "type"},
|
||||
"child_counters": {"parent_id", "last_child"},
|
||||
"export_hashes": {"issue_id", "content_hash"},
|
||||
}
|
||||
|
||||
var missingElements []string
|
||||
for table, columns := range criticalChecks {
|
||||
// Try to query all columns
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM %s LIMIT 0",
|
||||
strings.Join(columns, ", "),
|
||||
table,
|
||||
) // #nosec G201 -- table/column names sourced from hardcoded map
|
||||
_, err := db.Exec(query)
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "no such table") {
|
||||
missingElements = append(missingElements, fmt.Sprintf("table:%s", table))
|
||||
} else if strings.Contains(errMsg, "no such column") {
|
||||
// Find which columns are missing
|
||||
for _, col := range columns {
|
||||
colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) // #nosec G201 -- names come from static schema definition
|
||||
if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") {
|
||||
missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingElements) > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatibility",
|
||||
Status: StatusError,
|
||||
Message: "Database schema is incomplete or incompatible",
|
||||
Detail: fmt.Sprintf("Missing: %s", strings.Join(missingElements, ", ")),
|
||||
Fix: "Run 'bd migrate' to upgrade schema, or if daemon is running an old version, run 'bd daemons killall' to restart",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatibility",
|
||||
Status: StatusOK,
|
||||
Message: "All required tables and columns present",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDatabaseIntegrity runs SQLite's PRAGMA integrity_check (bd-2au)
|
||||
func CheckDatabaseIntegrity(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Get database path (same logic as CheckSchemaCompatibility)
|
||||
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)
|
||||
}
|
||||
|
||||
// If no database, skip this check
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Database Integrity",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// Open database in read-only mode for integrity check
|
||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)")
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Database Integrity",
|
||||
Status: StatusError,
|
||||
Message: "Failed to open database for integrity check",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run PRAGMA integrity_check
|
||||
// This checks the entire database for corruption
|
||||
rows, err := db.Query("PRAGMA integrity_check")
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Database Integrity",
|
||||
Status: StatusError,
|
||||
Message: "Failed to run integrity check",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []string
|
||||
for rows.Next() {
|
||||
var result string
|
||||
if err := rows.Scan(&result); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// "ok" means no corruption detected
|
||||
if len(results) == 1 && results[0] == "ok" {
|
||||
return DoctorCheck{
|
||||
Name: "Database Integrity",
|
||||
Status: StatusOK,
|
||||
Message: "No corruption detected",
|
||||
}
|
||||
}
|
||||
|
||||
// Any other result indicates corruption
|
||||
return DoctorCheck{
|
||||
Name: "Database Integrity",
|
||||
Status: StatusError,
|
||||
Message: "Database corruption detected",
|
||||
Detail: strings.Join(results, "; "),
|
||||
Fix: "Database may need recovery. Export with 'bd export' if possible, then restore from backup or reinitialize",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDatabaseJSONLSync checks if database and JSONL are in sync
|
||||
func CheckDatabaseJSONLSync(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
// Find JSONL file
|
||||
var jsonlPath string
|
||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||
testPath := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(testPath); err == nil {
|
||||
jsonlPath = testPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no database, skip this check
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// If no JSONL, skip this check
|
||||
if jsonlPath == "" {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no JSONL file)",
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read JSONL first (doesn't depend on database)
|
||||
jsonlCount, jsonlPrefixes, jsonlErr := CountJSONLIssues(jsonlPath)
|
||||
|
||||
// Single database open for all queries (instead of 3 separate opens)
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
// Database can't be opened. If JSONL has issues, suggest recovery.
|
||||
if jsonlErr == nil && jsonlCount > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Database cannot be opened but JSONL contains %d issues", jsonlCount),
|
||||
Detail: err.Error(),
|
||||
Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)),
|
||||
}
|
||||
}
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to open database",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get database count
|
||||
var dbCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&dbCount)
|
||||
if err != nil {
|
||||
// Database opened but can't query. If JSONL has issues, suggest recovery.
|
||||
if jsonlErr == nil && jsonlCount > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Database cannot be queried but JSONL contains %d issues", jsonlCount),
|
||||
Detail: err.Error(),
|
||||
Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)),
|
||||
}
|
||||
}
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to query database",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get database prefix
|
||||
var dbPrefix string
|
||||
err = db.QueryRow("SELECT value FROM config WHERE key = ?", "issue_prefix").Scan(&dbPrefix)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to read database prefix",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Use JSONL error if we got it earlier
|
||||
if jsonlErr != nil {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to read JSONL file",
|
||||
Detail: jsonlErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issues
|
||||
var issues []string
|
||||
|
||||
// Count mismatch
|
||||
if dbCount != jsonlCount {
|
||||
issues = append(issues, fmt.Sprintf("Count mismatch: database has %d issues, JSONL has %d", dbCount, jsonlCount))
|
||||
}
|
||||
|
||||
// Prefix mismatch (only check most common prefix in JSONL)
|
||||
if dbPrefix != "" && len(jsonlPrefixes) > 0 {
|
||||
var mostCommonPrefix string
|
||||
maxCount := 0
|
||||
for prefix, count := range jsonlPrefixes {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
mostCommonPrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
// Only warn if majority of issues have wrong prefix
|
||||
if mostCommonPrefix != dbPrefix && maxCount > jsonlCount/2 {
|
||||
issues = append(issues, fmt.Sprintf("Prefix mismatch: database uses %q but most JSONL issues use %q", dbPrefix, mostCommonPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
// If we found issues, report them
|
||||
if len(issues) > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: strings.Join(issues, "; "),
|
||||
Fix: "Run 'bd sync --import-only' to import JSONL updates or 'bd import -i issues.jsonl --rename-on-import' to fix prefixes",
|
||||
}
|
||||
}
|
||||
|
||||
// Check modification times (only if counts match)
|
||||
dbInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to check database file",
|
||||
}
|
||||
}
|
||||
|
||||
jsonlInfo, err := os.Stat(jsonlPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to check JSONL file",
|
||||
}
|
||||
}
|
||||
|
||||
if jsonlInfo.ModTime().After(dbInfo.ModTime()) {
|
||||
timeDiff := jsonlInfo.ModTime().Sub(dbInfo.ModTime())
|
||||
if timeDiff > 30*time.Second {
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusWarning,
|
||||
Message: "JSONL is newer than database",
|
||||
Fix: "Run 'bd sync --import-only' to import JSONL updates",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: StatusOK,
|
||||
Message: "Database and JSONL are in sync",
|
||||
}
|
||||
}
|
||||
|
||||
// Fix functions
|
||||
|
||||
// FixDatabaseConfig auto-detects and fixes metadata.json database/JSONL config mismatches
|
||||
func FixDatabaseConfig(path string) error {
|
||||
return fix.DatabaseConfig(path)
|
||||
}
|
||||
|
||||
// FixDBJSONLSync fixes database-JSONL sync issues by running bd sync --import-only
|
||||
func FixDBJSONLSync(path string) error {
|
||||
return fix.DBJSONLSync(path)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// getDatabaseVersionFromPath reads the database version from the given path
|
||||
func getDatabaseVersionFromPath(dbPath string) string {
|
||||
db, err := sql.Open("sqlite3", "file:"+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"
|
||||
}
|
||||
|
||||
// CountJSONLIssues counts issues in the JSONL file and returns the count, prefixes, and any error
|
||||
func CountJSONLIssues(jsonlPath string) (int, map[string]int, error) {
|
||||
// jsonlPath is safe: constructed from filepath.Join(beadsDir, hardcoded name)
|
||||
file, err := os.Open(jsonlPath) //nolint:gosec
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to open JSONL file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
count := 0
|
||||
prefixes := make(map[string]int)
|
||||
errorCount := 0
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse JSON to get the ID
|
||||
var issue map[string]interface{}
|
||||
if err := json.Unmarshal(line, &issue); err != nil {
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if id, ok := issue["id"].(string); ok {
|
||||
count++
|
||||
// Extract prefix (everything before the last dash)
|
||||
lastDash := strings.LastIndex(id, "-")
|
||||
if lastDash != -1 {
|
||||
prefixes[id[:lastDash]]++
|
||||
} else {
|
||||
prefixes[id]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return count, prefixes, fmt.Errorf("failed to read JSONL file: %w", err)
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
return count, prefixes, fmt.Errorf("skipped %d malformed lines in JSONL", errorCount)
|
||||
}
|
||||
|
||||
return count, prefixes, nil
|
||||
}
|
||||
|
||||
// countIssuesInJSONLFile counts the number of valid issues in a JSONL file
|
||||
// This is a wrapper around CountJSONLIssues that returns only the count
|
||||
func countIssuesInJSONLFile(jsonlPath string) int {
|
||||
count, _, _ := CountJSONLIssues(jsonlPath)
|
||||
return count
|
||||
}
|
||||
|
||||
// detectPrefixFromJSONL detects the most common prefix in a JSONL file
|
||||
func detectPrefixFromJSONL(jsonlPath string) string {
|
||||
_, prefixes, _ := CountJSONLIssues(jsonlPath)
|
||||
if len(prefixes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the most common prefix
|
||||
var mostCommonPrefix string
|
||||
maxCount := 0
|
||||
for prefix, count := range prefixes {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
mostCommonPrefix = prefix
|
||||
}
|
||||
}
|
||||
return mostCommonPrefix
|
||||
}
|
||||
|
||||
// isNoDbModeConfigured checks if no-db: true is set in config.yaml
|
||||
// Uses proper YAML parsing to avoid false matches in comments or nested keys
|
||||
func isNoDbModeConfigured(beadsDir string) bool {
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from beadsDir
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var cfg localConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return cfg.NoDb
|
||||
}
|
||||
503
cmd/bd/doctor/git.go
Normal file
503
cmd/bd/doctor/git.go
Normal file
@@ -0,0 +1,503 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
)
|
||||
|
||||
// CheckGitHooks verifies that recommended git hooks are installed.
|
||||
func CheckGitHooks() DoctorCheck {
|
||||
// Check if we're in a git repository using worktree-aware detection
|
||||
gitDir, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Git Hooks",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended hooks and their purposes
|
||||
recommendedHooks := map[string]string{
|
||||
"pre-commit": "Flushes pending bd changes to JSONL before commit",
|
||||
"post-merge": "Imports updated JSONL after git pull/merge",
|
||||
"pre-push": "Exports database to JSONL before push",
|
||||
}
|
||||
|
||||
hooksDir := filepath.Join(gitDir, "hooks")
|
||||
var missingHooks []string
|
||||
var installedHooks []string
|
||||
|
||||
for hookName := range recommendedHooks {
|
||||
hookPath := filepath.Join(hooksDir, hookName)
|
||||
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
|
||||
missingHooks = append(missingHooks, hookName)
|
||||
} else {
|
||||
installedHooks = append(installedHooks, hookName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingHooks) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Git Hooks",
|
||||
Status: StatusOK,
|
||||
Message: "All recommended hooks installed",
|
||||
Detail: fmt.Sprintf("Installed: %s", strings.Join(installedHooks, ", ")),
|
||||
}
|
||||
}
|
||||
|
||||
hookInstallMsg := "Install hooks with 'bd hooks install'. See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions"
|
||||
|
||||
if len(installedHooks) > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Git Hooks",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Missing %d recommended hook(s)", len(missingHooks)),
|
||||
Detail: fmt.Sprintf("Missing: %s", strings.Join(missingHooks, ", ")),
|
||||
Fix: hookInstallMsg,
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Git Hooks",
|
||||
Status: StatusWarning,
|
||||
Message: "No recommended git hooks installed",
|
||||
Detail: fmt.Sprintf("Recommended: %s", strings.Join([]string{"pre-commit", "post-merge", "pre-push"}, ", ")),
|
||||
Fix: hookInstallMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSyncBranchHookCompatibility checks if pre-push hook is compatible with sync-branch mode.
|
||||
// When sync-branch is configured, the pre-push hook must have the sync-branch bypass logic
|
||||
// (added in version 0.29.0). Without it, users experience circular "bd sync" failures (issue #532).
|
||||
func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
|
||||
// Check if sync-branch is configured
|
||||
syncBranch := syncbranch.GetFromYAML()
|
||||
if syncBranch == "" {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (sync-branch not configured)",
|
||||
}
|
||||
}
|
||||
|
||||
// sync-branch is configured - check pre-push hook version
|
||||
// Get actual git directory (handles worktrees where .git is a file)
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
gitDir := strings.TrimSpace(string(output))
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(path, gitDir)
|
||||
}
|
||||
|
||||
// Check for pre-push hook in standard location or shared hooks location
|
||||
var hookPath string
|
||||
|
||||
// First check if core.hooksPath is configured (shared hooks)
|
||||
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
|
||||
hooksPathCmd.Dir = path
|
||||
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
|
||||
sharedHooksDir := strings.TrimSpace(string(hooksPathOutput))
|
||||
if !filepath.IsAbs(sharedHooksDir) {
|
||||
sharedHooksDir = filepath.Join(path, sharedHooksDir)
|
||||
}
|
||||
hookPath = filepath.Join(sharedHooksDir, "pre-push")
|
||||
} else {
|
||||
// Use standard .git/hooks location
|
||||
hookPath = filepath.Join(gitDir, "hooks", "pre-push")
|
||||
}
|
||||
|
||||
hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
|
||||
if err != nil {
|
||||
// No pre-push hook installed - different issue, covered by checkGitHooks
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no pre-push hook installed)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a bd hook and extract version
|
||||
hookStr := string(hookContent)
|
||||
if !strings.Contains(hookStr, "bd-hooks-version:") {
|
||||
// Not a bd hook - can't determine compatibility
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusWarning,
|
||||
Message: "Pre-push hook is not a bd hook",
|
||||
Detail: "Cannot verify sync-branch compatibility with custom hooks",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract version from hook
|
||||
var hookVersion string
|
||||
for _, line := range strings.Split(hookStr, "\n") {
|
||||
if strings.Contains(line, "bd-hooks-version:") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
hookVersion = strings.TrimSpace(parts[1])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hookVersion == "" {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusWarning,
|
||||
Message: "Could not determine pre-push hook version",
|
||||
Detail: "Cannot verify sync-branch compatibility",
|
||||
Fix: "Run 'bd hooks install --force' to update hooks",
|
||||
}
|
||||
}
|
||||
|
||||
// MinSyncBranchHookVersion added sync-branch bypass logic
|
||||
// If hook version < MinSyncBranchHookVersion, it will cause circular "bd sync" failures
|
||||
if CompareVersions(hookVersion, MinSyncBranchHookVersion) < 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Pre-push hook incompatible with sync-branch mode (version %s)", hookVersion),
|
||||
Detail: fmt.Sprintf("Hook version %s lacks sync-branch bypass (requires %s+). This causes circular 'bd sync' failures during push.", hookVersion, MinSyncBranchHookVersion),
|
||||
Fix: "Run 'bd hooks install --force' to update hooks",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Pre-push hook compatible with sync-branch (version %s)", hookVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckMergeDriver verifies that the git merge driver is correctly configured.
|
||||
func CheckMergeDriver(path string) DoctorCheck {
|
||||
// Check if we're in a git repository using worktree-aware detection
|
||||
_, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Git Merge Driver",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
|
||||
// Get current merge driver configuration
|
||||
cmd := exec.Command("git", "config", "merge.beads.driver")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Merge driver not configured
|
||||
return DoctorCheck{
|
||||
Name: "Git Merge Driver",
|
||||
Status: StatusWarning,
|
||||
Message: "Git merge driver not configured",
|
||||
Fix: "Run 'bd init' to configure the merge driver, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"",
|
||||
}
|
||||
}
|
||||
|
||||
currentConfig := strings.TrimSpace(string(output))
|
||||
correctConfig := "bd merge %A %O %A %B"
|
||||
|
||||
// Check if using old incorrect placeholders
|
||||
if strings.Contains(currentConfig, "%L") || strings.Contains(currentConfig, "%R") {
|
||||
return DoctorCheck{
|
||||
Name: "Git Merge Driver",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Incorrect merge driver config: %q (uses invalid %%L/%%R placeholders)", currentConfig),
|
||||
Detail: "Git only supports %O (base), %A (current), %B (other). Using %L/%R causes merge failures.",
|
||||
Fix: "Run 'bd doctor --fix' to update to correct config, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if config is correct
|
||||
if currentConfig != correctConfig {
|
||||
return DoctorCheck{
|
||||
Name: "Git Merge Driver",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Non-standard merge driver config: %q", currentConfig),
|
||||
Detail: fmt.Sprintf("Expected: %q", correctConfig),
|
||||
Fix: fmt.Sprintf("Run 'bd doctor --fix' to update config, or manually: git config merge.beads.driver \"%s\"", correctConfig),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Git Merge Driver",
|
||||
Status: StatusOK,
|
||||
Message: "Correctly configured",
|
||||
Detail: currentConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSyncBranchConfig checks if sync-branch is properly configured.
|
||||
func CheckSyncBranchConfig(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Skip if .beads doesn't exist
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no .beads directory)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in a git repository using worktree-aware detection
|
||||
_, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check sync-branch from config.yaml or environment variable
|
||||
// This is the source of truth for multi-clone setups
|
||||
syncBranch := syncbranch.GetFromYAML()
|
||||
|
||||
// Get current branch
|
||||
currentBranch := ""
|
||||
cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
|
||||
cmd.Dir = path
|
||||
if output, err := cmd.Output(); err == nil {
|
||||
currentBranch = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// CRITICAL: Check if we're on the sync branch - this is a misconfiguration
|
||||
// that will cause bd sync to fail trying to create a worktree for a branch
|
||||
// that's already checked out
|
||||
if syncBranch != "" && currentBranch == syncBranch {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("On sync branch '%s'", syncBranch),
|
||||
Detail: fmt.Sprintf("Currently on branch '%s' which is configured as the sync branch. bd sync cannot create a worktree for a branch that's already checked out.", syncBranch),
|
||||
Fix: "Switch to your main working branch: git checkout main",
|
||||
}
|
||||
}
|
||||
|
||||
if syncBranch != "" {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Configured (%s)", syncBranch),
|
||||
Detail: fmt.Sprintf("Current branch: %s, sync branch: %s", currentBranch, syncBranch),
|
||||
}
|
||||
}
|
||||
|
||||
// Not configured - this is optional but recommended for multi-clone setups
|
||||
// Check if this looks like a multi-clone setup (has remote)
|
||||
hasRemote := false
|
||||
cmd = exec.Command("git", "remote")
|
||||
cmd.Dir = path
|
||||
if output, err := cmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
|
||||
hasRemote = true
|
||||
}
|
||||
|
||||
if hasRemote {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: StatusWarning,
|
||||
Message: "sync-branch not configured",
|
||||
Detail: "Multi-clone setups should configure sync-branch in config.yaml",
|
||||
Fix: "Add 'sync-branch: beads-sync' to .beads/config.yaml",
|
||||
}
|
||||
}
|
||||
|
||||
// No remote - probably a local-only repo, sync-branch not needed
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no remote configured)",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSyncBranchHealth detects when the sync branch has diverged from main
|
||||
// or from the remote sync branch (after a force-push reset).
|
||||
// bd-6rf: Detect and fix stale beads-sync branch
|
||||
func CheckSyncBranchHealth(path string) DoctorCheck {
|
||||
// Skip if not in a git repo using worktree-aware detection
|
||||
_, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
|
||||
// Get configured sync branch
|
||||
syncBranch := syncbranch.GetFromYAML()
|
||||
if syncBranch == "" {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no sync branch configured)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if local sync branch exists
|
||||
cmd := exec.Command("git", "rev-parse", "--verify", syncBranch) // #nosec G204 - syncBranch from config file
|
||||
cmd.Dir = path
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Local branch doesn't exist - that's fine, bd sync will create it
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("N/A (local %s branch not created yet)", syncBranch),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if remote sync branch exists
|
||||
remote := "origin"
|
||||
remoteBranch := fmt.Sprintf("%s/%s", remote, syncBranch)
|
||||
cmd = exec.Command("git", "rev-parse", "--verify", remoteBranch) // #nosec G204 - remoteBranch from config
|
||||
cmd.Dir = path
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Remote branch doesn't exist - that's fine
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("N/A (remote %s not found)", remoteBranch),
|
||||
}
|
||||
}
|
||||
|
||||
// Check 1: Is local sync branch diverged from remote? (after force-push)
|
||||
// If they have no common ancestor in recent history, the remote was likely force-pushed
|
||||
cmd = exec.Command("git", "merge-base", syncBranch, remoteBranch) // #nosec G204 - branches from config
|
||||
cmd.Dir = path
|
||||
mergeBaseOutput, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No common ancestor - branches have completely diverged
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Local %s diverged from remote", syncBranch),
|
||||
Detail: "The remote sync branch was likely reset/force-pushed. Your local branch has orphaned history.",
|
||||
Fix: fmt.Sprintf("Reset local branch: git branch -D %s (it will be recreated on next bd sync)", syncBranch),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if local is behind remote (needs to fast-forward)
|
||||
mergeBase := strings.TrimSpace(string(mergeBaseOutput))
|
||||
cmd = exec.Command("git", "rev-parse", syncBranch) // #nosec G204 - syncBranch from config
|
||||
cmd.Dir = path
|
||||
localHead, _ := cmd.Output()
|
||||
localHeadStr := strings.TrimSpace(string(localHead))
|
||||
|
||||
cmd = exec.Command("git", "rev-parse", remoteBranch) // #nosec G204 - remoteBranch from config
|
||||
cmd.Dir = path
|
||||
remoteHead, _ := cmd.Output()
|
||||
remoteHeadStr := strings.TrimSpace(string(remoteHead))
|
||||
|
||||
// If merge base equals local but not remote, local is behind
|
||||
if mergeBase == localHeadStr && mergeBase != remoteHeadStr {
|
||||
// Count how far behind
|
||||
cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, remoteBranch)) // #nosec G204 - branches from config
|
||||
cmd.Dir = path
|
||||
countOutput, _ := cmd.Output()
|
||||
behindCount := strings.TrimSpace(string(countOutput))
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Local %s is %s commits behind remote (will sync)", syncBranch, behindCount),
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: Is sync branch far behind main on source files?
|
||||
// Get the main branch name
|
||||
mainBranch := "main"
|
||||
cmd = exec.Command("git", "rev-parse", "--verify", "main")
|
||||
cmd.Dir = path
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Try "master" as fallback
|
||||
cmd = exec.Command("git", "rev-parse", "--verify", "master")
|
||||
cmd.Dir = path
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Can't determine main branch
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: "OK",
|
||||
}
|
||||
}
|
||||
mainBranch = "master"
|
||||
}
|
||||
|
||||
// Count commits main is ahead of sync branch
|
||||
cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, mainBranch)) // #nosec G204 - branches from config/hardcoded
|
||||
cmd.Dir = path
|
||||
aheadOutput, err := cmd.Output()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: "OK",
|
||||
}
|
||||
}
|
||||
aheadCount := strings.TrimSpace(string(aheadOutput))
|
||||
|
||||
// Check if there are non-.beads/ file differences (stale source code)
|
||||
cmd = exec.Command("git", "diff", "--name-only", fmt.Sprintf("%s..%s", syncBranch, mainBranch), "--", ":(exclude).beads/") // #nosec G204 - branches from config/hardcoded
|
||||
cmd.Dir = path
|
||||
diffOutput, _ := cmd.Output()
|
||||
diffFiles := strings.TrimSpace(string(diffOutput))
|
||||
|
||||
if diffFiles != "" && aheadCount != "0" {
|
||||
// Count the number of different files
|
||||
fileCount := len(strings.Split(diffFiles, "\n"))
|
||||
// Parse ahead count as int for comparison
|
||||
aheadCountInt := 0
|
||||
_, _ = fmt.Sscanf(aheadCount, "%d", &aheadCountInt)
|
||||
|
||||
// Only warn if significantly behind (20+ commits AND 50+ source files)
|
||||
// Small drift is normal between bd sync operations
|
||||
if fileCount > 50 && aheadCountInt > 20 {
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Sync branch %s commits behind %s on source files", aheadCount, mainBranch),
|
||||
Detail: fmt.Sprintf("%d source files differ between %s and %s. The sync branch has stale code.", fileCount, syncBranch, mainBranch),
|
||||
Fix: fmt.Sprintf("Reset sync branch: git branch -f %s %s && git push --force-with-lease origin %s", syncBranch, mainBranch, syncBranch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Sync Branch Health",
|
||||
Status: StatusOK,
|
||||
Message: "OK",
|
||||
}
|
||||
}
|
||||
|
||||
// FixGitHooks fixes missing or broken git hooks by calling bd hooks install.
|
||||
func FixGitHooks(path string) error {
|
||||
return fix.GitHooks(path)
|
||||
}
|
||||
|
||||
// FixMergeDriver fixes the git merge driver configuration to use correct placeholders.
|
||||
func FixMergeDriver(path string) error {
|
||||
return fix.MergeDriver(path)
|
||||
}
|
||||
|
||||
// FixSyncBranchHealth fixes database-JSONL sync issues.
|
||||
func FixSyncBranchHealth(path string) error {
|
||||
return fix.DBJSONLSync(path)
|
||||
}
|
||||
221
cmd/bd/doctor/installation.go
Normal file
221
cmd/bd/doctor/installation.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
// CheckInstallation verifies that .beads directory exists
|
||||
func CheckInstallation(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
// Auto-detect prefix from directory name
|
||||
prefix := filepath.Base(path)
|
||||
prefix = strings.TrimRight(prefix, "-")
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Installation",
|
||||
Status: StatusError,
|
||||
Message: "No .beads/ directory found",
|
||||
Fix: fmt.Sprintf("Run 'bd init --prefix %s' to initialize beads", prefix),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Installation",
|
||||
Status: StatusOK,
|
||||
Message: ".beads/ directory found",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckMultipleDatabases checks for multiple database files in .beads directory
|
||||
func CheckMultipleDatabases(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Find all .db files (excluding backups and vc.db)
|
||||
files, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: StatusError,
|
||||
Message: "Unable to check for multiple databases",
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out backups and vc.db
|
||||
var dbFiles []string
|
||||
for _, f := range files {
|
||||
base := filepath.Base(f)
|
||||
if !strings.HasSuffix(base, ".backup.db") && base != "vc.db" {
|
||||
dbFiles = append(dbFiles, base)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dbFiles) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: StatusOK,
|
||||
Message: "No database files (JSONL-only mode)",
|
||||
}
|
||||
}
|
||||
|
||||
if len(dbFiles) == 1 {
|
||||
return DoctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: StatusOK,
|
||||
Message: "Single database file",
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple databases found
|
||||
return DoctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Multiple database files found: %s", strings.Join(dbFiles, ", ")),
|
||||
Fix: "Run 'bd migrate' to consolidate databases or manually remove old .db files",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPermissions verifies that .beads directory and database are readable/writable
|
||||
func CheckPermissions(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Check if .beads/ is writable
|
||||
testFile := filepath.Join(beadsDir, ".doctor-test-write")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: StatusError,
|
||||
Message: ".beads/ directory is not writable",
|
||||
Fix: fmt.Sprintf("Fix permissions: chmod u+w %s", beadsDir),
|
||||
}
|
||||
}
|
||||
_ = os.Remove(testFile) // Clean up test file (intentionally ignore error)
|
||||
|
||||
// Check database permissions
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
// Try to open database
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: StatusError,
|
||||
Message: "Database file exists but cannot be opened",
|
||||
Fix: fmt.Sprintf("Check database permissions: %s", dbPath),
|
||||
}
|
||||
}
|
||||
_ = db.Close() // Intentionally ignore close error
|
||||
|
||||
// Try a write test
|
||||
db, err = sql.Open("sqlite", dbPath)
|
||||
if err == nil {
|
||||
_, err = db.Exec("SELECT 1")
|
||||
_ = db.Close() // Intentionally ignore close error
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: StatusError,
|
||||
Message: "Database file is not readable",
|
||||
Fix: fmt.Sprintf("Fix permissions: chmod u+rw %s", dbPath),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: StatusOK,
|
||||
Message: "All permissions OK",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckUntrackedBeadsFiles checks for untracked .beads/*.jsonl files that should be committed
|
||||
func CheckUntrackedBeadsFiles(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Skip if .beads doesn't exist
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Untracked Files",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no .beads directory)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in a git repository using worktree-aware detection
|
||||
_, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Untracked Files",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
|
||||
// Run git status --porcelain to find untracked files in .beads/
|
||||
cmd := exec.Command("git", "status", "--porcelain", ".beads/")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Untracked Files",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to check git status",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse output for untracked JSONL files (lines starting with "??")
|
||||
var untrackedJSONL []string
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Untracked files start with "?? "
|
||||
if strings.HasPrefix(line, "?? ") {
|
||||
file := strings.TrimPrefix(line, "?? ")
|
||||
// Only care about .jsonl files
|
||||
if strings.HasSuffix(file, ".jsonl") {
|
||||
untrackedJSONL = append(untrackedJSONL, filepath.Base(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(untrackedJSONL) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Untracked Files",
|
||||
Status: StatusOK,
|
||||
Message: "All .beads/*.jsonl files are tracked",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Untracked Files",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Untracked JSONL files: %s", strings.Join(untrackedJSONL, ", ")),
|
||||
Detail: "These files should be committed to propagate changes to other clones",
|
||||
Fix: "Run 'bd doctor --fix' to stage and commit untracked files, or manually: git add .beads/*.jsonl && git commit",
|
||||
}
|
||||
}
|
||||
|
||||
// FixPermissions fixes file permission issues in the .beads directory
|
||||
func FixPermissions(path string) error {
|
||||
return fix.Permissions(path)
|
||||
}
|
||||
|
||||
// FixUntrackedJSONL stages and commits untracked .beads/*.jsonl files
|
||||
func FixUntrackedJSONL(path string) error {
|
||||
return fix.UntrackedJSONL(path)
|
||||
}
|
||||
543
cmd/bd/doctor/integrity.go
Normal file
543
cmd/bd/doctor/integrity.go
Normal file
@@ -0,0 +1,543 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
// CheckIDFormat checks whether issues use hash-based or sequential IDs
|
||||
func CheckIDFormat(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Check metadata.json first for custom database name
|
||||
var dbPath string
|
||||
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||
dbPath = cfg.DatabasePath(beadsDir)
|
||||
} else {
|
||||
// Fall back to canonical database name
|
||||
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
}
|
||||
|
||||
// Check if using JSONL-only mode
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Check if JSONL exists (--no-db mode)
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if _, err := os.Stat(jsonlPath); err == nil {
|
||||
return DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (JSONL-only mode)",
|
||||
}
|
||||
}
|
||||
// No database and no JSONL
|
||||
return DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusOK,
|
||||
Message: "No issues yet (will use hash-based IDs)",
|
||||
}
|
||||
}
|
||||
|
||||
// Open database
|
||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusError,
|
||||
Message: "Unable to open database",
|
||||
}
|
||||
}
|
||||
defer func() { _ = db.Close() }() // Intentionally ignore close error
|
||||
|
||||
// Get sample of issues to check ID format (up to 10 for pattern analysis)
|
||||
rows, err := db.Query("SELECT id FROM issues ORDER BY created_at LIMIT 10")
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusError,
|
||||
Message: "Unable to query issues",
|
||||
}
|
||||
}
|
||||
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 DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusOK,
|
||||
Message: "No issues yet (will use hash-based IDs)",
|
||||
}
|
||||
}
|
||||
|
||||
// Detect ID format using robust heuristic
|
||||
if DetectHashBasedIDs(db, issueIDs) {
|
||||
return DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusOK,
|
||||
Message: "hash-based ✓",
|
||||
}
|
||||
}
|
||||
|
||||
// Sequential IDs - recommend migration
|
||||
return DoctorCheck{
|
||||
Name: "Issue IDs",
|
||||
Status: StatusWarning,
|
||||
Message: "sequential (e.g., bd-1, bd-2, ...)",
|
||||
Fix: "Run 'bd migrate --to-hash-ids' to upgrade (prevents ID collisions in multi-worker scenarios)",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDependencyCycles checks for circular dependencies in the issue graph
|
||||
func CheckDependencyCycles(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
// If no database, skip this check
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Dependency Cycles",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// Open database to check for cycles
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Dependency Cycles",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to open database",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query for cycles using simplified SQL
|
||||
query := `
|
||||
WITH RECURSIVE paths AS (
|
||||
SELECT
|
||||
issue_id,
|
||||
depends_on_id,
|
||||
issue_id as start_id,
|
||||
issue_id || '→' || depends_on_id as path,
|
||||
0 as depth
|
||||
FROM dependencies
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
d.issue_id,
|
||||
d.depends_on_id,
|
||||
p.start_id,
|
||||
p.path || '→' || d.depends_on_id,
|
||||
p.depth + 1
|
||||
FROM dependencies d
|
||||
JOIN paths p ON d.issue_id = p.depends_on_id
|
||||
WHERE p.depth < 100
|
||||
AND p.path NOT LIKE '%' || d.depends_on_id || '→%'
|
||||
)
|
||||
SELECT DISTINCT start_id
|
||||
FROM paths
|
||||
WHERE depends_on_id = start_id`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Dependency Cycles",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to check for cycles",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cycleCount := 0
|
||||
var firstCycle string
|
||||
for rows.Next() {
|
||||
var startID string
|
||||
if err := rows.Scan(&startID); err != nil {
|
||||
continue
|
||||
}
|
||||
cycleCount++
|
||||
if cycleCount == 1 {
|
||||
firstCycle = startID
|
||||
}
|
||||
}
|
||||
|
||||
if cycleCount == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Dependency Cycles",
|
||||
Status: StatusOK,
|
||||
Message: "No circular dependencies detected",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Dependency Cycles",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Found %d circular dependency cycle(s)", cycleCount),
|
||||
Detail: fmt.Sprintf("First cycle involves: %s", firstCycle),
|
||||
Fix: "Run 'bd dep cycles' to see full cycle paths, then 'bd dep remove' to break cycles",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckTombstones checks the health of tombstone records
|
||||
// Reports: total tombstones, expiring soon (within 7 days), already expired
|
||||
func CheckTombstones(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
// Skip if database doesn't exist
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusWarning,
|
||||
Message: "Unable to open database",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query tombstone statistics
|
||||
var totalTombstones int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'tombstone'").Scan(&totalTombstones)
|
||||
if err != nil {
|
||||
// Might be old schema without tombstone support
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (schema may not support tombstones)",
|
||||
}
|
||||
}
|
||||
|
||||
if totalTombstones == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusOK,
|
||||
Message: "None (no deleted issues)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tombstones expiring within 7 days
|
||||
// Default TTL is 30 days, so expiring soon means deleted_at older than 23 days ago
|
||||
expiringThreshold := time.Now().Add(-23 * 24 * time.Hour).Format(time.RFC3339)
|
||||
expiredThreshold := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
var expiringSoon, alreadyExpired int
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) FROM issues
|
||||
WHERE status = 'tombstone'
|
||||
AND deleted_at IS NOT NULL
|
||||
AND deleted_at < ?
|
||||
AND deleted_at >= ?
|
||||
`, expiringThreshold, expiredThreshold).Scan(&expiringSoon)
|
||||
if err != nil {
|
||||
expiringSoon = 0
|
||||
}
|
||||
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) FROM issues
|
||||
WHERE status = 'tombstone'
|
||||
AND deleted_at IS NOT NULL
|
||||
AND deleted_at < ?
|
||||
`, expiredThreshold).Scan(&alreadyExpired)
|
||||
if err != nil {
|
||||
alreadyExpired = 0
|
||||
}
|
||||
|
||||
// Build status message
|
||||
if alreadyExpired > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("%d total, %d expired", totalTombstones, alreadyExpired),
|
||||
Detail: "Expired tombstones will be removed on next compact",
|
||||
Fix: "Run 'bd compact' to prune expired tombstones",
|
||||
}
|
||||
}
|
||||
|
||||
if expiringSoon > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("%d total, %d expiring within 7 days", totalTombstones, expiringSoon),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Tombstones",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("%d total", totalTombstones),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDeletionsManifest checks the status of deletions.jsonl and suggests migration to tombstones
|
||||
func CheckDeletionsManifest(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Skip if .beads doesn't exist
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no .beads directory)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in a git repository using worktree-aware detection
|
||||
_, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (not a git repository)",
|
||||
}
|
||||
}
|
||||
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
|
||||
// Check if deletions.jsonl exists
|
||||
info, err := os.Stat(deletionsPath)
|
||||
if err == nil {
|
||||
// File exists - count entries (empty file is valid, means no deletions)
|
||||
if info.Size() == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "Empty (no legacy deletions)",
|
||||
}
|
||||
}
|
||||
file, err := os.Open(deletionsPath) // #nosec G304 - controlled path
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
count := 0
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if len(scanner.Bytes()) > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
// Suggest migration to inline tombstones
|
||||
if count > 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Legacy format (%d entries)", count),
|
||||
Detail: "deletions.jsonl is deprecated in favor of inline tombstones",
|
||||
Fix: "Run 'bd migrate-tombstones' to convert to inline tombstones",
|
||||
}
|
||||
}
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "Empty (no legacy deletions)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deletions.jsonl doesn't exist - this is the expected state with tombstones
|
||||
// Check for .migrated file to confirm migration happened
|
||||
migratedPath := filepath.Join(beadsDir, "deletions.jsonl.migrated")
|
||||
if _, err := os.Stat(migratedPath); err == nil {
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "Migrated to tombstones",
|
||||
}
|
||||
}
|
||||
|
||||
// No deletions.jsonl and no .migrated file - check if JSONL exists
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
jsonlPath = filepath.Join(beadsDir, "beads.jsonl")
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "N/A (no JSONL file)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSONL exists but no deletions tracking - this is fine for new repos using tombstones
|
||||
return DoctorCheck{
|
||||
Name: "Deletions Manifest",
|
||||
Status: StatusOK,
|
||||
Message: "Using inline tombstones",
|
||||
}
|
||||
}
|
||||
|
||||
// Fix functions
|
||||
|
||||
// FixMigrateTombstones converts legacy deletions.jsonl entries to inline tombstones
|
||||
func FixMigrateTombstones(path string) error {
|
||||
return fix.MigrateTombstones(path)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// DetectHashBasedIDs uses multiple heuristics to determine if the database uses hash-based IDs.
|
||||
// This is more robust than checking a single ID's format, since base36 hash IDs can be all-numeric.
|
||||
func DetectHashBasedIDs(db *sql.DB, sampleIDs []string) bool {
|
||||
// Heuristic 1: Check for child_counters table (added for hash ID support)
|
||||
var tableName string
|
||||
err := db.QueryRow(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='child_counters'
|
||||
`).Scan(&tableName)
|
||||
if err == nil {
|
||||
// child_counters table exists - this is a strong indicator of hash IDs
|
||||
return true
|
||||
}
|
||||
|
||||
// Heuristic 2: Check if any sample ID clearly contains letters (a-z)
|
||||
// Hash IDs use base36 (0-9, a-z), sequential IDs are purely numeric
|
||||
for _, id := range sampleIDs {
|
||||
if isHashID(id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic 3: Look for patterns that indicate hash IDs
|
||||
if len(sampleIDs) >= 2 {
|
||||
// Extract suffixes (part after prefix-) for analysis
|
||||
var suffixes []string
|
||||
for _, id := range sampleIDs {
|
||||
parts := strings.SplitN(id, "-", 2)
|
||||
if len(parts) == 2 {
|
||||
// Strip hierarchical suffix like .1 or .1.2
|
||||
baseSuffix := strings.Split(parts[1], ".")[0]
|
||||
suffixes = append(suffixes, baseSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
if len(suffixes) >= 2 {
|
||||
// Check for variable lengths (strong indicator of adaptive hash IDs)
|
||||
// BUT: sequential IDs can also have variable length (1, 10, 100)
|
||||
// So we need to check if the length variation is natural (1→2→3 digits)
|
||||
// or random (3→8→4 chars typical of adaptive hash IDs)
|
||||
lengths := make(map[int]int) // length -> count
|
||||
for _, s := range suffixes {
|
||||
lengths[len(s)]++
|
||||
}
|
||||
|
||||
// If we have 3+ different lengths, likely hash IDs (adaptive length)
|
||||
// Sequential IDs typically have 1-2 lengths (e.g., 1-9, 10-99, 100-999)
|
||||
if len(lengths) >= 3 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for leading zeros (rare in sequential IDs, common in hash IDs)
|
||||
// Sequential IDs: bd-1, bd-2, bd-10, bd-100
|
||||
// Hash IDs: bd-0088, bd-02a4, bd-05a1
|
||||
hasLeadingZero := false
|
||||
for _, s := range suffixes {
|
||||
if len(s) > 1 && s[0] == '0' {
|
||||
hasLeadingZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasLeadingZero {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for non-sequential ordering
|
||||
// Try to parse as integers - if they're not sequential, likely hash IDs
|
||||
allNumeric := true
|
||||
var nums []int
|
||||
for _, s := range suffixes {
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(s, "%d", &num); err == nil {
|
||||
nums = append(nums, num)
|
||||
} else {
|
||||
allNumeric = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allNumeric && len(nums) >= 2 {
|
||||
// Check if they form a roughly sequential pattern (1,2,3 or 10,11,12)
|
||||
// Hash IDs would be more random (e.g., 88, 13452, 676)
|
||||
isSequentialPattern := true
|
||||
for i := 1; i < len(nums); i++ {
|
||||
diff := nums[i] - nums[i-1]
|
||||
// Allow for some gaps (deleted issues), but should be mostly sequential
|
||||
if diff < 0 || diff > 100 {
|
||||
isSequentialPattern = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// If the numbers are NOT sequential, they're likely hash IDs
|
||||
if !isSequentialPattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't determine for sure, default to assuming sequential IDs
|
||||
// This is conservative - better to recommend migration than miss sequential IDs
|
||||
return false
|
||||
}
|
||||
|
||||
// isHashID checks if a single ID contains hash characteristics
|
||||
// Hash IDs contain hex letters (a-f), sequential IDs are only digits
|
||||
// May have hierarchical suffix like .1 or .1.2
|
||||
func isHashID(id string) bool {
|
||||
lastSeperatorIndex := strings.LastIndex(id, "-")
|
||||
if lastSeperatorIndex == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
suffix := id[lastSeperatorIndex+1:]
|
||||
// Strip hierarchical suffix like .1 or .1.2
|
||||
baseSuffix := strings.Split(suffix, ".")[0]
|
||||
|
||||
if len(baseSuffix) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must be valid Base36 (0-9, a-z)
|
||||
if !regexp.MustCompile(`^[0-9a-z]+$`).MatchString(baseSuffix) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's 5+ characters long, it's almost certainly a hash ID
|
||||
// (sequential IDs rarely exceed 9999 = 4 digits)
|
||||
if len(baseSuffix) >= 5 {
|
||||
return true
|
||||
}
|
||||
|
||||
// For shorter IDs, check if it contains any letter (a-z)
|
||||
// Sequential IDs are purely numeric
|
||||
return regexp.MustCompile(`[a-z]`).MatchString(baseSuffix)
|
||||
}
|
||||
155
cmd/bd/doctor/quick.go
Normal file
155
cmd/bd/doctor/quick.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
)
|
||||
|
||||
// CheckSyncBranchQuick does a fast check for sync-branch configuration.
|
||||
// Returns empty string if OK, otherwise returns issue description.
|
||||
func CheckSyncBranchQuick() string {
|
||||
if syncbranch.IsConfigured() {
|
||||
return ""
|
||||
}
|
||||
return "sync-branch not configured in config.yaml"
|
||||
}
|
||||
|
||||
// CheckHooksQuick does a fast check for outdated git hooks.
|
||||
// Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout.
|
||||
// cliVersion is the current CLI version to compare against.
|
||||
func CheckHooksQuick(cliVersion string) string {
|
||||
// Get actual git directory (handles worktrees where .git is a file)
|
||||
gitDir, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return "" // Not a git repo, skip
|
||||
}
|
||||
hooksDir := filepath.Join(gitDir, "hooks")
|
||||
|
||||
// Check if hooks dir exists
|
||||
if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
|
||||
return "" // No git hooks directory, skip
|
||||
}
|
||||
|
||||
// Check all beads-managed hooks
|
||||
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
|
||||
|
||||
var outdatedHooks []string
|
||||
var oldestVersion string
|
||||
|
||||
for _, hookName := range hookNames {
|
||||
hookPath := filepath.Join(hooksDir, hookName)
|
||||
content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
|
||||
if err != nil {
|
||||
continue // Hook doesn't exist, skip (will be caught by full doctor)
|
||||
}
|
||||
|
||||
// Look for version marker
|
||||
hookContent := string(content)
|
||||
if !strings.Contains(hookContent, "bd-hooks-version:") {
|
||||
continue // Not a bd hook or old format, skip
|
||||
}
|
||||
|
||||
// Extract version
|
||||
for _, line := range strings.Split(hookContent, "\n") {
|
||||
if strings.Contains(line, "bd-hooks-version:") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
hookVersion := strings.TrimSpace(parts[1])
|
||||
if hookVersion != cliVersion {
|
||||
outdatedHooks = append(outdatedHooks, hookName)
|
||||
// Track the oldest version for display
|
||||
if oldestVersion == "" || CompareVersions(hookVersion, oldestVersion) < 0 {
|
||||
oldestVersion = hookVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(outdatedHooks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return summary of outdated hooks
|
||||
if len(outdatedHooks) == 1 {
|
||||
return fmt.Sprintf("Git hook %s outdated (%s → %s)", outdatedHooks[0], oldestVersion, cliVersion)
|
||||
}
|
||||
return fmt.Sprintf("Git hooks outdated: %s (%s → %s)", strings.Join(outdatedHooks, ", "), oldestVersion, cliVersion)
|
||||
}
|
||||
|
||||
// CheckSyncBranchHookQuick does a fast check for sync-branch hook compatibility.
|
||||
// Returns empty string if OK, otherwise returns issue description.
|
||||
func CheckSyncBranchHookQuick(path string) string {
|
||||
// Check if sync-branch is configured
|
||||
syncBranch := syncbranch.GetFromYAML()
|
||||
if syncBranch == "" {
|
||||
return "" // sync-branch not configured, nothing to check
|
||||
}
|
||||
|
||||
// Get git directory
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "" // Not a git repo, skip
|
||||
}
|
||||
gitDir := strings.TrimSpace(string(output))
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(path, gitDir)
|
||||
}
|
||||
|
||||
// Find pre-push hook (check shared hooks first)
|
||||
var hookPath string
|
||||
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
|
||||
hooksPathCmd.Dir = path
|
||||
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
|
||||
sharedHooksDir := strings.TrimSpace(string(hooksPathOutput))
|
||||
if !filepath.IsAbs(sharedHooksDir) {
|
||||
sharedHooksDir = filepath.Join(path, sharedHooksDir)
|
||||
}
|
||||
hookPath = filepath.Join(sharedHooksDir, "pre-push")
|
||||
} else {
|
||||
hookPath = filepath.Join(gitDir, "hooks", "pre-push")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
|
||||
if err != nil {
|
||||
return "" // No pre-push hook, covered by other checks
|
||||
}
|
||||
|
||||
// Check if bd hook and extract version
|
||||
hookStr := string(content)
|
||||
if !strings.Contains(hookStr, "bd-hooks-version:") {
|
||||
return "" // Not a bd hook, can't check
|
||||
}
|
||||
|
||||
var hookVersion string
|
||||
for _, line := range strings.Split(hookStr, "\n") {
|
||||
if strings.Contains(line, "bd-hooks-version:") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
hookVersion = strings.TrimSpace(parts[1])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hookVersion == "" {
|
||||
return "" // Can't determine version
|
||||
}
|
||||
|
||||
// Check if version < MinSyncBranchHookVersion (when sync-branch bypass was added)
|
||||
if CompareVersions(hookVersion, MinSyncBranchHookVersion) < 0 {
|
||||
return fmt.Sprintf("Pre-push hook (%s) incompatible with sync-branch mode (requires %s+)", hookVersion, MinSyncBranchHookVersion)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
20
cmd/bd/doctor/types.go
Normal file
20
cmd/bd/doctor/types.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package doctor
|
||||
|
||||
// Status constants for doctor checks
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusWarning = "warning"
|
||||
StatusError = "error"
|
||||
)
|
||||
|
||||
// MinSyncBranchHookVersion is the minimum hook version that supports sync-branch bypass (issue #532)
|
||||
const MinSyncBranchHookVersion = "0.29.0"
|
||||
|
||||
// DoctorCheck represents a single diagnostic check result
|
||||
type DoctorCheck struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // StatusOK, StatusWarning, or StatusError
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
268
cmd/bd/doctor/version.go
Normal file
268
cmd/bd/doctor/version.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
)
|
||||
|
||||
// CheckCLIVersion checks if the CLI version is up to date.
|
||||
// Takes cliVersion parameter since it can't access the Version variable from main package.
|
||||
func CheckCLIVersion(cliVersion string) DoctorCheck {
|
||||
latestVersion, err := fetchLatestGitHubRelease()
|
||||
if err != nil {
|
||||
// Network error or API issue - don't fail, just warn
|
||||
return DoctorCheck{
|
||||
Name: "CLI Version",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("%s (unable to check for updates)", cliVersion),
|
||||
}
|
||||
}
|
||||
|
||||
if latestVersion == "" || latestVersion == cliVersion {
|
||||
return DoctorCheck{
|
||||
Name: "CLI Version",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("%s (latest)", cliVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// Compare versions using simple semver-aware comparison
|
||||
if CompareVersions(latestVersion, cliVersion) > 0 {
|
||||
upgradeCmds := ` • Homebrew: brew upgrade bd
|
||||
• Script: curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash`
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "CLI Version",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("%s (latest: %s)", cliVersion, latestVersion),
|
||||
Fix: fmt.Sprintf("Upgrade to latest version:\n%s", upgradeCmds),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "CLI Version",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("%s (latest)", cliVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckMetadataVersionTracking checks if metadata.json has proper version tracking.
|
||||
func CheckMetadataVersionTracking(path string, currentVersion string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Load metadata.json
|
||||
cfg, err := configfile.Load(beadsDir)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusError,
|
||||
Message: "Unable to read metadata.json",
|
||||
Detail: err.Error(),
|
||||
Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if metadata.json exists
|
||||
if cfg == nil {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: "metadata.json not found",
|
||||
Fix: "Run any bd command to create metadata.json with version tracking",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LastBdVersion field is present
|
||||
if cfg.LastBdVersion == "" {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: "LastBdVersion field is empty (first run)",
|
||||
Detail: "Version tracking will be initialized on next command",
|
||||
Fix: "Run any bd command to initialize version tracking",
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that LastBdVersion is a valid semver-like string
|
||||
// Simple validation: should be X.Y.Z format where X, Y, Z are numbers
|
||||
if !IsValidSemver(cfg.LastBdVersion) {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion),
|
||||
Detail: "Expected semver format like '0.24.2'",
|
||||
Fix: "Run any bd command to reset version tracking to current version",
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LastBdVersion is very old (> 10 versions behind)
|
||||
// Calculate version distance
|
||||
versionDiff := CompareVersions(currentVersion, cfg.LastBdVersion)
|
||||
if versionDiff > 0 {
|
||||
// Current version is newer - check how far behind
|
||||
currentParts := ParseVersionParts(currentVersion)
|
||||
lastParts := ParseVersionParts(cfg.LastBdVersion)
|
||||
|
||||
// Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+
|
||||
majorDiff := currentParts[0] - lastParts[0]
|
||||
minorDiff := currentParts[1] - lastParts[1]
|
||||
|
||||
if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) {
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, currentVersion),
|
||||
Detail: "You may have missed important upgrade notifications",
|
||||
Fix: "Run 'bd upgrade review' to see recent changes",
|
||||
}
|
||||
}
|
||||
|
||||
// Version is behind but not too old
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, currentVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// Version is current or ahead (shouldn't happen, but handle it)
|
||||
return DoctorCheck{
|
||||
Name: "Metadata Version Tracking",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion),
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLatestGitHubRelease fetches the latest release version from GitHub API.
|
||||
func fetchLatestGitHubRelease() (string, error) {
|
||||
url := "https://api.github.com/repos/steveyegge/beads/releases/latest"
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set User-Agent as required by GitHub API
|
||||
req.Header.Set("User-Agent", "beads-cli-doctor")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("github api returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &release); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Strip 'v' prefix if present
|
||||
version := strings.TrimPrefix(release.TagName, "v")
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// CompareVersions compares two semantic version strings.
|
||||
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
// Handles versions like "0.20.1", "1.2.3", etc.
|
||||
func CompareVersions(v1, v2 string) int {
|
||||
// Split versions into parts
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
// Compare each part
|
||||
maxLen := len(parts1)
|
||||
if len(parts2) > maxLen {
|
||||
maxLen = len(parts2)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var p1, p2 int
|
||||
|
||||
// Get part value or default to 0 if part doesn't exist
|
||||
if i < len(parts1) {
|
||||
_, _ = fmt.Sscanf(parts1[i], "%d", &p1)
|
||||
}
|
||||
if i < len(parts2) {
|
||||
_, _ = fmt.Sscanf(parts2[i], "%d", &p2)
|
||||
}
|
||||
|
||||
if p1 < p2 {
|
||||
return -1
|
||||
}
|
||||
if p1 > p2 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsValidSemver checks if a version string is valid semver-like format (X.Y.Z)
|
||||
func IsValidSemver(version string) bool {
|
||||
if version == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Split by dots and ensure all parts are numeric
|
||||
versionParts := strings.Split(version, ".")
|
||||
if len(versionParts) < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse each part to ensure it's a valid number
|
||||
for _, part := range versionParts {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
|
||||
return false
|
||||
}
|
||||
if num < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ParseVersionParts parses version string into numeric parts
|
||||
// Returns [major, minor, patch, ...] or empty slice on error
|
||||
func ParseVersionParts(version string) []int {
|
||||
parts := strings.Split(version, ".")
|
||||
result := make([]int, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
|
||||
return result
|
||||
}
|
||||
result = append(result, num)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/cmd/bd/doctor"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
@@ -226,7 +227,7 @@ func TestDetectHashBasedIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test detection
|
||||
result := detectHashBasedIDs(db, tt.sampleIDs)
|
||||
result := doctor.DetectHashBasedIDs(db, tt.sampleIDs)
|
||||
if result != tt.expected {
|
||||
t.Errorf("detectHashBasedIDs() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
@@ -245,31 +246,31 @@ func TestCheckIDFormat(t *testing.T) {
|
||||
name: "hash IDs with letters",
|
||||
issueIDs: []string{"bd-a3f8e9", "bd-b2c4d6", "bd-xyz123"},
|
||||
createTable: false,
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "hash IDs all numeric with leading zeros",
|
||||
issueIDs: []string{"bd-0088", "bd-02a4", "bd-05a1", "bd-0458"},
|
||||
createTable: false,
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "hash IDs with child_counters table",
|
||||
issueIDs: []string{"bd-123", "bd-456"},
|
||||
createTable: true,
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "sequential IDs",
|
||||
issueIDs: []string{"bd-1", "bd-2", "bd-3", "bd-4"},
|
||||
createTable: false,
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
},
|
||||
{
|
||||
name: "mixed: mostly hash IDs",
|
||||
issueIDs: []string{"bd-0088", "bd-0134cc5a", "bd-02a4"},
|
||||
createTable: false,
|
||||
expectedStatus: statusOK, // Variable length = hash IDs
|
||||
expectedStatus: doctor.StatusOK, // Variable length = hash IDs
|
||||
},
|
||||
}
|
||||
|
||||
@@ -326,19 +327,19 @@ func TestCheckIDFormat(t *testing.T) {
|
||||
db.Close()
|
||||
|
||||
// Run check
|
||||
check := checkIDFormat(tmpDir)
|
||||
check := doctor.CheckIDFormat(tmpDir)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s (message: %s)", tt.expectedStatus, check.Status, check.Message)
|
||||
}
|
||||
|
||||
if tt.expectedStatus == statusOK && check.Status == statusOK {
|
||||
if tt.expectedStatus == doctor.StatusOK && check.Status == doctor.StatusOK {
|
||||
if !strings.Contains(check.Message, "hash-based") {
|
||||
t.Errorf("Expected hash-based message, got: %s", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectedStatus == statusWarning && check.Status == statusWarning {
|
||||
if tt.expectedStatus == doctor.StatusWarning && check.Status == doctor.StatusWarning {
|
||||
if check.Fix == "" {
|
||||
t.Error("Expected fix message for sequential IDs")
|
||||
}
|
||||
@@ -350,9 +351,9 @@ func TestCheckIDFormat(t *testing.T) {
|
||||
func TestCheckInstallation(t *testing.T) {
|
||||
// Test with missing .beads directory
|
||||
tmpDir := t.TempDir()
|
||||
check := checkInstallation(tmpDir)
|
||||
check := doctor.CheckInstallation(tmpDir)
|
||||
|
||||
if check.Status != statusError {
|
||||
if check.Status != doctor.StatusError {
|
||||
t.Errorf("Expected error status, got %s", check.Status)
|
||||
}
|
||||
if check.Fix == "" {
|
||||
@@ -365,8 +366,8 @@ func TestCheckInstallation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check = checkInstallation(tmpDir)
|
||||
if check.Status != statusOK {
|
||||
check = doctor.CheckInstallation(tmpDir)
|
||||
if check.Status != doctor.StatusOK {
|
||||
t.Errorf("Expected ok status, got %s", check.Status)
|
||||
}
|
||||
}
|
||||
@@ -392,9 +393,9 @@ func TestCheckDatabaseVersionJSONLMode(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := checkDatabaseVersion(tmpDir)
|
||||
check := doctor.CheckDatabaseVersion(tmpDir, Version)
|
||||
|
||||
if check.Status != statusOK {
|
||||
if check.Status != doctor.StatusOK {
|
||||
t.Errorf("Expected ok status for JSONL mode, got %s", check.Status)
|
||||
}
|
||||
if check.Message != "JSONL-only mode" {
|
||||
@@ -420,9 +421,9 @@ func TestCheckDatabaseVersionFreshClone(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := checkDatabaseVersion(tmpDir)
|
||||
check := doctor.CheckDatabaseVersion(tmpDir, Version)
|
||||
|
||||
if check.Status != statusWarning {
|
||||
if check.Status != doctor.StatusWarning {
|
||||
t.Errorf("Expected warning status for fresh clone, got %s", check.Status)
|
||||
}
|
||||
if check.Message != "Fresh clone detected (no database)" {
|
||||
@@ -450,9 +451,9 @@ func TestCompareVersions(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := compareVersions(tc.v1, tc.v2)
|
||||
result := doctor.CompareVersions(tc.v1, tc.v2)
|
||||
if result != tc.expected {
|
||||
t.Errorf("compareVersions(%q, %q) = %d, expected %d", tc.v1, tc.v2, result, tc.expected)
|
||||
t.Errorf("doctor.CompareVersions(%q, %q) = %d, expected %d", tc.v1, tc.v2, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,31 +468,31 @@ func TestCheckMultipleDatabases(t *testing.T) {
|
||||
{
|
||||
name: "no databases",
|
||||
dbFiles: []string{},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "single database",
|
||||
dbFiles: []string{"beads.db"},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "multiple databases",
|
||||
dbFiles: []string{"beads.db", "old.db"},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "backup files ignored",
|
||||
dbFiles: []string{"beads.db", "beads.backup.db"},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "vc.db ignored",
|
||||
dbFiles: []string{"beads.db", "vc.db"},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
}
|
||||
@@ -512,7 +513,7 @@ func TestCheckMultipleDatabases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
check := checkMultipleDatabases(tmpDir)
|
||||
check := doctor.CheckMultipleDatabases(tmpDir)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
@@ -532,9 +533,9 @@ func TestCheckPermissions(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := checkPermissions(tmpDir)
|
||||
check := doctor.CheckPermissions(tmpDir)
|
||||
|
||||
if check.Status != statusOK {
|
||||
if check.Status != doctor.StatusOK {
|
||||
t.Errorf("Expected ok status for writable directory, got %s: %s", check.Status, check.Message)
|
||||
}
|
||||
}
|
||||
@@ -550,13 +551,13 @@ func TestCheckDatabaseJSONLSync(t *testing.T) {
|
||||
name: "no database",
|
||||
hasDB: false,
|
||||
hasJSONL: true,
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "no JSONL",
|
||||
hasDB: true,
|
||||
hasJSONL: false,
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -588,7 +589,7 @@ func TestCheckDatabaseJSONLSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
check := checkDatabaseJSONLSync(tmpDir)
|
||||
check := doctor.CheckDatabaseJSONLSync(tmpDir)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
@@ -617,7 +618,7 @@ invalid json line here
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
count, prefixes, err := countJSONLIssues(jsonlPath)
|
||||
count, prefixes, err := doctor.CountJSONLIssues(jsonlPath)
|
||||
|
||||
// Should count valid issues (3)
|
||||
if count != 3 {
|
||||
@@ -649,35 +650,35 @@ func TestCheckGitHooks(t *testing.T) {
|
||||
name: "not a git repository",
|
||||
hasGitDir: false,
|
||||
installedHooks: []string{},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "all hooks installed",
|
||||
hasGitDir: true,
|
||||
installedHooks: []string{"pre-commit", "post-merge", "pre-push"},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "no hooks installed",
|
||||
hasGitDir: true,
|
||||
installedHooks: []string{},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "some hooks installed",
|
||||
hasGitDir: true,
|
||||
installedHooks: []string{"pre-commit"},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "partial hooks installed",
|
||||
hasGitDir: true,
|
||||
installedHooks: []string{"pre-commit", "post-merge"},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
}
|
||||
@@ -724,7 +725,7 @@ func TestCheckGitHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
check := checkGitHooks()
|
||||
check := doctor.CheckGitHooks()
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
@@ -741,6 +742,140 @@ func TestCheckGitHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckClaudePlugin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claudeCodeEnv string
|
||||
expectedStatus string
|
||||
expectedMsg string
|
||||
}{
|
||||
{
|
||||
name: "not running in claude code",
|
||||
claudeCodeEnv: "",
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectedMsg: "N/A (not running in Claude Code)",
|
||||
},
|
||||
{
|
||||
name: "not running in claude code (0)",
|
||||
claudeCodeEnv: "0",
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectedMsg: "N/A (not running in Claude Code)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Save original env
|
||||
origEnv := os.Getenv("CLAUDECODE")
|
||||
defer func() {
|
||||
if origEnv == "" {
|
||||
os.Unsetenv("CLAUDECODE")
|
||||
} else {
|
||||
os.Setenv("CLAUDECODE", origEnv)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set test env
|
||||
if tc.claudeCodeEnv == "" {
|
||||
os.Unsetenv("CLAUDECODE")
|
||||
} else {
|
||||
os.Setenv("CLAUDECODE", tc.claudeCodeEnv)
|
||||
}
|
||||
|
||||
check := doctor.CheckClaudePlugin()
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
}
|
||||
|
||||
if check.Message != tc.expectedMsg {
|
||||
t.Errorf("Expected message %q, got %q", tc.expectedMsg, check.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaudePluginVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pluginJSON string
|
||||
expectInstalled bool
|
||||
expectVersion string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "plugin installed",
|
||||
pluginJSON: `{
|
||||
"version": 1,
|
||||
"plugins": {
|
||||
"beads@beads-marketplace": {
|
||||
"version": "0.21.3"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expectInstalled: true,
|
||||
expectVersion: "0.21.3",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "plugin not installed",
|
||||
pluginJSON: `{
|
||||
"version": 1,
|
||||
"plugins": {
|
||||
"other-plugin@marketplace": {
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expectInstalled: false,
|
||||
expectVersion: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
pluginJSON: `{invalid json`,
|
||||
expectInstalled: false,
|
||||
expectVersion: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create temp dir with plugin file
|
||||
tmpHome := t.TempDir()
|
||||
pluginDir := filepath.Join(tmpHome, ".claude", "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pluginPath := filepath.Join(pluginDir, "installed_plugins.json")
|
||||
if err := os.WriteFile(pluginPath, []byte(tc.pluginJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Temporarily override home directory
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpHome)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
version, installed, err := doctor.GetClaudePluginVersion()
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tc.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if installed != tc.expectInstalled {
|
||||
t.Errorf("Expected installed=%v, got %v", tc.expectInstalled, installed)
|
||||
}
|
||||
if version != tc.expectVersion {
|
||||
t.Errorf("Expected version %q, got %q", tc.expectVersion, version)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -758,7 +893,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
@@ -771,7 +906,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
@@ -784,7 +919,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
@@ -797,7 +932,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
@@ -810,7 +945,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
data, _ := json.Marshal(cfg)
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
|
||||
},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
@@ -818,7 +953,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
setupMetadata: func(beadsDir string) error {
|
||||
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte("{invalid json}"), 0644)
|
||||
},
|
||||
expectedStatus: statusError,
|
||||
expectedStatus: doctor.StatusError,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
@@ -827,7 +962,7 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
// Don't create metadata.json
|
||||
return nil
|
||||
},
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
}
|
||||
@@ -845,13 +980,13 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := checkMetadataVersionTracking(tmpDir)
|
||||
check := doctor.CheckMetadataVersionTracking(tmpDir, Version)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s (message: %s)", tc.expectedStatus, check.Status, check.Message)
|
||||
}
|
||||
|
||||
if tc.expectWarning && check.Status == statusWarning && check.Fix == "" {
|
||||
if tc.expectWarning && check.Status == doctor.StatusWarning && check.Fix == "" {
|
||||
t.Error("Expected fix message for warning status")
|
||||
}
|
||||
})
|
||||
@@ -874,9 +1009,9 @@ func TestIsValidSemver(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := isValidSemver(tc.version)
|
||||
result := doctor.IsValidSemver(tc.version)
|
||||
if result != tc.expected {
|
||||
t.Errorf("isValidSemver(%q) = %v, expected %v", tc.version, result, tc.expected)
|
||||
t.Errorf("doctor.IsValidSemver(%q) = %v, expected %v", tc.version, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,14 +1031,14 @@ func TestParseVersionParts(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := parseVersionParts(tc.version)
|
||||
result := doctor.ParseVersionParts(tc.version)
|
||||
if len(result) != len(tc.expected) {
|
||||
t.Errorf("parseVersionParts(%q) returned %d parts, expected %d", tc.version, len(result), len(tc.expected))
|
||||
t.Errorf("doctor.ParseVersionParts(%q) returned %d parts, expected %d", tc.version, len(result), len(tc.expected))
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tc.expected[i] {
|
||||
t.Errorf("parseVersionParts(%q)[%d] = %d, expected %d", tc.version, i, result[i], tc.expected[i])
|
||||
t.Errorf("doctor.ParseVersionParts(%q)[%d] = %d, expected %d", tc.version, i, result[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,7 +1056,7 @@ func TestCheckSyncBranchConfig(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
// No .beads directory
|
||||
},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
@@ -932,7 +1067,7 @@ func TestCheckSyncBranchConfig(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
@@ -954,7 +1089,7 @@ func TestCheckSyncBranchConfig(t *testing.T) {
|
||||
// Set env var (simulates config.yaml or BEADS_SYNC_BRANCH)
|
||||
t.Setenv("BEADS_SYNC_BRANCH", "beads-sync")
|
||||
},
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
// Note: Tests for "not configured" scenarios are difficult because viper
|
||||
@@ -968,7 +1103,7 @@ func TestCheckSyncBranchConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tc.setupFunc(t, tmpDir)
|
||||
|
||||
result := checkSyncBranchConfig(tmpDir)
|
||||
result := doctor.CheckSyncBranchConfig(tmpDir)
|
||||
|
||||
if result.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %q, got %q", tc.expectedStatus, result.Status)
|
||||
@@ -1110,49 +1245,49 @@ func TestCheckSyncBranchHookCompatibility(t *testing.T) {
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: false,
|
||||
hookVersion: "",
|
||||
expectedStatus: statusOK, // N/A case
|
||||
expectedStatus: doctor.StatusOK, // N/A case
|
||||
},
|
||||
{
|
||||
name: "sync-branch configured, no pre-push hook",
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: true,
|
||||
hookVersion: "",
|
||||
expectedStatus: statusOK, // Covered by other check
|
||||
expectedStatus: doctor.StatusOK, // Covered by other check
|
||||
},
|
||||
{
|
||||
name: "sync-branch configured, custom hook",
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: true,
|
||||
hookVersion: "custom",
|
||||
expectedStatus: statusWarning,
|
||||
expectedStatus: doctor.StatusWarning,
|
||||
},
|
||||
{
|
||||
name: "sync-branch configured, old hook (0.24.2)",
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: true,
|
||||
hookVersion: "0.24.2",
|
||||
expectedStatus: statusError,
|
||||
expectedStatus: doctor.StatusError,
|
||||
},
|
||||
{
|
||||
name: "sync-branch configured, old hook (0.28.0)",
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: true,
|
||||
hookVersion: "0.28.0",
|
||||
expectedStatus: statusError,
|
||||
expectedStatus: doctor.StatusError,
|
||||
},
|
||||
{
|
||||
name: "sync-branch configured, compatible hook (0.29.0)",
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: true,
|
||||
hookVersion: "0.29.0",
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "sync-branch configured, newer hook (0.30.0)",
|
||||
syncBranchEnv: "beads-sync",
|
||||
hasGitDir: true,
|
||||
hookVersion: "0.30.0",
|
||||
expectedStatus: statusOK,
|
||||
expectedStatus: doctor.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1175,9 +1310,6 @@ func TestCheckSyncBranchHookCompatibility(t *testing.T) {
|
||||
// Create pre-push hook if specified
|
||||
if tc.hookVersion != "" {
|
||||
hooksDir := filepath.Join(tmpDir, ".git", "hooks")
|
||||
if err := os.MkdirAll(hooksDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hookPath := filepath.Join(hooksDir, "pre-push")
|
||||
var hookContent string
|
||||
if tc.hookVersion == "custom" {
|
||||
@@ -1191,14 +1323,14 @@ func TestCheckSyncBranchHookCompatibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
check := checkSyncBranchHookCompatibility(tmpDir)
|
||||
check := doctor.CheckSyncBranchHookCompatibility(tmpDir)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s (message: %s)", tc.expectedStatus, check.Status, check.Message)
|
||||
}
|
||||
|
||||
// Error case should have a fix message
|
||||
if tc.expectedStatus == statusError && check.Fix == "" {
|
||||
if tc.expectedStatus == doctor.StatusError && check.Fix == "" {
|
||||
t.Error("Expected fix message for error status")
|
||||
}
|
||||
})
|
||||
@@ -1249,9 +1381,6 @@ func TestCheckSyncBranchHookQuick(t *testing.T) {
|
||||
|
||||
if tc.hookVersion != "" {
|
||||
hooksDir := filepath.Join(tmpDir, ".git", "hooks")
|
||||
if err := os.MkdirAll(hooksDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hookPath := filepath.Join(hooksDir, "pre-push")
|
||||
hookContent := fmt.Sprintf("#!/bin/sh\n# bd-hooks-version: %s\nexit 0\n", tc.hookVersion)
|
||||
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
|
||||
@@ -1260,7 +1389,7 @@ func TestCheckSyncBranchHookQuick(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
issue := checkSyncBranchHookQuick(tmpDir)
|
||||
issue := doctor.CheckSyncBranchHookQuick(tmpDir)
|
||||
|
||||
if tc.expectIssue && issue == "" {
|
||||
t.Error("Expected issue to be reported, got empty string")
|
||||
@@ -1271,62 +1400,3 @@ func TestCheckSyncBranchHookQuick(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractManualFix tests the extractManualFix function (GH#403)
|
||||
func TestExtractManualFix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "manually pattern with git config",
|
||||
input: "Run 'bd doctor --fix' to update to correct config, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"",
|
||||
expected: "git config merge.beads.driver \"bd merge %A %O %A %B\"",
|
||||
},
|
||||
{
|
||||
name: "manually pattern with newline",
|
||||
input: "Run 'bd doctor --fix' to auto-migrate, or manually:\nbd init && bd import",
|
||||
expected: "bd init && bd import",
|
||||
},
|
||||
{
|
||||
name: "or alternative after bd doctor --fix",
|
||||
input: "Run 'bd doctor --fix' or bd init",
|
||||
expected: "bd init",
|
||||
},
|
||||
{
|
||||
name: "or alternative before bd doctor --fix",
|
||||
input: "Run: bd init (safe to re-run) or bd doctor --fix",
|
||||
expected: "bd init (safe to re-run)",
|
||||
},
|
||||
{
|
||||
name: "just bd doctor --fix",
|
||||
input: "Run 'bd doctor --fix'",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "no bd doctor --fix at all",
|
||||
input: "Run 'bd init' to initialize the database",
|
||||
expected: "Run 'bd init' to initialize the database",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "update configuration suggestion",
|
||||
input: "Run 'bd doctor --fix' to update the configuration",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := extractManualFix(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("extractManualFix(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user