* feat: add performance testing framework foundation Implements foundation for comprehensive performance testing and user diagnostics for beads databases at 10K-20K scale. Components added: - Fixture generator (internal/testutil/fixtures/) for realistic test data * LargeSQLite/XLargeSQLite: 10K/20K issues with epic hierarchies * LargeFromJSONL/XLargeFromJSONL: test JSONL import path * Realistic cross-linked dependencies, labels, assignees * Reproducible with seeded RNG - User diagnostics (bd doctor --perf) for field performance data * Collects platform info (OS, arch, Go/SQLite versions) * Measures key operation timings (ready, list, show, search) * Generates CPU profiles for bug reports * Clean separation in cmd/bd/doctor/perf.go Test data characteristics: - 10% epics, 30% features, 60% tasks - 4-level hierarchies (Epic → Feature → Task → Subtask) - 20% cross-epic blocking dependencies - Realistic status/priority/label distributions Supports bd-l954 (Performance Testing Framework epic) Closes bd-6ed8, bd-q59i * perf: optimize GetReadyWork with compound index (20x speedup) Add compound index on dependencies(depends_on_id, type, issue_id) to eliminate performance bottleneck in GetReadyWork recursive CTE query. Performance improvements (10K issue database): - GetReadyWork: 752ms → 36.6ms (20.5x faster) - Target: <50ms ✓ ACHIEVED - 20K database: ~1500ms → 79.4ms (19x faster) Benchmark infrastructure enhancements: - Add dataset caching in /tmp/beads-bench-cache/ to avoid regenerating 10K-20K issues on every benchmark run (first run: ~2min, subsequent: <5s) - Add progress logging during fixture generation (shows 10%, 20%... completion) - Add database size logging (17.5 MB for 10K, 35.1 MB for 20K) - Document rationale for only benchmarking large datasets (>10K issues) - Add CPU/trace profiling with --profile flag for performance debugging Schema changes: - internal/storage/sqlite/schema.go: Add idx_dependencies_depends_on_type_issue New files: - internal/storage/sqlite/bench_helpers_test.go: Reusable benchmark setup with caching - internal/storage/sqlite/sqlite_bench_test.go: Comprehensive benchmarks for critical operations - Makefile: Convenient benchmark execution (make bench-quick, make bench) Related: - Resolves bd-5qim (optimize GetReadyWork performance) - Builds on bd-6ed8 (fixture generator), bd-q59i (bd doctor --perf) * perf: add WASM compilation cache to eliminate cold-start overhead Configure wazero compilation cache for ncruces/go-sqlite3 to avoid ~220ms JIT compilation on every process start. Cache configuration: - Location: ~/.cache/beads/wasm/ (platform-specific via os.UserCacheDir) - Automatic version management: wazero keys entries by its version - Fallback: in-memory cache if directory creation fails - No cleanup needed: old versions are harmless (~5-10MB each) Performance impact: - First run: ~220ms (populate cache) - Subsequent runs: ~20ms (load from cache) - Savings: ~200ms per cold start Cache invalidation: - Automatic when wazero version changes (upgrades use new cache dir) - Manual cleanup: rm -rf ~/.cache/beads/wasm/ (safe to delete anytime) This complements daemon mode: - Daemon mode: eliminates startup cost by keeping process alive - WASM cache: reduces startup cost for one-off commands or daemon restarts Changes: - internal/storage/sqlite/sqlite.go: Add init() with cache setup * refactor: improve maintainability of performance testing code Extract common patterns and eliminate duplication across benchmarks, fixture generation, and performance diagnostics. Replace magic numbers with explicit configuration to improve readability and make it easier to tune test parameters. * docs: clarify profiling behavior and add missing documentation Add explanatory comments for profiling setup to clarify why --profile forces direct mode (captures actual database operations instead of RPC overhead) and document the stopCPUProfile function's role in flushing profile data to disk. Also fix gosec G104 linter warning by explicitly ignoring Close() error during cleanup. * fix: prevent bench-quick from running indefinitely Added //go:build bench tags and skipped timeout-prone benchmarks to prevent make bench-quick from running for hours. Changes: - Add //go:build bench tag to cycle_bench_test.go and compact_bench_test.go - Skip Dense graph benchmarks (documented to timeout >120s) - Fix compact benchmark prefix: bd- → bd (validation expects prefix without trailing dash) Before: make bench-quick ran for 3.5+ hours (12,699s) before manual interrupt After: make bench-quick completes in ~25 seconds The Dense graph benchmarks are known to timeout and represent rare edge cases that don't need optimization for typical workflows.
1329 lines
36 KiB
Go
1329 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/cmd/bd/doctor"
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/configfile"
|
|
"github.com/steveyegge/beads/internal/daemon"
|
|
_ "github.com/ncruces/go-sqlite3/driver"
|
|
_ "github.com/ncruces/go-sqlite3/embed"
|
|
)
|
|
|
|
// Status constants for doctor checks
|
|
const (
|
|
statusOK = "ok"
|
|
statusWarning = "warning"
|
|
statusError = "error"
|
|
)
|
|
|
|
type doctorCheck struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // statusOK, statusWarning, or statusError
|
|
Message string `json:"message"`
|
|
Detail string `json:"detail,omitempty"` // Additional detail like storage type
|
|
Fix string `json:"fix,omitempty"`
|
|
}
|
|
|
|
type doctorResult struct {
|
|
Path string `json:"path"`
|
|
Checks []doctorCheck `json:"checks"`
|
|
OverallOK bool `json:"overall_ok"`
|
|
CLIVersion string `json:"cli_version"`
|
|
}
|
|
|
|
var (
|
|
doctorFix bool
|
|
perfMode bool
|
|
)
|
|
|
|
var doctorCmd = &cobra.Command{
|
|
Use: "doctor [path]",
|
|
Short: "Check beads installation health",
|
|
Long: `Sanity check the beads installation for the current directory or specified path.
|
|
|
|
This command checks:
|
|
- If .beads/ directory exists
|
|
- Database version and migration status
|
|
- Schema compatibility (all required tables and columns present)
|
|
- Whether using hash-based vs sequential IDs
|
|
- If CLI version is current (checks GitHub releases)
|
|
- Multiple database files
|
|
- Multiple JSONL files
|
|
- Daemon health (version mismatches, stale processes)
|
|
- Database-JSONL sync status
|
|
- File permissions
|
|
- Circular dependencies
|
|
- Git hooks (pre-commit, post-merge, pre-push)
|
|
- .beads/.gitignore up to date
|
|
|
|
Performance Mode (--perf):
|
|
Run performance diagnostics on your database:
|
|
- Times key operations (bd ready, bd list, bd show, etc.)
|
|
- Collects system info (OS, arch, SQLite version, database stats)
|
|
- Generates CPU profile for analysis
|
|
- Outputs shareable report for bug reports
|
|
|
|
Examples:
|
|
bd doctor # Check current directory
|
|
bd doctor /path/to/repo # Check specific repository
|
|
bd doctor --json # Machine-readable output
|
|
bd doctor --fix # Automatically fix issues
|
|
bd doctor --perf # Performance diagnostics`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
// Use global jsonOutput set by PersistentPreRun
|
|
|
|
// Determine path to check
|
|
checkPath := "."
|
|
if len(args) > 0 {
|
|
checkPath = args[0]
|
|
}
|
|
|
|
// Convert to absolute path
|
|
absPath, err := filepath.Abs(checkPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to resolve path: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Run performance diagnostics if --perf flag is set
|
|
if perfMode {
|
|
doctor.RunPerformanceDiagnostics(absPath)
|
|
return
|
|
}
|
|
|
|
// Run diagnostics
|
|
result := runDiagnostics(absPath)
|
|
|
|
// Apply fixes if requested
|
|
if doctorFix {
|
|
applyFixes(result)
|
|
// Re-run diagnostics to show results
|
|
result = runDiagnostics(absPath)
|
|
}
|
|
|
|
// Output results
|
|
if jsonOutput {
|
|
outputJSON(result)
|
|
} else {
|
|
printDiagnostics(result)
|
|
}
|
|
|
|
// Exit with error if any checks failed
|
|
if !result.OverallOK {
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Automatically fix issues where possible")
|
|
}
|
|
|
|
func applyFixes(result doctorResult) {
|
|
for _, check := range result.Checks {
|
|
if check.Status == statusWarning || check.Status == statusError {
|
|
switch check.Name {
|
|
case "Gitignore":
|
|
fmt.Println("Fixing .beads/.gitignore...")
|
|
if err := doctor.FixGitignore(); err != nil {
|
|
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
|
|
} else {
|
|
fmt.Println(" ✓ Updated .beads/.gitignore")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func runDiagnostics(path string) doctorResult{
|
|
result := doctorResult{
|
|
Path: path,
|
|
CLIVersion: Version,
|
|
OverallOK: true,
|
|
}
|
|
|
|
// Check 1: Installation (.beads/ directory)
|
|
installCheck := checkInstallation(path)
|
|
result.Checks = append(result.Checks, installCheck)
|
|
if installCheck.Status != statusOK {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check Git Hooks early (even if .beads/ doesn't exist yet)
|
|
hooksCheck := checkGitHooks(path)
|
|
result.Checks = append(result.Checks, hooksCheck)
|
|
// Don't fail overall check for missing hooks, just warn
|
|
|
|
// If no .beads/, skip remaining checks
|
|
if installCheck.Status != statusOK {
|
|
return result
|
|
}
|
|
|
|
// Check 2: Database version
|
|
dbCheck := checkDatabaseVersion(path)
|
|
result.Checks = append(result.Checks, dbCheck)
|
|
if dbCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 2a: Schema compatibility (bd-ckvw)
|
|
schemaCheck := checkSchemaCompatibility(path)
|
|
result.Checks = append(result.Checks, schemaCheck)
|
|
if schemaCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 3: ID format (hash vs sequential)
|
|
idCheck := checkIDFormat(path)
|
|
result.Checks = append(result.Checks, idCheck)
|
|
if idCheck.Status == statusWarning {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 4: CLI version (GitHub)
|
|
versionCheck := checkCLIVersion()
|
|
result.Checks = append(result.Checks, versionCheck)
|
|
// Don't fail overall check for outdated CLI, just warn
|
|
|
|
// Check 5: Multiple database files
|
|
multiDBCheck := checkMultipleDatabases(path)
|
|
result.Checks = append(result.Checks, multiDBCheck)
|
|
if multiDBCheck.Status == statusWarning || multiDBCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 6: Legacy JSONL filename (issues.jsonl vs beads.jsonl)
|
|
jsonlCheck := convertDoctorCheck(doctor.CheckLegacyJSONLFilename(path))
|
|
result.Checks = append(result.Checks, jsonlCheck)
|
|
if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 7: Daemon health
|
|
daemonCheck := checkDaemonStatus(path)
|
|
result.Checks = append(result.Checks, daemonCheck)
|
|
if daemonCheck.Status == statusWarning || daemonCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 8: Database-JSONL sync
|
|
syncCheck := checkDatabaseJSONLSync(path)
|
|
result.Checks = append(result.Checks, syncCheck)
|
|
if syncCheck.Status == statusWarning || syncCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 9: Permissions
|
|
permCheck := checkPermissions(path)
|
|
result.Checks = append(result.Checks, permCheck)
|
|
if permCheck.Status == statusError {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 10: Dependency cycles
|
|
cycleCheck := checkDependencyCycles(path)
|
|
result.Checks = append(result.Checks, cycleCheck)
|
|
if cycleCheck.Status == statusError || cycleCheck.Status == statusWarning {
|
|
result.OverallOK = false
|
|
}
|
|
|
|
// Check 11: Claude integration
|
|
claudeCheck := convertDoctorCheck(doctor.CheckClaude())
|
|
result.Checks = append(result.Checks, claudeCheck)
|
|
// Don't fail overall check for missing Claude integration, just warn
|
|
|
|
// Check 12: Legacy beads slash commands in documentation
|
|
legacyDocsCheck := convertDoctorCheck(doctor.CheckLegacyBeadsSlashCommands(path))
|
|
result.Checks = append(result.Checks, legacyDocsCheck)
|
|
// Don't fail overall check for legacy docs, just warn
|
|
|
|
// Check 13: Gitignore up to date
|
|
gitignoreCheck := convertDoctorCheck(doctor.CheckGitignore())
|
|
result.Checks = append(result.Checks, gitignoreCheck)
|
|
// Don't fail overall check for gitignore, just warn
|
|
|
|
return result
|
|
}
|
|
|
|
// convertDoctorCheck converts doctor package check to main package check
|
|
func convertDoctorCheck(dc doctor.DoctorCheck) doctorCheck {
|
|
return doctorCheck{
|
|
Name: dc.Name,
|
|
Status: dc.Status,
|
|
Message: dc.Message,
|
|
Detail: dc.Detail,
|
|
Fix: dc.Fix,
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
func checkDatabaseVersion(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 database file exists
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
// Check if JSONL exists (--no-db mode)
|
|
// Check both canonical (beads.jsonl) and legacy (issues.jsonl) names
|
|
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
|
|
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
if _, err := os.Stat(beadsJSONL); err == nil {
|
|
return doctorCheck{
|
|
Name: "Database",
|
|
Status: statusOK,
|
|
Message: "JSONL-only mode",
|
|
Detail: "Using beads.jsonl (no SQLite database)",
|
|
}
|
|
}
|
|
|
|
if _, err := os.Stat(issuesJSONL); err == nil {
|
|
return doctorCheck{
|
|
Name: "Database",
|
|
Status: statusOK,
|
|
Message: "JSONL-only mode",
|
|
Detail: "Using issues.jsonl (no SQLite database)",
|
|
}
|
|
}
|
|
|
|
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 != Version {
|
|
return doctorCheck{
|
|
Name: "Database",
|
|
Status: statusWarning,
|
|
Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, Version),
|
|
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",
|
|
}
|
|
}
|
|
|
|
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 first issue to check ID format
|
|
var issueID string
|
|
err = db.QueryRow("SELECT id FROM issues ORDER BY created_at LIMIT 1").Scan(&issueID)
|
|
if err == sql.ErrNoRows {
|
|
return doctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: statusOK,
|
|
Message: "No issues yet (will use hash-based IDs)",
|
|
}
|
|
}
|
|
if err != nil {
|
|
return doctorCheck{
|
|
Name: "Issue IDs",
|
|
Status: statusError,
|
|
Message: "Unable to query issues",
|
|
}
|
|
}
|
|
|
|
// Detect ID format
|
|
if isHashID(issueID) {
|
|
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)",
|
|
}
|
|
}
|
|
|
|
func checkCLIVersion() 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)", Version),
|
|
}
|
|
}
|
|
|
|
if latestVersion == "" || latestVersion == Version {
|
|
return doctorCheck{
|
|
Name: "CLI Version",
|
|
Status: statusOK,
|
|
Message: fmt.Sprintf("%s (latest)", Version),
|
|
}
|
|
}
|
|
|
|
// Compare versions using simple semver-aware comparison
|
|
if compareVersions(latestVersion, Version) > 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)", Version, latestVersion),
|
|
Fix: fmt.Sprintf("Upgrade to latest version:\n%s", upgradeCmds),
|
|
}
|
|
}
|
|
|
|
return doctorCheck{
|
|
Name: "CLI Version",
|
|
Status: statusOK,
|
|
Message: fmt.Sprintf("%s (latest)", Version),
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
// Note: isHashID is defined in migrate_hash_ids.go to avoid duplication
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func printDiagnostics(result doctorResult) {
|
|
// Print header
|
|
fmt.Println("\nDiagnostics")
|
|
|
|
// Print each check with tree formatting
|
|
for i, check := range result.Checks {
|
|
// Determine prefix
|
|
prefix := "├"
|
|
if i == len(result.Checks)-1 {
|
|
prefix = "└"
|
|
}
|
|
|
|
// Format status indicator
|
|
var statusIcon string
|
|
switch check.Status {
|
|
case statusOK:
|
|
statusIcon = ""
|
|
case statusWarning:
|
|
statusIcon = color.YellowString(" ⚠")
|
|
case statusError:
|
|
statusIcon = color.RedString(" ✗")
|
|
}
|
|
|
|
// Print main check line
|
|
fmt.Printf(" %s %s: %s%s\n", prefix, check.Name, check.Message, statusIcon)
|
|
|
|
// Print detail if present (indented under the check)
|
|
if check.Detail != "" {
|
|
detailPrefix := "│"
|
|
if i == len(result.Checks)-1 {
|
|
detailPrefix = " "
|
|
}
|
|
fmt.Printf(" %s %s\n", detailPrefix, color.New(color.Faint).Sprint(check.Detail))
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
|
|
// Print warnings/errors with fixes
|
|
hasIssues := false
|
|
for _, check := range result.Checks {
|
|
if check.Status != statusOK && check.Fix != "" {
|
|
if !hasIssues {
|
|
hasIssues = true
|
|
}
|
|
|
|
switch check.Status {
|
|
case statusWarning:
|
|
color.Yellow("⚠ Warning: %s\n", check.Message)
|
|
case statusError:
|
|
color.Red("✗ Error: %s\n", check.Message)
|
|
}
|
|
|
|
fmt.Printf(" Fix: %s\n\n", check.Fix)
|
|
}
|
|
}
|
|
|
|
if !hasIssues {
|
|
color.Green("✓ All checks passed\n")
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
func checkDaemonStatus(path 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 != Version {
|
|
return doctorCheck{
|
|
Name: "Daemon Health",
|
|
Status: statusWarning,
|
|
Message: fmt.Sprintf("Version mismatch (daemon: %s, CLI: %s)", d.Version, Version),
|
|
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),
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
// 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 first dash)
|
|
parts := strings.SplitN(id, "-", 2)
|
|
if len(parts) > 0 {
|
|
prefixes[parts[0]]++
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
func checkGitHooks(path string) doctorCheck {
|
|
// Check if we're in a git repository
|
|
gitDir := filepath.Join(path, ".git")
|
|
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
|
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,
|
|
}
|
|
}
|
|
|
|
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"},
|
|
"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)
|
|
_, 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)
|
|
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",
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(doctorCmd)
|
|
doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile")
|
|
}
|