Files
beads/cmd/bd/doctor.go
dave 6a5c289af3 fix: hide issues.jsonl from git status when sync.branch configured (GH#870)
When sync.branch is configured, issues.jsonl appears modified in git status
even though changes go to the sync branch. This is confusing for users and
risks accidental commits to the wrong branch.

Implementation:
- Added SyncBranchGitignore() to set git index flags (assume-unchanged,
  skip-worktree) on issues.jsonl when sync.branch is configured
- For untracked files, adds to .git/info/exclude instead
- Called automatically from bd sync after successful sync-branch sync
- Added bd doctor check and fix for this issue
- Added HasSyncBranchGitignoreFlags() to check current flag state
- Added ClearSyncBranchGitignore() to remove flags when sync.branch disabled

Fixes: GH#870 (duplicate of GH#797, GH#801)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
2026-01-03 21:07:32 -08:00

724 lines
28 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"time"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/internal/ui"
)
// 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"`
Category string `json:"category,omitempty"` // category for grouping in output
}
type doctorResult struct {
Path string `json:"path"`
Checks []doctorCheck `json:"checks"`
OverallOK bool `json:"overall_ok"`
CLIVersion string `json:"cli_version"`
Timestamp string `json:"timestamp,omitempty"` // ISO8601 timestamp for historical tracking
Platform map[string]string `json:"platform,omitempty"` // platform info for debugging
}
var (
doctorFix bool
doctorYes bool
doctorInteractive bool // per-fix confirmation mode
doctorDryRun bool // preview fixes without applying
doctorOutput string // export diagnostics to file
doctorFixChildParent bool // opt-in fix for child→parent deps
doctorVerbose bool // show detailed output during fixes
doctorForce bool // force repair mode, bypass validation where safe
doctorSource string // source of truth selection: auto, jsonl, db
perfMode bool
checkHealthMode bool
doctorCheckFlag string // run specific check (e.g., "pollution")
doctorClean bool // for pollution check, delete detected issues
doctorDeep bool // full graph integrity validation
)
// ConfigKeyHintsDoctor is the config key for suppressing doctor hints
const ConfigKeyHintsDoctor = "hints.doctor"
// minSyncBranchHookVersion is the minimum hook version that supports sync-branch bypass (issue #532)
const minSyncBranchHookVersion = "0.29.0"
var doctorCmd = &cobra.Command{
Use: "doctor [path]",
GroupID: "maint",
Short: "Check and fix beads installation health (start here)",
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)
- If Claude plugin is current (when running in Claude Code)
- 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
- Metadata.json version tracking (LastBdVersion field)
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
Export Mode (--output):
Save diagnostics to a JSON file for historical analysis and bug reporting.
Includes timestamp and platform info for tracking intermittent issues.
Specific Check Mode (--check):
Run a specific check in detail. Available checks:
- pollution: Detect and optionally clean test issues from database
Deep Validation Mode (--deep):
Validate full graph integrity. May be slow on large databases.
Additional checks:
- Parent consistency: All parent-child deps point to existing issues
- Dependency integrity: All deps reference valid issues
- Epic completeness: Find epics ready to close (all children closed)
- Agent bead integrity: Agent beads have valid state values
- Mail thread integrity: Thread IDs reference existing issues
- Molecule integrity: Molecules have valid parent-child structures
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 (with confirmation)
bd doctor --fix --yes # Automatically fix issues (no confirmation)
bd doctor --fix -i # Confirm each fix individually
bd doctor --fix --fix-child-parent # Also fix child→parent deps (opt-in)
bd doctor --fix --force # Force repair even when database can't be opened
bd doctor --fix --source=jsonl # Rebuild database from JSONL (source of truth)
bd doctor --dry-run # Preview what --fix would do without making changes
bd doctor --perf # Performance diagnostics
bd doctor --output diagnostics.json # Export diagnostics to file
bd doctor --check=pollution # Show potential test issues
bd doctor --check=pollution --clean # Delete test issues (with confirmation)
bd doctor --deep # Full graph integrity validation`,
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 quick health check if --check-health flag is set
if checkHealthMode {
runCheckHealth(absPath)
return
}
// Run specific check if --check flag is set
if doctorCheckFlag != "" {
switch doctorCheckFlag {
case "pollution":
runPollutionCheck(absPath, doctorClean, doctorYes)
return
default:
fmt.Fprintf(os.Stderr, "Error: unknown check %q\n", doctorCheckFlag)
fmt.Fprintf(os.Stderr, "Available checks: pollution\n")
os.Exit(1)
}
}
// Run deep validation if --deep flag is set
if doctorDeep {
runDeepValidation(absPath)
return
}
// Run diagnostics
result := runDiagnostics(absPath)
// Preview fixes (dry-run) or apply fixes if requested
if doctorDryRun {
previewFixes(result)
} else if doctorFix {
applyFixes(result)
// Re-run diagnostics to show results
result = runDiagnostics(absPath)
}
// Add timestamp and platform info for export
if doctorOutput != "" || jsonOutput {
result.Timestamp = time.Now().UTC().Format(time.RFC3339)
result.Platform = doctor.CollectPlatformInfo(absPath)
}
// Export to file if --output specified
if doctorOutput != "" {
if err := exportDiagnostics(result, doctorOutput); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to export diagnostics: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Diagnostics exported to %s\n", doctorOutput)
}
// Output results
if jsonOutput {
outputJSON(result)
} else if doctorOutput == "" {
// Only print to console if not exporting (to avoid duplicate output)
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")
doctorCmd.Flags().BoolVarP(&doctorYes, "yes", "y", false, "Skip confirmation prompt (for non-interactive use)")
doctorCmd.Flags().BoolVarP(&doctorInteractive, "interactive", "i", false, "Confirm each fix individually")
doctorCmd.Flags().BoolVar(&doctorDryRun, "dry-run", false, "Preview fixes without making changes")
doctorCmd.Flags().BoolVar(&doctorFixChildParent, "fix-child-parent", false, "Remove child→parent dependencies (opt-in)")
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output during fixes (e.g., list each removed dependency)")
doctorCmd.Flags().BoolVar(&doctorForce, "force", false, "Force repair mode: attempt recovery even when database cannot be opened")
doctorCmd.Flags().StringVar(&doctorSource, "source", "auto", "Choose source of truth for recovery: auto (detect), jsonl (prefer JSONL), db (prefer database)")
}
func runDiagnostics(path string) doctorResult {
result := doctorResult{
Path: path,
CLIVersion: Version,
OverallOK: true,
}
// Check 1: Installation (.beads/ directory)
installCheck := convertWithCategory(doctor.CheckInstallation(path), doctor.CategoryCore)
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 := convertWithCategory(doctor.CheckGitHooks(), doctor.CategoryGit)
result.Checks = append(result.Checks, hooksCheck)
// Don't fail overall check for missing hooks, just warn
// Check sync-branch hook compatibility (issue #532)
syncBranchHookCheck := convertWithCategory(doctor.CheckSyncBranchHookCompatibility(path), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchHookCheck)
if syncBranchHookCheck.Status == statusError {
result.OverallOK = false
}
// If no .beads/, skip remaining checks
if installCheck.Status != statusOK {
return result
}
// Check 1a: Fresh clone detection
// Must come early - if this is a fresh clone, other checks may be misleading
freshCloneCheck := convertWithCategory(doctor.CheckFreshClone(path), doctor.CategoryCore)
result.Checks = append(result.Checks, freshCloneCheck)
if freshCloneCheck.Status == statusWarning || freshCloneCheck.Status == statusError {
result.OverallOK = false
}
// Check 2: Database version
dbCheck := convertWithCategory(doctor.CheckDatabaseVersion(path, Version), doctor.CategoryCore)
result.Checks = append(result.Checks, dbCheck)
if dbCheck.Status == statusError {
result.OverallOK = false
}
// Check 2a: Schema compatibility
schemaCheck := convertWithCategory(doctor.CheckSchemaCompatibility(path), doctor.CategoryCore)
result.Checks = append(result.Checks, schemaCheck)
if schemaCheck.Status == statusError {
result.OverallOK = false
}
// Check 2b: Repo fingerprint (detects wrong database or URL change)
fingerprintCheck := convertWithCategory(doctor.CheckRepoFingerprint(path), doctor.CategoryCore)
result.Checks = append(result.Checks, fingerprintCheck)
if fingerprintCheck.Status == statusError {
result.OverallOK = false
}
// Check 2c: Database integrity
integrityCheck := convertWithCategory(doctor.CheckDatabaseIntegrity(path), doctor.CategoryCore)
result.Checks = append(result.Checks, integrityCheck)
if integrityCheck.Status == statusError {
result.OverallOK = false
}
// Check 3: ID format (hash vs sequential)
idCheck := convertWithCategory(doctor.CheckIDFormat(path), doctor.CategoryCore)
result.Checks = append(result.Checks, idCheck)
if idCheck.Status == statusWarning {
result.OverallOK = false
}
// Check 4: CLI version (GitHub)
versionCheck := convertWithCategory(doctor.CheckCLIVersion(Version), doctor.CategoryCore)
result.Checks = append(result.Checks, versionCheck)
// Don't fail overall check for outdated CLI, just warn
// Check 4.5: Claude plugin version (if running in Claude Code)
pluginCheck := convertWithCategory(doctor.CheckClaudePlugin(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, pluginCheck)
// Don't fail overall check for outdated plugin, just warn
// Check 5: Multiple database files
multiDBCheck := convertWithCategory(doctor.CheckMultipleDatabases(path), doctor.CategoryData)
result.Checks = append(result.Checks, multiDBCheck)
if multiDBCheck.Status == statusWarning || multiDBCheck.Status == statusError {
result.OverallOK = false
}
// Check 6: Multiple JSONL files (excluding merge artifacts)
jsonlCheck := convertWithCategory(doctor.CheckLegacyJSONLFilename(path), doctor.CategoryData)
result.Checks = append(result.Checks, jsonlCheck)
if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError {
result.OverallOK = false
}
// Check 6a: Legacy JSONL config (migrate beads.jsonl to issues.jsonl)
legacyConfigCheck := convertWithCategory(doctor.CheckLegacyJSONLConfig(path), doctor.CategoryData)
result.Checks = append(result.Checks, legacyConfigCheck)
// Don't fail overall check for legacy config, just warn
// Check 7: Database/JSONL configuration mismatch
configCheck := convertWithCategory(doctor.CheckDatabaseConfig(path), doctor.CategoryData)
result.Checks = append(result.Checks, configCheck)
if configCheck.Status == statusWarning || configCheck.Status == statusError {
result.OverallOK = false
}
// Check 7a: Configuration value validation
configValuesCheck := convertWithCategory(doctor.CheckConfigValues(path), doctor.CategoryData)
result.Checks = append(result.Checks, configValuesCheck)
// Don't fail overall check for config value warnings, just warn
// Check 7b: JSONL integrity (malformed lines, missing IDs)
jsonlIntegrityCheck := convertWithCategory(doctor.CheckJSONLIntegrity(path), doctor.CategoryData)
result.Checks = append(result.Checks, jsonlIntegrityCheck)
if jsonlIntegrityCheck.Status == statusWarning || jsonlIntegrityCheck.Status == statusError {
result.OverallOK = false
}
// Check 8: Daemon health
daemonCheck := convertWithCategory(doctor.CheckDaemonStatus(path, Version), doctor.CategoryRuntime)
result.Checks = append(result.Checks, daemonCheck)
if daemonCheck.Status == statusWarning || daemonCheck.Status == statusError {
result.OverallOK = false
}
// Check 9: Database-JSONL sync
syncCheck := convertWithCategory(doctor.CheckDatabaseJSONLSync(path), doctor.CategoryData)
result.Checks = append(result.Checks, syncCheck)
if syncCheck.Status == statusWarning || syncCheck.Status == statusError {
result.OverallOK = false
}
// Check 9: Permissions
permCheck := convertWithCategory(doctor.CheckPermissions(path), doctor.CategoryCore)
result.Checks = append(result.Checks, permCheck)
if permCheck.Status == statusError {
result.OverallOK = false
}
// Check 10: Dependency cycles
cycleCheck := convertWithCategory(doctor.CheckDependencyCycles(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, cycleCheck)
if cycleCheck.Status == statusError || cycleCheck.Status == statusWarning {
result.OverallOK = false
}
// Check 11: Claude integration
claudeCheck := convertWithCategory(doctor.CheckClaude(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, claudeCheck)
// Don't fail overall check for missing Claude integration, just warn
// Check 11b: Gemini CLI integration
geminiCheck := convertWithCategory(doctor.CheckGemini(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, geminiCheck)
// Don't fail overall check for missing Gemini integration, just info
// Check 11a: bd in PATH (needed for Claude hooks to work)
bdPathCheck := convertWithCategory(doctor.CheckBdInPath(), doctor.CategoryIntegration)
result.Checks = append(result.Checks, bdPathCheck)
// Don't fail overall check for missing bd in PATH, just warn
// Check 11b: Documentation bd prime references match installed version
bdPrimeDocsCheck := convertWithCategory(doctor.CheckDocumentationBdPrimeReference(path), doctor.CategoryIntegration)
result.Checks = append(result.Checks, bdPrimeDocsCheck)
// Don't fail overall check for doc mismatch, just warn
// Check 12: Agent documentation presence
agentDocsCheck := convertWithCategory(doctor.CheckAgentDocumentation(path), doctor.CategoryIntegration)
result.Checks = append(result.Checks, agentDocsCheck)
// Don't fail overall check for missing docs, just warn
// Check 13: Legacy beads slash commands in documentation
legacyDocsCheck := convertWithCategory(doctor.CheckLegacyBeadsSlashCommands(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, legacyDocsCheck)
// Don't fail overall check for legacy docs, just warn
// Check 14: Gitignore up to date
gitignoreCheck := convertWithCategory(doctor.CheckGitignore(), doctor.CategoryGit)
result.Checks = append(result.Checks, gitignoreCheck)
// Don't fail overall check for gitignore, just warn
// Check 14a: issues.jsonl tracking (catches global gitignore conflicts)
issuesTrackingCheck := convertWithCategory(doctor.CheckIssuesTracking(), doctor.CategoryGit)
result.Checks = append(result.Checks, issuesTrackingCheck)
// Don't fail overall check for tracking issues, just warn
// Check 14b: redirect file tracking (worktree redirect files shouldn't be committed)
redirectTrackingCheck := convertWithCategory(doctor.CheckRedirectNotTracked(), doctor.CategoryGit)
result.Checks = append(result.Checks, redirectTrackingCheck)
// Don't fail overall check for redirect tracking, just warn
// Check 15: Git merge driver configuration
mergeDriverCheck := convertWithCategory(doctor.CheckMergeDriver(path), doctor.CategoryGit)
result.Checks = append(result.Checks, mergeDriverCheck)
// Don't fail overall check for merge driver, just warn
// Check 15a: Git working tree cleanliness (AGENTS.md hygiene)
gitWorkingTreeCheck := convertWithCategory(doctor.CheckGitWorkingTree(path), doctor.CategoryGit)
result.Checks = append(result.Checks, gitWorkingTreeCheck)
// Don't fail overall check for dirty working tree, just warn
// Check 15b: Git upstream sync (ahead/behind/diverged)
gitUpstreamCheck := convertWithCategory(doctor.CheckGitUpstream(path), doctor.CategoryGit)
result.Checks = append(result.Checks, gitUpstreamCheck)
// Don't fail overall check for upstream drift, just warn
// Check 16: Metadata.json version tracking
metadataCheck := convertWithCategory(doctor.CheckMetadataVersionTracking(path, Version), doctor.CategoryMetadata)
result.Checks = append(result.Checks, metadataCheck)
// Don't fail overall check for metadata, just warn
// Check 17: Sync branch configuration
syncBranchCheck := convertWithCategory(doctor.CheckSyncBranchConfig(path), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchCheck)
// Don't fail overall check for missing sync.branch, just warn
// Check 17a: Sync branch health
syncBranchHealthCheck := convertWithCategory(doctor.CheckSyncBranchHealth(path), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchHealthCheck)
// Don't fail overall check for sync branch health, just warn
// Check 17b: Orphaned issues - referenced in commits but still open
orphanedIssuesCheck := convertWithCategory(doctor.CheckOrphanedIssues(path), doctor.CategoryGit)
result.Checks = append(result.Checks, orphanedIssuesCheck)
// Don't fail overall check for orphaned issues, just warn
// Check 17c: Sync branch gitignore flags (GH#870)
syncBranchGitignoreCheck := convertWithCategory(doctor.CheckSyncBranchGitignore(), doctor.CategoryGit)
result.Checks = append(result.Checks, syncBranchGitignoreCheck)
// Don't fail overall check for sync branch gitignore, just warn
// Check 18: Deletions manifest (legacy, now replaced by tombstones)
deletionsCheck := convertWithCategory(doctor.CheckDeletionsManifest(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, deletionsCheck)
// Don't fail overall check for missing deletions manifest, just warn
// Check 19: Tombstones health
tombstonesCheck := convertWithCategory(doctor.CheckTombstones(path), doctor.CategoryMetadata)
result.Checks = append(result.Checks, tombstonesCheck)
// Don't fail overall check for tombstone issues, just warn
// Check 20: Untracked .beads/*.jsonl files
untrackedCheck := convertWithCategory(doctor.CheckUntrackedBeadsFiles(path), doctor.CategoryData)
result.Checks = append(result.Checks, untrackedCheck)
// Don't fail overall check for untracked files, just warn
// Check 21: Merge artifacts (from bd clean)
mergeArtifactsCheck := convertDoctorCheck(doctor.CheckMergeArtifacts(path))
result.Checks = append(result.Checks, mergeArtifactsCheck)
// Don't fail overall check for merge artifacts, just warn
// Check 22: Orphaned dependencies (from bd repair-deps, bd validate)
orphanedDepsCheck := convertDoctorCheck(doctor.CheckOrphanedDependencies(path))
result.Checks = append(result.Checks, orphanedDepsCheck)
// Don't fail overall check for orphaned deps, just warn
// Check 22a: Child→parent dependencies (anti-pattern)
childParentDepsCheck := convertDoctorCheck(doctor.CheckChildParentDependencies(path))
result.Checks = append(result.Checks, childParentDepsCheck)
// Don't fail overall check for child→parent deps, just warn
// Check 23: Duplicate issues (from bd validate)
duplicatesCheck := convertDoctorCheck(doctor.CheckDuplicateIssues(path))
result.Checks = append(result.Checks, duplicatesCheck)
// Don't fail overall check for duplicates, just warn
// Check 24: Test pollution (from bd validate)
pollutionCheck := convertDoctorCheck(doctor.CheckTestPollution(path))
result.Checks = append(result.Checks, pollutionCheck)
// Don't fail overall check for test pollution, just warn
// Check 25: Git conflicts in JSONL (from bd validate)
conflictsCheck := convertDoctorCheck(doctor.CheckGitConflicts(path))
result.Checks = append(result.Checks, conflictsCheck)
if conflictsCheck.Status == statusError {
result.OverallOK = false
}
// Check 26: Stale closed issues (maintenance)
staleClosedCheck := convertDoctorCheck(doctor.CheckStaleClosedIssues(path))
result.Checks = append(result.Checks, staleClosedCheck)
// Don't fail overall check for stale issues, just warn
// Check 26a: Stale molecules (complete but unclosed)
staleMoleculesCheck := convertDoctorCheck(doctor.CheckStaleMolecules(path))
result.Checks = append(result.Checks, staleMoleculesCheck)
// Don't fail overall check for stale molecules, just warn
// Check 26b: Persistent mol- issues (should have been ephemeral)
persistentMolCheck := convertDoctorCheck(doctor.CheckPersistentMolIssues(path))
result.Checks = append(result.Checks, persistentMolCheck)
// Don't fail overall check for persistent mol issues, just warn
// Check 27: Expired tombstones (maintenance)
tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path))
result.Checks = append(result.Checks, tombstonesExpiredCheck)
// Don't fail overall check for expired tombstones, just warn
// Check 28: Compaction candidates (maintenance)
compactionCheck := convertDoctorCheck(doctor.CheckCompactionCandidates(path))
result.Checks = append(result.Checks, compactionCheck)
// Info only, not a warning - compaction requires human review
// Check 29: Database size (pruning suggestion)
// Note: This check has no auto-fix - pruning is destructive and user-controlled
sizeCheck := convertDoctorCheck(doctor.CheckDatabaseSize(path))
result.Checks = append(result.Checks, sizeCheck)
// Don't fail overall check for size warning, just inform
// Check 30: Pending migrations (summarizes all available migrations)
migrationsCheck := convertDoctorCheck(doctor.CheckPendingMigrations(path))
result.Checks = append(result.Checks, migrationsCheck)
// Status is determined by the check itself based on migration priorities
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,
Category: dc.Category,
}
}
// convertWithCategory converts a doctor check and sets its category
func convertWithCategory(dc doctor.DoctorCheck, category string) doctorCheck {
check := convertDoctorCheck(dc)
check.Category = category
return check
}
// exportDiagnostics writes the doctor result to a JSON file
func exportDiagnostics(result doctorResult, outputPath string) error {
// #nosec G304 - outputPath is a user-provided flag value for file generation
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer f.Close()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
if err := encoder.Encode(result); err != nil {
return fmt.Errorf("failed to write JSON: %w", err)
}
return nil
}
func printDiagnostics(result doctorResult) {
// Print header with version
fmt.Printf("\nbd doctor v%s\n\n", result.CLIVersion)
// Group checks by category
checksByCategory := make(map[string][]doctorCheck)
for _, check := range result.Checks {
cat := check.Category
if cat == "" {
cat = "Other"
}
checksByCategory[cat] = append(checksByCategory[cat], check)
}
// Track counts
var passCount, warnCount, failCount int
var warnings []doctorCheck
// Print checks by category in defined order
for _, category := range doctor.CategoryOrder {
checks, exists := checksByCategory[category]
if !exists || len(checks) == 0 {
continue
}
// Print category header
fmt.Println(ui.RenderCategory(category))
// Print each check in this category
for _, check := range checks {
// Determine status icon
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ui.RenderPassIcon()
passCount++
case statusWarning:
statusIcon = ui.RenderWarnIcon()
warnCount++
warnings = append(warnings, check)
case statusError:
statusIcon = ui.RenderFailIcon()
failCount++
warnings = append(warnings, check)
}
// Print check line: icon + name + message
fmt.Printf(" %s %s", statusIcon, check.Name)
if check.Message != "" {
fmt.Printf("%s", ui.RenderMuted(" "+check.Message))
}
fmt.Println()
// Print detail if present (indented)
if check.Detail != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(check.Detail))
}
}
fmt.Println()
}
// Print any checks without a category
if otherChecks, exists := checksByCategory["Other"]; exists && len(otherChecks) > 0 {
fmt.Println(ui.RenderCategory("Other"))
for _, check := range otherChecks {
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ui.RenderPassIcon()
passCount++
case statusWarning:
statusIcon = ui.RenderWarnIcon()
warnCount++
warnings = append(warnings, check)
case statusError:
statusIcon = ui.RenderFailIcon()
failCount++
warnings = append(warnings, check)
}
fmt.Printf(" %s %s", statusIcon, check.Name)
if check.Message != "" {
fmt.Printf("%s", ui.RenderMuted(" "+check.Message))
}
fmt.Println()
if check.Detail != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(check.Detail))
}
}
fmt.Println()
}
// Print summary line
fmt.Println(ui.RenderSeparator())
summary := fmt.Sprintf("%s %d passed %s %d warnings %s %d failed",
ui.RenderPassIcon(), passCount,
ui.RenderWarnIcon(), warnCount,
ui.RenderFailIcon(), failCount,
)
fmt.Println(summary)
// Print warnings/errors section with fixes
if len(warnings) > 0 {
fmt.Println()
fmt.Println(ui.RenderWarn(ui.IconWarn + " WARNINGS"))
// Sort by severity: errors first, then warnings
slices.SortStableFunc(warnings, func(a, b doctorCheck) int {
// Errors (statusError) come before warnings (statusWarning)
if a.Status == statusError && b.Status != statusError {
return -1
}
if a.Status != statusError && b.Status == statusError {
return 1
}
return 0 // maintain original order within same severity
})
for i, check := range warnings {
// Show numbered items with icon and color based on status
// Errors get entire line in red, warnings just the number in yellow
line := fmt.Sprintf("%s: %s", check.Name, check.Message)
if check.Status == statusError {
fmt.Printf(" %s %s %s\n", ui.RenderFailIcon(), ui.RenderFail(fmt.Sprintf("%d.", i+1)), ui.RenderFail(line))
} else {
fmt.Printf(" %s %s %s\n", ui.RenderWarnIcon(), ui.RenderWarn(fmt.Sprintf("%d.", i+1)), line)
}
if check.Fix != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), check.Fix)
}
}
} else {
fmt.Println()
fmt.Printf("%s\n", ui.RenderPass("✓ All checks passed"))
}
}