From cbf48672e7795e5292ee0c08401188f7fe1dc3f1 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 3 Dec 2025 00:09:25 -0800 Subject: [PATCH] feat(doctor): add --dry-run flag and fix registry parsing (bd-qn5, bd-a5z) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --dry-run flag to preview fixes without applying changes - Handle corrupted/empty/null-byte registry files gracefully - Treat corrupted registry as empty instead of failing šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/bd/doctor.go | 47 ++++++++++++++++++++++++++++++-- internal/daemon/registry.go | 17 +++++++++++- internal/daemon/registry_test.go | 43 +++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 8035f0bd..856b90fe 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -50,6 +50,7 @@ type doctorResult struct { var ( doctorFix bool doctorYes bool + doctorDryRun bool // bd-a5z: preview fixes without applying perfMode bool checkHealthMode bool ) @@ -91,6 +92,7 @@ Examples: 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 --dry-run # Preview what --fix would do without making changes bd doctor --perf # Performance diagnostics`, Run: func(cmd *cobra.Command, args []string) { // Use global jsonOutput set by PersistentPreRun @@ -123,8 +125,10 @@ Examples: // Run diagnostics result := runDiagnostics(absPath) - // Apply fixes if requested - if doctorFix { + // bd-a5z: 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) @@ -147,6 +151,45 @@ Examples: 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().BoolVar(&doctorDryRun, "dry-run", false, "Preview fixes without making changes (bd-a5z)") +} + +// previewFixes shows what would be fixed without applying changes (bd-a5z) +func previewFixes(result doctorResult) { + // Collect all fixable issues + var fixableIssues []doctorCheck + for _, check := range result.Checks { + if (check.Status == statusWarning || check.Status == statusError) && check.Fix != "" { + fixableIssues = append(fixableIssues, check) + } + } + + if len(fixableIssues) == 0 { + fmt.Println("\nāœ“ No fixable issues found (dry-run)") + return + } + + fmt.Println("\n[DRY-RUN] The following issues would be fixed with --fix:") + fmt.Println() + + for i, issue := range fixableIssues { + // Show the issue details + fmt.Printf(" %d. %s\n", i+1, issue.Name) + if issue.Status == statusError { + color.Red(" Status: ERROR\n") + } else { + color.Yellow(" Status: WARNING\n") + } + fmt.Printf(" Issue: %s\n", issue.Message) + if issue.Detail != "" { + fmt.Printf(" Detail: %s\n", issue.Detail) + } + fmt.Printf(" Fix: %s\n", issue.Fix) + fmt.Println() + } + + fmt.Printf("[DRY-RUN] Would attempt to fix %d issue(s)\n", len(fixableIssues)) + fmt.Println("Run 'bd doctor --fix' to apply these fixes") } func applyFixes(result doctorResult) { diff --git a/internal/daemon/registry.go b/internal/daemon/registry.go index fc2d6690..844bbd36 100644 --- a/internal/daemon/registry.go +++ b/internal/daemon/registry.go @@ -71,6 +71,7 @@ func (r *Registry) withFileLock(fn func() error) error { // readEntriesLocked reads all entries from the registry file. // Caller must hold the file lock. +// bd-qn5: Handles missing, empty, or corrupted registry files gracefully. func (r *Registry) readEntriesLocked() ([]RegistryEntry, error) { data, err := os.ReadFile(r.path) if err != nil { @@ -80,9 +81,23 @@ func (r *Registry) readEntriesLocked() ([]RegistryEntry, error) { return nil, fmt.Errorf("failed to read registry: %w", err) } + // bd-qn5: Handle empty file or file with only whitespace/null bytes + // This can happen if the file was created but never written to, or was corrupted + trimmed := make([]byte, 0, len(data)) + for _, b := range data { + if b != 0 && b != ' ' && b != '\t' && b != '\n' && b != '\r' { + trimmed = append(trimmed, b) + } + } + if len(trimmed) == 0 { + return []RegistryEntry{}, nil + } + var entries []RegistryEntry if err := json.Unmarshal(data, &entries); err != nil { - return nil, fmt.Errorf("failed to parse registry: %w", err) + // bd-qn5: If registry is corrupted, treat as empty rather than failing + // A corrupted registry just means we'll need to rediscover daemons + return []RegistryEntry{}, nil } return entries, nil diff --git a/internal/daemon/registry_test.go b/internal/daemon/registry_test.go index b7b613cb..7a78a51f 100644 --- a/internal/daemon/registry_test.go +++ b/internal/daemon/registry_test.go @@ -240,13 +240,46 @@ func TestRegistryCorruptedFile(t *testing.T) { os.MkdirAll(filepath.Dir(registryPath), 0755) os.WriteFile(registryPath, []byte("invalid json{{{"), 0644) - // Reading should return an error + // bd-qn5: Corrupted registry should be treated as empty, not an error + // This allows bd doctor to work gracefully when registry is corrupted entries, err := registry.readEntries() - if err == nil { - t.Error("Expected error when reading corrupted registry") + if err != nil { + t.Errorf("Expected corrupted registry to be treated as empty, got error: %v", err) } - if entries != nil { - t.Errorf("Expected nil entries on error, got %v", entries) + if len(entries) != 0 { + t.Errorf("Expected empty entries for corrupted registry, got %v", entries) + } +} + +// bd-qn5: Test for the specific null bytes case that was reported +func TestRegistryNullBytesFile(t *testing.T) { + tmpDir := t.TempDir() + registryPath := filepath.Join(tmpDir, ".beads", "registry.json") + + homeEnv := "HOME" + if runtime.GOOS == "windows" { + homeEnv = "USERPROFILE" + } + oldHome := os.Getenv(homeEnv) + os.Setenv(homeEnv, tmpDir) + defer os.Setenv(homeEnv, oldHome) + + registry, err := NewRegistry() + if err != nil { + t.Fatalf("Failed to create registry: %v", err) + } + + // Create a file with null bytes (the reported error case) + os.MkdirAll(filepath.Dir(registryPath), 0755) + os.WriteFile(registryPath, []byte{0, 0, 0, 0}, 0644) + + // Should treat as empty, not error + entries, err := registry.readEntries() + if err != nil { + t.Errorf("Expected null-byte registry to be treated as empty, got error: %v", err) + } + if len(entries) != 0 { + t.Errorf("Expected empty entries for null-byte registry, got %v", entries) } }