fix: Handle .beads/redirect files and limit verbose output in bd doctor --fix
- Add resolveBeadsDir helper to fix/common.go to follow redirect files - Update OrphanedDependencies, ChildParentDependencies, and MergeArtifacts to use resolveBeadsDir instead of hardcoded .beads path - Add --verbose/-v flag to bd doctor command - Only print individual items if verbose or count < 20, always show summary (bd-dq74, bd-v55y) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ var (
|
|||||||
doctorDryRun bool // preview fixes without applying
|
doctorDryRun bool // preview fixes without applying
|
||||||
doctorOutput string // export diagnostics to file
|
doctorOutput string // export diagnostics to file
|
||||||
doctorFixChildParent bool // opt-in fix for child→parent deps
|
doctorFixChildParent bool // opt-in fix for child→parent deps
|
||||||
|
doctorVerbose bool // show detailed output during fixes
|
||||||
perfMode bool
|
perfMode bool
|
||||||
checkHealthMode bool
|
checkHealthMode bool
|
||||||
doctorCheckFlag string // run specific check (e.g., "pollution")
|
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().BoolVarP(&doctorInteractive, "interactive", "i", false, "Confirm each fix individually")
|
||||||
doctorCmd.Flags().BoolVar(&doctorDryRun, "dry-run", false, "Preview fixes without making changes")
|
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().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 {
|
func runDiagnostics(path string) doctorResult {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrTestBinary is returned when getBdBinary detects it's running as a test binary.
|
// 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)))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func MergeArtifacts(path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
beadsDir := filepath.Join(path, ".beads")
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
|
|
||||||
// Read patterns from .gitignore or use defaults
|
// Read patterns from .gitignore or use defaults
|
||||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||||
@@ -99,12 +99,13 @@ func readMergeArtifactPatterns(beadsDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OrphanedDependencies removes dependencies pointing to non-existent issues.
|
// 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 {
|
if err := validateBeadsWorkspace(path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
beadsDir := filepath.Join(path, ".beads")
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
|
||||||
// Open database
|
// Open database
|
||||||
@@ -146,6 +147,9 @@ func OrphanedDependencies(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete orphaned dependencies
|
// 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 {
|
for _, o := range orphans {
|
||||||
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
||||||
o.issueID, o.dependsOnID)
|
o.issueID, o.dependsOnID)
|
||||||
@@ -154,23 +158,27 @@ func OrphanedDependencies(path string) error {
|
|||||||
} else {
|
} else {
|
||||||
// Mark issue as dirty for export
|
// Mark issue as dirty for export
|
||||||
_, _ = db.Exec("INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
|
_, _ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChildParentDependencies removes child→parent blocking dependencies.
|
// ChildParentDependencies removes child→parent blocking dependencies.
|
||||||
// These often indicate a modeling mistake (deadlock: child waits for parent, parent waits for children).
|
// 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.
|
// 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 {
|
if err := validateBeadsWorkspace(path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
beadsDir := filepath.Join(path, ".beads")
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
|
||||||
// Open database
|
// Open database
|
||||||
@@ -215,6 +223,9 @@ func ChildParentDependencies(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete child→parent blocking dependencies (preserving parent-child type)
|
// 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 {
|
for _, d := range badDeps {
|
||||||
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ? AND type = ?",
|
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ? AND type = ?",
|
||||||
d.issueID, d.dependsOnID, d.depType)
|
d.issueID, d.dependsOnID, d.depType)
|
||||||
@@ -223,11 +234,14 @@ func ChildParentDependencies(path string) error {
|
|||||||
} else {
|
} else {
|
||||||
// Mark issue as dirty for export
|
// Mark issue as dirty for export
|
||||||
_, _ = db.Exec("INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", d.issueID)
|
_, _ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ func TestFixFunctions_RequireBeadsDir(t *testing.T) {
|
|||||||
{"SyncBranchHealth", func(dir string) error { return SyncBranchHealth(dir, "beads-sync") }},
|
{"SyncBranchHealth", func(dir string) error { return SyncBranchHealth(dir, "beads-sync") }},
|
||||||
{"UntrackedJSONL", UntrackedJSONL},
|
{"UntrackedJSONL", UntrackedJSONL},
|
||||||
{"MigrateTombstones", MigrateTombstones},
|
{"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 {
|
for _, tc := range funcs {
|
||||||
@@ -70,7 +71,7 @@ func TestChildParentDependencies_NoBadDeps(t *testing.T) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
// Run fix - should find no bad deps
|
// Run fix - should find no bad deps
|
||||||
err = ChildParentDependencies(dir)
|
err = ChildParentDependencies(dir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ChildParentDependencies failed: %v", err)
|
t.Errorf("ChildParentDependencies failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ func TestChildParentDependencies_FixesBadDeps(t *testing.T) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
// Run fix
|
// Run fix
|
||||||
err = ChildParentDependencies(dir)
|
err = ChildParentDependencies(dir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ChildParentDependencies failed: %v", err)
|
t.Errorf("ChildParentDependencies failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@ func TestChildParentDependencies_PreservesParentChildType(t *testing.T) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
// Run fix
|
// Run fix
|
||||||
err = ChildParentDependencies(dir)
|
err = ChildParentDependencies(dir, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ChildParentDependencies failed: %v", err)
|
t.Fatalf("ChildParentDependencies failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,14 +269,14 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
case "Merge Artifacts":
|
case "Merge Artifacts":
|
||||||
err = fix.MergeArtifacts(path)
|
err = fix.MergeArtifacts(path)
|
||||||
case "Orphaned Dependencies":
|
case "Orphaned Dependencies":
|
||||||
err = fix.OrphanedDependencies(path)
|
err = fix.OrphanedDependencies(path, doctorVerbose)
|
||||||
case "Child-Parent Dependencies":
|
case "Child-Parent Dependencies":
|
||||||
// Requires explicit opt-in flag (destructive, may remove intentional deps)
|
// Requires explicit opt-in flag (destructive, may remove intentional deps)
|
||||||
if !doctorFixChildParent {
|
if !doctorFixChildParent {
|
||||||
fmt.Printf(" ⚠ Child→parent deps require explicit opt-in: bd doctor --fix --fix-child-parent\n")
|
fmt.Printf(" ⚠ Child→parent deps require explicit opt-in: bd doctor --fix --fix-child-parent\n")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = fix.ChildParentDependencies(path)
|
err = fix.ChildParentDependencies(path, doctorVerbose)
|
||||||
case "Duplicate Issues":
|
case "Duplicate Issues":
|
||||||
// No auto-fix: duplicates require user review
|
// No auto-fix: duplicates require user review
|
||||||
fmt.Printf(" ⚠ Run 'bd duplicates' to review and merge duplicates\n")
|
fmt.Printf(" ⚠ Run 'bd duplicates' to review and merge duplicates\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user