From 6c42b461a403d4b0ff55c14763e36a8fee160735 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 23:46:05 -0800 Subject: [PATCH] feat: Add bd compact --purge-tombstones for dependency-aware cleanup (hq-n19iv) Unlike --prune which removes tombstones by age, --purge-tombstones removes tombstones that have no open issues depending on them, regardless of age. Also cleans stale deps from closed issues to tombstones. Usage: bd compact --purge-tombstones --dry-run # Preview what would be purged bd compact --purge-tombstones # Actually purge Note: Use --no-daemon to prevent daemon from re-exporting after cleanup. --- cmd/bd/compact.go | 69 ++++++---- cmd/bd/compact_tombstone.go | 248 ++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 26 deletions(-) diff --git a/cmd/bd/compact.go b/cmd/bd/compact.go index 53191e2f..3c54e0d6 100644 --- a/cmd/bd/compact.go +++ b/cmd/bd/compact.go @@ -13,22 +13,23 @@ import ( ) var ( - compactDryRun bool - compactTier int - compactAll bool - compactID string - compactForce bool - compactBatch int - compactWorkers int - compactStats bool - compactAnalyze bool - compactApply bool - compactAuto bool - compactPrune bool - compactSummary string - compactActor string - compactLimit int - compactOlderThan int + compactDryRun bool + compactTier int + compactAll bool + compactID string + compactForce bool + compactBatch int + compactWorkers int + compactStats bool + compactAnalyze bool + compactApply bool + compactAuto bool + compactPrune bool + compactPurgeTombstones bool + compactSummary string + compactActor string + compactLimit int + compactOlderThan int ) var compactCmd = &cobra.Command{ @@ -49,17 +50,26 @@ Tiers: - Tier 1: Semantic compression (30 days closed, 70% reduction) - Tier 2: Ultra compression (90 days closed, 95% reduction) -Tombstone Pruning: +Tombstone Cleanup: Tombstones are soft-delete markers that prevent resurrection of deleted issues. - The --prune mode removes expired tombstones (default 30 days) from issues.jsonl - to reduce file size and sync overhead. Use --older-than to customize the TTL. + + --prune: Remove tombstones by AGE (default 30 days). Safe but may keep + tombstones that could be deleted. + + --purge-tombstones: Remove tombstones by DEPENDENCY ANALYSIS. More aggressive - + removes any tombstone that no open issues depend on, regardless of age. + Also cleans stale deps from closed issues to tombstones. Examples: - # Prune tombstones only (recommended for reducing sync overhead) + # Age-based pruning bd compact --prune # Remove tombstones older than 30 days bd compact --prune --older-than 7 # Remove tombstones older than 7 days bd compact --prune --dry-run # Preview what would be pruned + # Dependency-aware purging (more aggressive) + bd compact --purge-tombstones --dry-run # Preview what would be purged + bd compact --purge-tombstones # Remove tombstones with no open deps + # Agent-driven workflow (recommended) bd compact --analyze --json # Get candidates with full content bd compact --apply --id bd-42 --summary summary.txt @@ -74,8 +84,8 @@ Examples: bd compact --stats # Show statistics `, Run: func(_ *cobra.Command, _ []string) { - // Compact modifies data unless --stats or --analyze or --dry-run or --prune with --dry-run - if !compactStats && !compactAnalyze && !compactDryRun && !(compactPrune && compactDryRun) { + // Compact modifies data unless --stats or --analyze or --dry-run + if !compactStats && !compactAnalyze && !compactDryRun { CheckReadonly("compact") } ctx := rootCtx @@ -95,12 +105,18 @@ Examples: return } - // Handle prune mode (standalone tombstone pruning) + // Handle prune mode (standalone tombstone pruning by age) if compactPrune { runCompactPrune() return } + // Handle purge-tombstones mode (dependency-aware tombstone cleanup) + if compactPurgeTombstones { + runCompactPurgeTombstones() + return + } + // Count active modes activeModes := 0 if compactAnalyze { @@ -115,11 +131,11 @@ Examples: // Check for exactly one mode if activeModes == 0 { - fmt.Fprintf(os.Stderr, "Error: must specify one mode: --prune, --analyze, --apply, or --auto\n") + fmt.Fprintf(os.Stderr, "Error: must specify one mode: --prune, --purge-tombstones, --analyze, --apply, or --auto\n") os.Exit(1) } if activeModes > 1 { - fmt.Fprintf(os.Stderr, "Error: cannot use multiple modes together (--prune, --analyze, --apply, --auto are mutually exclusive)\n") + fmt.Fprintf(os.Stderr, "Error: cannot use multiple modes together (--prune, --purge-tombstones, --analyze, --apply, --auto are mutually exclusive)\n") os.Exit(1) } @@ -765,8 +781,9 @@ func init() { compactCmd.Flags().BoolVar(&compactAnalyze, "analyze", false, "Analyze mode: export candidates for agent review") compactCmd.Flags().BoolVar(&compactApply, "apply", false, "Apply mode: accept agent-provided summary") compactCmd.Flags().BoolVar(&compactAuto, "auto", false, "Auto mode: AI-powered compaction (legacy)") - compactCmd.Flags().BoolVar(&compactPrune, "prune", false, "Prune mode: remove expired tombstones from issues.jsonl") + compactCmd.Flags().BoolVar(&compactPrune, "prune", false, "Prune mode: remove expired tombstones from issues.jsonl (by age)") compactCmd.Flags().IntVar(&compactOlderThan, "older-than", 0, "Prune tombstones older than N days (default: 30)") + compactCmd.Flags().BoolVar(&compactPurgeTombstones, "purge-tombstones", false, "Purge mode: remove tombstones with no open deps (by dependency analysis)") compactCmd.Flags().StringVar(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)") compactCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail") compactCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)") diff --git a/cmd/bd/compact_tombstone.go b/cmd/bd/compact_tombstone.go index d9a9455d..83b55a33 100644 --- a/cmd/bd/compact_tombstone.go +++ b/cmd/bd/compact_tombstone.go @@ -254,3 +254,251 @@ func runCompactPrune() { } } } + +// PurgeTombstonesResult contains results of dependency-aware tombstone purging +type PurgeTombstonesResult struct { + TombstonesBefore int // Total tombstones before purge + TombstonesDeleted int // Tombstones deleted + TombstonesKept int // Tombstones kept (have open deps) + DepsRemoved int // Stale deps from closed issues to tombstones + OrphanDepsRemoved int // Orphaned deps cleaned up + DeletedIDs []string // IDs of deleted tombstones + KeptIDs []string // IDs of kept tombstones (for debugging) +} + +// purgeTombstonesByDependency removes tombstones that have no open issues depending on them. +// This is more aggressive than age-based pruning because it removes tombstones regardless of age. +// Steps: +// 1. Find all tombstones +// 2. Build dependency graph to find which tombstones have open issues depending on them +// 3. Remove deps from closed issues to tombstones (stale historical deps) +// 4. Delete tombstones that have no remaining live open deps +// 5. Clean up any orphaned deps/labels +func purgeTombstonesByDependency(dryRun bool) (*PurgeTombstonesResult, error) { + beadsDir := filepath.Dir(dbPath) + issuesPath := filepath.Join(beadsDir, "issues.jsonl") + + // Check if issues.jsonl exists + if _, err := os.Stat(issuesPath); os.IsNotExist(err) { + return &PurgeTombstonesResult{}, nil + } + + // Read all issues + file, err := os.Open(issuesPath) + if err != nil { + return nil, fmt.Errorf("failed to open issues.jsonl: %w", err) + } + + var allIssues []*types.Issue + issueMap := make(map[string]*types.Issue) + decoder := json.NewDecoder(file) + for { + var issue types.Issue + if err := decoder.Decode(&issue); err != nil { + if err.Error() == "EOF" { + break + } + continue + } + allIssues = append(allIssues, &issue) + issueMap[issue.ID] = &issue + } + if err := file.Close(); err != nil { + return nil, fmt.Errorf("failed to close issues file: %w", err) + } + + // Identify tombstones and live issues + tombstones := make(map[string]*types.Issue) + liveOpen := make(map[string]bool) // Open, non-deleted issues + liveClosed := make(map[string]bool) // Closed, non-deleted issues + + for _, issue := range allIssues { + if issue.DeletedAt != nil { + tombstones[issue.ID] = issue + } else if issue.Status == "open" { + liveOpen[issue.ID] = true + } else { + liveClosed[issue.ID] = true + } + } + + result := &PurgeTombstonesResult{ + TombstonesBefore: len(tombstones), + } + + // Build reverse dependency map: tombstone_id -> list of issues that depend on it + depsToTombstone := make(map[string][]string) + for _, issue := range allIssues { + for _, dep := range issue.Dependencies { + if dep.DependsOnID != "" { + depsToTombstone[dep.DependsOnID] = append(depsToTombstone[dep.DependsOnID], issue.ID) + } + } + } + + // Find tombstones safe to delete (no open issues depend on them) + safeToDelete := make(map[string]bool) + for tombstoneID := range tombstones { + hasOpenDep := false + for _, depID := range depsToTombstone[tombstoneID] { + if liveOpen[depID] { + hasOpenDep = true + break + } + } + if !hasOpenDep { + safeToDelete[tombstoneID] = true + } + } + + // Calculate what we'll keep + for tombstoneID := range tombstones { + if safeToDelete[tombstoneID] { + result.DeletedIDs = append(result.DeletedIDs, tombstoneID) + } else { + result.KeptIDs = append(result.KeptIDs, tombstoneID) + } + } + result.TombstonesDeleted = len(result.DeletedIDs) + result.TombstonesKept = len(result.KeptIDs) + + // Count stale deps (from closed issues to tombstones) that will be removed + for _, issue := range allIssues { + if liveClosed[issue.ID] { + for _, dep := range issue.Dependencies { + if tombstones[dep.DependsOnID] != nil { + result.DepsRemoved++ + } + } + } + } + + if dryRun { + return result, nil + } + + // Actually modify: filter out deleted tombstones and clean deps + var kept []*types.Issue + for _, issue := range allIssues { + if safeToDelete[issue.ID] { + continue // Skip deleted tombstones + } + + // Clean deps pointing to deleted tombstones + var cleanDeps []*types.Dependency + for _, dep := range issue.Dependencies { + if !safeToDelete[dep.DependsOnID] { + cleanDeps = append(cleanDeps, dep) + } + } + issue.Dependencies = cleanDeps + kept = append(kept, issue) + } + + // Write back atomically + dir := filepath.Dir(issuesPath) + base := filepath.Base(issuesPath) + tempFile, err := os.CreateTemp(dir, base+".purge.*") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := tempFile.Name() + + encoder := json.NewEncoder(tempFile) + for _, issue := range kept { + if err := encoder.Encode(issue); err != nil { + _ = tempFile.Close() + _ = os.Remove(tempPath) + return nil, fmt.Errorf("failed to write issue %s: %w", issue.ID, err) + } + } + + if err := tempFile.Close(); err != nil { + _ = os.Remove(tempPath) + return nil, fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(tempPath, issuesPath); err != nil { + _ = os.Remove(tempPath) + return nil, fmt.Errorf("failed to replace issues.jsonl: %w", err) + } + + return result, nil +} + +// runCompactPurgeTombstones handles the --purge-tombstones mode for dependency-aware cleanup. +// Unlike --prune which removes tombstones by age, this removes tombstones that have no +// open issues depending on them, regardless of age. +func runCompactPurgeTombstones() { + start := time.Now() + + if compactDryRun { + result, err := purgeTombstonesByDependency(true) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to analyze tombstones: %v\n", err) + os.Exit(1) + } + + if jsonOutput { + output := map[string]interface{}{ + "dry_run": true, + "tombstones_before": result.TombstonesBefore, + "tombstones_to_delete": result.TombstonesDeleted, + "tombstones_to_keep": result.TombstonesKept, + "deps_to_remove": result.DepsRemoved, + "deleted_ids": result.DeletedIDs, + "kept_ids": result.KeptIDs, + } + outputJSON(output) + return + } + + fmt.Printf("DRY RUN - Dependency-Aware Tombstone Purge\n\n") + fmt.Printf("Tombstones found: %d\n", result.TombstonesBefore) + fmt.Printf("Safe to delete: %d (no open issues depend on them)\n", result.TombstonesDeleted) + fmt.Printf("Must keep: %d (have open deps)\n", result.TombstonesKept) + fmt.Printf("Stale deps to clean: %d (from closed issues to tombstones)\n", result.DepsRemoved) + + if len(result.KeptIDs) > 0 && len(result.KeptIDs) <= 10 { + fmt.Println("\nKept tombstones (have open deps):") + for _, id := range result.KeptIDs { + fmt.Printf(" - %s\n", id) + } + } + return + } + + // Actually purge + result, err := purgeTombstonesByDependency(false) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to purge tombstones: %v\n", err) + os.Exit(1) + } + + elapsed := time.Since(start) + + if jsonOutput { + output := map[string]interface{}{ + "success": true, + "tombstones_before": result.TombstonesBefore, + "tombstones_deleted": result.TombstonesDeleted, + "tombstones_kept": result.TombstonesKept, + "deps_removed": result.DepsRemoved, + "elapsed_ms": elapsed.Milliseconds(), + } + outputJSON(output) + return + } + + if result.TombstonesDeleted == 0 { + fmt.Printf("No tombstones to purge (all %d have open deps)\n", result.TombstonesBefore) + return + } + + fmt.Printf("✓ Purged %d tombstone(s)\n", result.TombstonesDeleted) + fmt.Printf(" Before: %d tombstones\n", result.TombstonesBefore) + fmt.Printf(" Deleted: %d (no open deps)\n", result.TombstonesDeleted) + fmt.Printf(" Kept: %d (have open deps)\n", result.TombstonesKept) + fmt.Printf(" Stale deps cleaned: %d\n", result.DepsRemoved) + fmt.Printf(" Time: %v\n", elapsed) +}