feat(sync): prevent zombie resurrection from stale clones

Add JSONL sanitization after git pull to remove deleted issues that
git's 3-way merge may resurrect. Also add bd doctor check to hydrate
deletions.jsonl from git history for pre-v0.25.0 deletions.

Changes:
- Add sanitizeJSONLWithDeletions() in sync.go (Step 3.6)
- Add checkDeletionsManifest() in doctor.go (Check 18)
- Add HydrateDeletionsManifest() fix in doctor/fix/deletions.go
- Add looksLikeIssueID() validation to prevent false positives
- Add comprehensive tests for sanitization logic

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-25 18:11:45 -08:00
parent 8051cc911b
commit e4f9c3556f
4 changed files with 658 additions and 0 deletions

View File

@@ -200,6 +200,8 @@ func applyFixes(result doctorResult) {
err = fix.SyncBranchConfig(result.Path)
case "Database Config":
err = fix.DatabaseConfig(result.Path)
case "Deletions Manifest":
err = fix.HydrateDeletionsManifest(result.Path)
default:
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
fmt.Printf(" Manual fix: %s\n", check.Fix)
@@ -367,6 +369,11 @@ func runDiagnostics(path string) doctorResult {
result.Checks = append(result.Checks, syncBranchCheck)
// Don't fail overall check for missing sync.branch, just warn
// Check 18: Deletions manifest (prevents zombie resurrection)
deletionsCheck := checkDeletionsManifest(path)
result.Checks = append(result.Checks, deletionsCheck)
// Don't fail overall check for missing deletions manifest, just warn
return result
}
@@ -1840,6 +1847,89 @@ func checkSyncBranchConfig(path string) doctorCheck {
}
}
func checkDeletionsManifest(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Skip if .beads doesn't exist
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
Message: "N/A (no .beads directory)",
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
Message: "N/A (not a git repository)",
}
}
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
// Check if deletions.jsonl exists and has content
info, err := os.Stat(deletionsPath)
if err == nil && info.Size() > 0 {
// Count entries
file, err := os.Open(deletionsPath) // #nosec G304 - controlled path
if err == nil {
defer file.Close()
count := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if len(scanner.Bytes()) > 0 {
count++
}
}
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
Message: fmt.Sprintf("Present (%d entries)", count),
}
}
}
// deletions.jsonl doesn't exist or is empty
// Check if there's git history that might have deletions
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
jsonlPath = filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
Message: "N/A (no JSONL file)",
}
}
}
// Check if JSONL has any git history
relPath, _ := filepath.Rel(path, jsonlPath)
cmd := exec.Command("git", "log", "--oneline", "-1", "--", relPath)
cmd.Dir = path
if output, err := cmd.Output(); err != nil || len(output) == 0 {
// No git history for JSONL
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
Message: "Not yet created (no deletions recorded)",
}
}
// There's git history but no deletions manifest - recommend hydration
return doctorCheck{
Name: "Deletions Manifest",
Status: statusWarning,
Message: "Missing or empty (may have pre-v0.25.0 deletions)",
Detail: "Deleted issues from before v0.25.0 are not tracked and may resurrect on sync",
Fix: "Run 'bd doctor --fix' to hydrate deletions manifest from git history",
}
}
func init() {
rootCmd.AddCommand(doctorCmd)
doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile")