package main import ( "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/steveyegge/beads/internal/types" ) // TombstonePruneResult contains the results of tombstone pruning type TombstonePruneResult struct { PrunedCount int PrunedIDs []string TTLDays int } // pruneExpiredTombstones reads issues.jsonl, removes expired tombstones, // and writes back the pruned file. Returns the prune result. // If customTTL is > 0, it overrides the default TTL (bypasses MinTombstoneTTL safety). // If customTTL is 0, uses DefaultTombstoneTTL. func pruneExpiredTombstones(customTTL time.Duration) (*TombstonePruneResult, 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 &TombstonePruneResult{}, nil } // Read all issues // nolint:gosec // G304: issuesPath is controlled from beadsDir file, err := os.Open(issuesPath) if err != nil { return nil, fmt.Errorf("failed to open issues.jsonl: %w", err) } var allIssues []*types.Issue decoder := json.NewDecoder(file) for { var issue types.Issue if err := decoder.Decode(&issue); err != nil { if err.Error() == "EOF" { break } // Skip corrupt lines continue } allIssues = append(allIssues, &issue) } if err := file.Close(); err != nil { return nil, fmt.Errorf("failed to close issues file: %w", err) } // Determine TTL - customTTL > 0 overrides default (for --hard mode) ttl := types.DefaultTombstoneTTL if customTTL > 0 { ttl = customTTL } ttlDays := int(ttl.Hours() / 24) // Filter out expired tombstones var kept []*types.Issue var prunedIDs []string for _, issue := range allIssues { if issue.IsExpired(ttl) { prunedIDs = append(prunedIDs, issue.ID) } else { kept = append(kept, issue) } } if len(prunedIDs) == 0 { return &TombstonePruneResult{TTLDays: ttlDays}, nil } // Write back the pruned file atomically dir := filepath.Dir(issuesPath) base := filepath.Base(issuesPath) tempFile, err := os.CreateTemp(dir, base+".prune.*") 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) } // Atomically replace if err := os.Rename(tempPath, issuesPath); err != nil { _ = os.Remove(tempPath) return nil, fmt.Errorf("failed to replace issues.jsonl: %w", err) } return &TombstonePruneResult{ PrunedCount: len(prunedIDs), PrunedIDs: prunedIDs, TTLDays: ttlDays, }, nil } // previewPruneTombstones checks what tombstones would be pruned without modifying files. // Used for dry-run mode in cleanup command. // If customTTL is > 0, it overrides the default TTL (bypasses MinTombstoneTTL safety). // If customTTL is 0, uses DefaultTombstoneTTL. func previewPruneTombstones(customTTL time.Duration) (*TombstonePruneResult, 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 &TombstonePruneResult{}, nil } // Read all issues // nolint:gosec // G304: issuesPath is controlled from beadsDir file, err := os.Open(issuesPath) if err != nil { return nil, fmt.Errorf("failed to open issues.jsonl: %w", err) } defer file.Close() var allIssues []*types.Issue decoder := json.NewDecoder(file) for { var issue types.Issue if err := decoder.Decode(&issue); err != nil { if err.Error() == "EOF" { break } // Skip corrupt lines continue } allIssues = append(allIssues, &issue) } // Determine TTL - customTTL > 0 overrides default (for --hard mode) ttl := types.DefaultTombstoneTTL if customTTL > 0 { ttl = customTTL } ttlDays := int(ttl.Hours() / 24) // Count expired tombstones var prunedIDs []string for _, issue := range allIssues { if issue.IsExpired(ttl) { prunedIDs = append(prunedIDs, issue.ID) } } return &TombstonePruneResult{ PrunedCount: len(prunedIDs), PrunedIDs: prunedIDs, TTLDays: ttlDays, }, nil } // runCompactPrune handles the --prune mode for standalone tombstone pruning. // This mode only prunes expired tombstones from issues.jsonl without doing // any semantic compaction. It's useful for reducing sync overhead. func runCompactPrune() { start := time.Now() // Calculate TTL from --older-than flag // -1 (default) = use 30 day default, 0 = expire all, >0 = N days var customTTL time.Duration if compactOlderThan >= 0 { if compactOlderThan == 0 { // --older-than=0 means "expire all tombstones" customTTL = 1 * time.Nanosecond } else { customTTL = time.Duration(compactOlderThan) * 24 * time.Hour } } if compactDryRun { // Preview mode - show what would be pruned result, err := previewPruneTombstones(customTTL) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to preview tombstones: %v\n", err) os.Exit(1) } if jsonOutput { output := map[string]interface{}{ "dry_run": true, "prune_count": result.PrunedCount, "ttl_days": result.TTLDays, "tombstone_ids": result.PrunedIDs, } outputJSON(output) return } fmt.Printf("DRY RUN - Tombstone Pruning\n\n") fmt.Printf("TTL: %d days\n", result.TTLDays) fmt.Printf("Tombstones that would be pruned: %d\n", result.PrunedCount) if len(result.PrunedIDs) > 0 && len(result.PrunedIDs) <= 20 { fmt.Println("\nTombstone IDs:") for _, id := range result.PrunedIDs { fmt.Printf(" - %s\n", id) } } else if len(result.PrunedIDs) > 20 { fmt.Printf("\nFirst 20 tombstone IDs:\n") for _, id := range result.PrunedIDs[:20] { fmt.Printf(" - %s\n", id) } fmt.Printf(" ... and %d more\n", len(result.PrunedIDs)-20) } return } // Actually prune tombstones result, err := pruneExpiredTombstones(customTTL) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to prune tombstones: %v\n", err) os.Exit(1) } elapsed := time.Since(start) if jsonOutput { output := map[string]interface{}{ "success": true, "pruned_count": result.PrunedCount, "ttl_days": result.TTLDays, "tombstone_ids": result.PrunedIDs, "elapsed_ms": elapsed.Milliseconds(), } outputJSON(output) return } if result.PrunedCount == 0 { fmt.Printf("No expired tombstones to prune (TTL: %d days)\n", result.TTLDays) return } fmt.Printf("✓ Pruned %d expired tombstone(s)\n", result.PrunedCount) fmt.Printf(" TTL: %d days\n", result.TTLDays) fmt.Printf(" Time: %v\n", elapsed) if len(result.PrunedIDs) <= 10 { fmt.Println("\nPruned IDs:") for _, id := range result.PrunedIDs { fmt.Printf(" - %s\n", id) } } } // 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) //nolint:gosec // G304: issuesPath from beads.FindBeadsDir() 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) }