feat(doctor): add --dry-run flag and fix registry parsing (bd-qn5, bd-a5z)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ type doctorResult struct {
|
|||||||
var (
|
var (
|
||||||
doctorFix bool
|
doctorFix bool
|
||||||
doctorYes bool
|
doctorYes bool
|
||||||
|
doctorDryRun bool // bd-a5z: preview fixes without applying
|
||||||
perfMode bool
|
perfMode bool
|
||||||
checkHealthMode bool
|
checkHealthMode bool
|
||||||
)
|
)
|
||||||
@@ -91,6 +92,7 @@ Examples:
|
|||||||
bd doctor --json # Machine-readable output
|
bd doctor --json # Machine-readable output
|
||||||
bd doctor --fix # Automatically fix issues (with confirmation)
|
bd doctor --fix # Automatically fix issues (with confirmation)
|
||||||
bd doctor --fix --yes # Automatically fix issues (no 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`,
|
bd doctor --perf # Performance diagnostics`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
// Use global jsonOutput set by PersistentPreRun
|
// Use global jsonOutput set by PersistentPreRun
|
||||||
@@ -123,8 +125,10 @@ Examples:
|
|||||||
// Run diagnostics
|
// Run diagnostics
|
||||||
result := runDiagnostics(absPath)
|
result := runDiagnostics(absPath)
|
||||||
|
|
||||||
// Apply fixes if requested
|
// bd-a5z: Preview fixes (dry-run) or apply fixes if requested
|
||||||
if doctorFix {
|
if doctorDryRun {
|
||||||
|
previewFixes(result)
|
||||||
|
} else if doctorFix {
|
||||||
applyFixes(result)
|
applyFixes(result)
|
||||||
// Re-run diagnostics to show results
|
// Re-run diagnostics to show results
|
||||||
result = runDiagnostics(absPath)
|
result = runDiagnostics(absPath)
|
||||||
@@ -147,6 +151,45 @@ Examples:
|
|||||||
func init() {
|
func init() {
|
||||||
doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Automatically fix issues where possible")
|
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(&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) {
|
func applyFixes(result doctorResult) {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func (r *Registry) withFileLock(fn func() error) error {
|
|||||||
|
|
||||||
// readEntriesLocked reads all entries from the registry file.
|
// readEntriesLocked reads all entries from the registry file.
|
||||||
// Caller must hold the file lock.
|
// Caller must hold the file lock.
|
||||||
|
// bd-qn5: Handles missing, empty, or corrupted registry files gracefully.
|
||||||
func (r *Registry) readEntriesLocked() ([]RegistryEntry, error) {
|
func (r *Registry) readEntriesLocked() ([]RegistryEntry, error) {
|
||||||
data, err := os.ReadFile(r.path)
|
data, err := os.ReadFile(r.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,9 +81,23 @@ func (r *Registry) readEntriesLocked() ([]RegistryEntry, error) {
|
|||||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
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
|
var entries []RegistryEntry
|
||||||
if err := json.Unmarshal(data, &entries); err != nil {
|
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
|
return entries, nil
|
||||||
|
|||||||
@@ -240,13 +240,46 @@ func TestRegistryCorruptedFile(t *testing.T) {
|
|||||||
os.MkdirAll(filepath.Dir(registryPath), 0755)
|
os.MkdirAll(filepath.Dir(registryPath), 0755)
|
||||||
os.WriteFile(registryPath, []byte("invalid json{{{"), 0644)
|
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()
|
entries, err := registry.readEntries()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Error("Expected error when reading corrupted registry")
|
t.Errorf("Expected corrupted registry to be treated as empty, got error: %v", err)
|
||||||
}
|
}
|
||||||
if entries != nil {
|
if len(entries) != 0 {
|
||||||
t.Errorf("Expected nil entries on error, got %v", entries)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user