diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 258d3f41..49ea34df 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -47,6 +47,7 @@ var ( 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 perfMode bool checkHealthMode bool doctorCheckFlag string // run specific check (e.g., "pollution") @@ -217,6 +218,7 @@ func init() { 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)") } func runDiagnostics(path string) doctorResult { diff --git a/cmd/bd/doctor/fix/common.go b/cmd/bd/doctor/fix/common.go index 771f38f2..09517c25 100644 --- a/cmd/bd/doctor/fix/common.go +++ b/cmd/bd/doctor/fix/common.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/steveyegge/beads/internal/beads" ) // ErrTestBinary is returned when getBdBinary detects it's running as a test binary. @@ -108,3 +110,43 @@ func isWithinWorkspace(root, candidate string) bool { } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))) } + +// resolveBeadsDir follows .beads/redirect files to find the actual beads directory. +// If no redirect exists, returns the original path unchanged. +func resolveBeadsDir(beadsDir string) string { + redirectFile := filepath.Join(beadsDir, beads.RedirectFileName) + data, err := os.ReadFile(redirectFile) //nolint:gosec // redirect file path is constructed from known beadsDir + if err != nil { + // No redirect file - use original path + return beadsDir + } + + // Parse the redirect target + target := strings.TrimSpace(string(data)) + if target == "" { + return beadsDir + } + + // Skip comments + lines := strings.Split(target, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + target = line + break + } + } + + // Resolve relative paths from the parent of the .beads directory + if !filepath.IsAbs(target) { + projectRoot := filepath.Dir(beadsDir) + target = filepath.Join(projectRoot, target) + } + + // Verify the target exists + if info, err := os.Stat(target); err != nil || !info.IsDir() { + return beadsDir + } + + return target +} diff --git a/cmd/bd/doctor/fix/validation.go b/cmd/bd/doctor/fix/validation.go index 297f8cea..ed7cc513 100644 --- a/cmd/bd/doctor/fix/validation.go +++ b/cmd/bd/doctor/fix/validation.go @@ -18,7 +18,7 @@ func MergeArtifacts(path string) error { return err } - beadsDir := filepath.Join(path, ".beads") + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) // Read patterns from .gitignore or use defaults patterns, err := readMergeArtifactPatterns(beadsDir) @@ -99,12 +99,13 @@ func readMergeArtifactPatterns(beadsDir string) ([]string, error) { } // OrphanedDependencies removes dependencies pointing to non-existent issues. -func OrphanedDependencies(path string) error { +// If verbose is true, prints each removed dependency; otherwise shows only summary. +func OrphanedDependencies(path string, verbose bool) error { if err := validateBeadsWorkspace(path); err != nil { return err } - beadsDir := filepath.Join(path, ".beads") + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) dbPath := filepath.Join(beadsDir, "beads.db") // Open database @@ -146,6 +147,9 @@ func OrphanedDependencies(path string) error { } // Delete orphaned dependencies + // Show individual items if verbose or count is small (<20) + showIndividual := verbose || len(orphans) < 20 + var removed int for _, o := range orphans { _, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?", o.issueID, o.dependsOnID) @@ -154,23 +158,27 @@ func OrphanedDependencies(path string) error { } else { // Mark issue as dirty for export _, _ = db.Exec("INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID) - fmt.Printf(" Removed orphaned dependency: %s→%s\n", o.issueID, o.dependsOnID) + removed++ + if showIndividual { + fmt.Printf(" Removed orphaned dependency: %s→%s\n", o.issueID, o.dependsOnID) + } } } - fmt.Printf(" Fixed %d orphaned dependency reference(s)\n", len(orphans)) + fmt.Printf(" Fixed %d orphaned dependency reference(s)\n", removed) return nil } // ChildParentDependencies removes child→parent blocking dependencies. // These often indicate a modeling mistake (deadlock: child waits for parent, parent waits for children). // Requires explicit opt-in via --fix-child-parent flag since some workflows may use these intentionally. -func ChildParentDependencies(path string) error { +// If verbose is true, prints each removed dependency; otherwise shows only summary. +func ChildParentDependencies(path string, verbose bool) error { if err := validateBeadsWorkspace(path); err != nil { return err } - beadsDir := filepath.Join(path, ".beads") + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) dbPath := filepath.Join(beadsDir, "beads.db") // Open database @@ -215,6 +223,9 @@ func ChildParentDependencies(path string) error { } // Delete child→parent blocking dependencies (preserving parent-child type) + // Show individual items if verbose or count is small (<20) + showIndividual := verbose || len(badDeps) < 20 + var removed int for _, d := range badDeps { _, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ? AND type = ?", d.issueID, d.dependsOnID, d.depType) @@ -223,11 +234,14 @@ func ChildParentDependencies(path string) error { } else { // Mark issue as dirty for export _, _ = db.Exec("INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", d.issueID) - fmt.Printf(" Removed child→parent dependency: %s→%s\n", d.issueID, d.dependsOnID) + removed++ + if showIndividual { + fmt.Printf(" Removed child→parent dependency: %s→%s\n", d.issueID, d.dependsOnID) + } } } - fmt.Printf(" Fixed %d child→parent dependency anti-pattern(s)\n", len(badDeps)) + fmt.Printf(" Fixed %d child→parent dependency anti-pattern(s)\n", removed) return nil } diff --git a/cmd/bd/doctor/fix/validation_test.go b/cmd/bd/doctor/fix/validation_test.go index a95bf510..b43a9b03 100644 --- a/cmd/bd/doctor/fix/validation_test.go +++ b/cmd/bd/doctor/fix/validation_test.go @@ -27,7 +27,8 @@ func TestFixFunctions_RequireBeadsDir(t *testing.T) { {"SyncBranchHealth", func(dir string) error { return SyncBranchHealth(dir, "beads-sync") }}, {"UntrackedJSONL", UntrackedJSONL}, {"MigrateTombstones", MigrateTombstones}, - {"ChildParentDependencies", ChildParentDependencies}, + {"ChildParentDependencies", func(dir string) error { return ChildParentDependencies(dir, false) }}, + {"OrphanedDependencies", func(dir string) error { return OrphanedDependencies(dir, false) }}, } for _, tc := range funcs { @@ -70,7 +71,7 @@ func TestChildParentDependencies_NoBadDeps(t *testing.T) { db.Close() // Run fix - should find no bad deps - err = ChildParentDependencies(dir) + err = ChildParentDependencies(dir, false) if err != nil { t.Errorf("ChildParentDependencies failed: %v", err) } @@ -116,7 +117,7 @@ func TestChildParentDependencies_FixesBadDeps(t *testing.T) { db.Close() // Run fix - err = ChildParentDependencies(dir) + err = ChildParentDependencies(dir, false) if err != nil { t.Errorf("ChildParentDependencies failed: %v", err) } @@ -173,7 +174,7 @@ func TestChildParentDependencies_PreservesParentChildType(t *testing.T) { db.Close() // Run fix - err = ChildParentDependencies(dir) + err = ChildParentDependencies(dir, false) if err != nil { t.Fatalf("ChildParentDependencies failed: %v", err) } diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index 4e8abae8..c5cc44ac 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -269,14 +269,14 @@ func applyFixList(path string, fixes []doctorCheck) { case "Merge Artifacts": err = fix.MergeArtifacts(path) case "Orphaned Dependencies": - err = fix.OrphanedDependencies(path) + err = fix.OrphanedDependencies(path, doctorVerbose) case "Child-Parent Dependencies": // Requires explicit opt-in flag (destructive, may remove intentional deps) if !doctorFixChildParent { fmt.Printf(" ⚠ Child→parent deps require explicit opt-in: bd doctor --fix --fix-child-parent\n") continue } - err = fix.ChildParentDependencies(path) + err = fix.ChildParentDependencies(path, doctorVerbose) case "Duplicate Issues": // No auto-fix: duplicates require user review fmt.Printf(" ⚠ Run 'bd duplicates' to review and merge duplicates\n")