Files
beads/cmd/bd/doctor_fix.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

333 lines
9.6 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"slices"
"strings"
"github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/ui"
)
// previewFixes shows what would be fixed without applying changes
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 {
fmt.Printf(" Status: %s\n", ui.RenderFail("ERROR"))
} else {
fmt.Printf(" Status: %s\n", ui.RenderWarn("WARNING"))
}
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) {
// 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("\nNo fixable issues found.")
return
}
// Show what will be fixed
fmt.Println("\nFixable issues:")
for i, issue := range fixableIssues {
fmt.Printf(" %d. %s: %s\n", i+1, issue.Name, issue.Message)
}
// Interactive mode - confirm each fix individually
if doctorInteractive {
applyFixesInteractive(result.Path, fixableIssues)
return
}
// Ask for confirmation (skip if --yes flag is set)
if !doctorYes {
fmt.Printf("\nThis will attempt to fix %d issue(s). Continue? (Y/n): ", len(fixableIssues))
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "" && response != "y" && response != "yes" {
fmt.Println("Fix canceled.")
return
}
}
// Apply fixes
fmt.Println("\nApplying fixes...")
applyFixList(result.Path, fixableIssues)
}
// applyFixesInteractive prompts for each fix individually
func applyFixesInteractive(path string, issues []doctorCheck) {
reader := bufio.NewReader(os.Stdin)
applyAll := false
var approvedFixes []doctorCheck
fmt.Println("\nReview each fix:")
fmt.Println(" [y]es - apply this fix")
fmt.Println(" [n]o - skip this fix")
fmt.Println(" [a]ll - apply all remaining fixes")
fmt.Println(" [q]uit - stop without applying more fixes")
fmt.Println()
for i, issue := range issues {
// Show issue details
fmt.Printf("(%d/%d) %s\n", i+1, len(issues), issue.Name)
if issue.Status == statusError {
fmt.Printf(" Status: %s\n", ui.RenderFail("ERROR"))
} else {
fmt.Printf(" Status: %s\n", ui.RenderWarn("WARNING"))
}
fmt.Printf(" Issue: %s\n", issue.Message)
if issue.Detail != "" {
fmt.Printf(" Detail: %s\n", issue.Detail)
}
fmt.Printf(" Fix: %s\n", issue.Fix)
// Check if we should apply all remaining
if applyAll {
fmt.Println(" → Auto-approved (apply all)")
approvedFixes = append(approvedFixes, issue)
continue
}
// Prompt for this fix
fmt.Print("\n Apply this fix? [y/n/a/q]: ")
response, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return
}
response = strings.TrimSpace(strings.ToLower(response))
switch response {
case "y", "yes":
approvedFixes = append(approvedFixes, issue)
fmt.Println(" → Approved")
case "n", "no", "":
fmt.Println(" → Skipped")
case "a", "all":
applyAll = true
approvedFixes = append(approvedFixes, issue)
fmt.Println(" → Approved (applying all remaining)")
case "q", "quit":
fmt.Println(" → Quit")
if len(approvedFixes) > 0 {
fmt.Printf("\nApplying %d approved fix(es)...\n", len(approvedFixes))
applyFixList(path, approvedFixes)
} else {
fmt.Println("\nNo fixes applied.")
}
return
default:
// Treat unknown input as skip
fmt.Println(" → Skipped (unrecognized input)")
}
fmt.Println()
}
// Apply all approved fixes
if len(approvedFixes) > 0 {
fmt.Printf("\nApplying %d approved fix(es)...\n", len(approvedFixes))
applyFixList(path, approvedFixes)
} else {
fmt.Println("\nNo fixes approved.")
}
}
// applyFixList applies a list of fixes and reports results
func applyFixList(path string, fixes []doctorCheck) {
// Apply fixes in a dependency-aware order.
// Rough dependency chain:
// permissions/daemon cleanup → config sanity → DB integrity/migrations → DB↔JSONL sync.
order := []string{
"Permissions",
"Daemon Health",
"Database Config",
"JSONL Config",
"Database Integrity",
"Database",
"Schema Compatibility",
"JSONL Integrity",
"DB-JSONL Sync",
}
priority := make(map[string]int, len(order))
for i, name := range order {
priority[name] = i
}
slices.SortStableFunc(fixes, func(a, b doctorCheck) int {
pa, oka := priority[a.Name]
if !oka {
pa = 1000
}
pb, okb := priority[b.Name]
if !okb {
pb = 1000
}
if pa < pb {
return -1
}
if pa > pb {
return 1
}
return 0
})
fixedCount := 0
errorCount := 0
for _, check := range fixes {
fmt.Printf("\nFixing %s...\n", check.Name)
var err error
switch check.Name {
case "Gitignore":
err = doctor.FixGitignore()
case "Redirect Tracking":
err = doctor.FixRedirectTracking()
case "Git Hooks":
err = fix.GitHooks(path)
case "Daemon Health":
err = fix.Daemon(path)
case "DB-JSONL Sync":
err = fix.DBJSONLSync(path)
case "Permissions":
err = fix.Permissions(path)
case "Database":
err = fix.DatabaseVersion(path)
case "Database Integrity":
// Corruption detected - try recovery from JSONL
// Pass force and source flags for enhanced recovery
err = fix.DatabaseCorruptionRecoveryWithOptions(path, doctorForce, doctorSource)
case "Schema Compatibility":
err = fix.SchemaCompatibility(path)
case "Repo Fingerprint":
err = fix.RepoFingerprint(path)
case "Git Merge Driver":
err = fix.MergeDriver(path)
case "Sync Branch Config":
// No auto-fix: sync-branch should be added to config.yaml (version controlled)
fmt.Printf(" ⚠ Add 'sync-branch: beads-sync' to .beads/config.yaml\n")
continue
case "Sync Branch Gitignore":
err = doctor.FixSyncBranchGitignore()
case "Database Config":
err = fix.DatabaseConfig(path)
case "JSONL Config":
err = fix.LegacyJSONLConfig(path)
case "JSONL Integrity":
err = fix.JSONLIntegrity(path)
case "Deletions Manifest":
err = fix.MigrateTombstones(path)
case "Untracked Files":
err = fix.UntrackedJSONL(path)
case "Sync Branch Health":
// Get sync branch from config
syncBranch := syncbranch.GetFromYAML()
if syncBranch == "" {
fmt.Printf(" ⚠ No sync branch configured in config.yaml\n")
continue
}
err = fix.SyncBranchHealth(path, syncBranch)
case "Merge Artifacts":
err = fix.MergeArtifacts(path)
case "Orphaned Dependencies":
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, doctorVerbose)
case "Duplicate Issues":
// No auto-fix: duplicates require user review
fmt.Printf(" ⚠ Run 'bd duplicates' to review and merge duplicates\n")
continue
case "Test Pollution":
// No auto-fix: test cleanup requires user review
fmt.Printf(" ⚠ Run 'bd doctor --check=pollution' to review and clean test issues\n")
continue
case "Git Conflicts":
// No auto-fix: git conflicts require manual resolution
fmt.Printf(" ⚠ Resolve conflicts manually: git checkout --ours or --theirs .beads/issues.jsonl\n")
continue
case "Stale Closed Issues":
// consolidate cleanup into doctor --fix
err = fix.StaleClosedIssues(path)
case "Expired Tombstones":
// consolidate cleanup into doctor --fix
err = fix.ExpiredTombstones(path)
case "Compaction Candidates":
// No auto-fix: compaction requires agent review
fmt.Printf(" ⚠ Run 'bd compact --analyze' to review candidates\n")
continue
case "Large Database":
// No auto-fix: pruning deletes data, must be user-controlled
fmt.Printf(" ⚠ Run 'bd cleanup --older-than 90' to prune old closed issues\n")
continue
default:
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
fmt.Printf(" Manual fix: %s\n", check.Fix)
continue
}
if err != nil {
errorCount++
fmt.Printf(" %s Error: %v\n", ui.RenderFail("✗"), err)
fmt.Printf(" Manual fix: %s\n", check.Fix)
} else {
fixedCount++
fmt.Printf(" %s Fixed\n", ui.RenderPass("✓"))
}
}
// Summary
fmt.Printf("\nFix summary: %d fixed, %d errors\n", fixedCount, errorCount)
if errorCount > 0 {
fmt.Println("\nSome fixes failed. Please review the errors above and apply manual fixes as needed.")
}
}