From 923a9da3901bfda31e18ae7af5f61ddfd7fb7b38 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 01:25:49 -0800 Subject: [PATCH] feat: add --hard flag to bd cleanup for bypassing tombstone TTL safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to permanently remove tombstones before the default 30-day TTL: - bd cleanup --hard --older-than N: prune tombstones older than N days - bd cleanup --hard: prune all tombstones This bypasses sync safety for scenarios like cleaning house after extended absence where resurrection from old clones is not a concern. Closes: bd-adoe 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/bd/cleanup.go | 52 +++++++++++++++++++++++++++++++++++++----- cmd/bd/compact.go | 24 +++++++++++++------ cmd/bd/compact_test.go | 8 +++---- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/cmd/bd/cleanup.go b/cmd/bd/cleanup.go index 836429dd..8c8d1d39 100644 --- a/cmd/bd/cleanup.go +++ b/cmd/bd/cleanup.go @@ -11,6 +11,8 @@ import ( "github.com/steveyegge/beads/internal/types" ) +// Hard delete mode: bypass tombstone TTL safety, use --older-than days directly + var cleanupCmd = &cobra.Command{ Use: "cleanup", Short: "Delete closed issues and prune expired tombstones", @@ -25,6 +27,14 @@ It does NOT remove temporary files - use 'bd clean' for that. By default, deletes ALL closed issues. Use --older-than to only delete issues closed before a certain date. +HARD DELETE MODE: +Use --hard to bypass the 30-day tombstone safety period. When combined with +--older-than, tombstones older than N days are permanently removed from JSONL. +This is useful for cleaning house when you know old clones won't resurrect issues. + +WARNING: --hard bypasses sync safety. Deleted issues may resurrect if an old +clone syncs before you've cleaned up all clones. + EXAMPLES: Delete all closed issues and prune tombstones: bd cleanup --force @@ -36,6 +46,9 @@ Preview what would be deleted/pruned: bd cleanup --dry-run bd cleanup --older-than 90 --dry-run +Hard delete: permanently remove issues/tombstones older than 3 days: + bd cleanup --older-than 3 --hard --force + SAFETY: - Requires --force flag to actually delete (unless --dry-run) - Supports --cascade to delete dependents @@ -50,6 +63,23 @@ SEE ALSO: dryRun, _ := cmd.Flags().GetBool("dry-run") cascade, _ := cmd.Flags().GetBool("cascade") olderThanDays, _ := cmd.Flags().GetInt("older-than") + hardDelete, _ := cmd.Flags().GetBool("hard") + + // Calculate custom TTL for --hard mode + // When --hard is set, use --older-than days as the tombstone TTL cutoff + // This bypasses the default 30-day tombstone safety period + var customTTL time.Duration + if hardDelete { + if olderThanDays > 0 { + customTTL = time.Duration(olderThanDays) * 24 * time.Hour + } else { + // --hard without --older-than: prune ALL tombstones (use 1 second TTL) + customTTL = time.Second + } + if !jsonOutput && !dryRun { + fmt.Println(color.YellowString("⚠️ HARD DELETE MODE: Bypassing tombstone TTL safety")) + } + } // Ensure we have storage if daemonClient != nil { @@ -135,22 +165,27 @@ SEE ALSO: // Also prune expired tombstones (bd-08ea) // This runs after closed issues are converted to tombstones, cleaning up old ones + // In --hard mode, customTTL overrides the default 30-day TTL if dryRun { // Preview what tombstones would be pruned - tombstoneResult, err := previewPruneTombstones() + tombstoneResult, err := previewPruneTombstones(customTTL) if err != nil { if !jsonOutput { fmt.Fprintf(os.Stderr, "Warning: failed to check tombstones: %v\n", err) } } else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 { if !jsonOutput { - fmt.Printf("\nExpired tombstones that would be pruned: %d (older than %d days)\n", - tombstoneResult.PrunedCount, tombstoneResult.TTLDays) + ttlMsg := fmt.Sprintf("older than %d days", tombstoneResult.TTLDays) + if hardDelete && olderThanDays == 0 { + ttlMsg = "all tombstones (--hard mode)" + } + fmt.Printf("\nExpired tombstones that would be pruned: %d (%s)\n", + tombstoneResult.PrunedCount, ttlMsg) } } } else if force { // Actually prune expired tombstones - tombstoneResult, err := pruneExpiredTombstones() + tombstoneResult, err := pruneExpiredTombstones(customTTL) if err != nil { if !jsonOutput { fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err) @@ -158,8 +193,12 @@ SEE ALSO: } else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 { if !jsonOutput { green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s Pruned %d expired tombstone(s) (older than %d days)\n", - green("✓"), tombstoneResult.PrunedCount, tombstoneResult.TTLDays) + ttlMsg := fmt.Sprintf("older than %d days", tombstoneResult.TTLDays) + if hardDelete && olderThanDays == 0 { + ttlMsg = "all tombstones (--hard mode)" + } + fmt.Printf("\n%s Pruned %d expired tombstone(s) (%s)\n", + green("✓"), tombstoneResult.PrunedCount, ttlMsg) } } } @@ -171,5 +210,6 @@ func init() { cleanupCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes") cleanupCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues") cleanupCmd.Flags().Int("older-than", 0, "Only delete issues closed more than N days ago (0 = all closed issues)") + cleanupCmd.Flags().Bool("hard", false, "Bypass tombstone TTL safety; use --older-than days as cutoff") rootCmd.AddCommand(cleanupCmd) } diff --git a/cmd/bd/compact.go b/cmd/bd/compact.go index 402dc510..ccc62529 100644 --- a/cmd/bd/compact.go +++ b/cmd/bd/compact.go @@ -313,7 +313,7 @@ func runCompactSingle(ctx context.Context, compactor *compact.Compactor, store * pruneDeletionsManifest() // Prune expired tombstones (bd-okh) - if tombstonePruneResult, err := pruneExpiredTombstones(); err != nil { + if tombstonePruneResult, err := pruneExpiredTombstones(0); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err) } else if tombstonePruneResult != nil && tombstonePruneResult.PrunedCount > 0 { fmt.Printf("\nTombstones pruned: %d expired (older than %d days)\n", @@ -448,7 +448,7 @@ func runCompactAll(ctx context.Context, compactor *compact.Compactor, store *sql pruneDeletionsManifest() // Prune expired tombstones (bd-okh) - if tombstonePruneResult, err := pruneExpiredTombstones(); err != nil { + if tombstonePruneResult, err := pruneExpiredTombstones(0); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err) } else if tombstonePruneResult != nil && tombstonePruneResult.PrunedCount > 0 { fmt.Printf("\nTombstones pruned: %d expired (older than %d days)\n", @@ -894,7 +894,7 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) { pruneResult, retentionDays := pruneDeletionsManifest() // Prune expired tombstones from issues.jsonl (bd-okh) - tombstonePruneResult, tombstoneErr := pruneExpiredTombstones() + tombstonePruneResult, tombstoneErr := pruneExpiredTombstones(0) if tombstoneErr != nil && !jsonOutput { fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", tombstoneErr) } @@ -990,7 +990,9 @@ type TombstonePruneResult struct { // pruneExpiredTombstones reads issues.jsonl, removes expired tombstones, // and writes back the pruned file. Returns the prune result. -func pruneExpiredTombstones() (*TombstonePruneResult, error) { +// 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") @@ -1023,8 +1025,11 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) { return nil, fmt.Errorf("failed to close issues file: %w", err) } - // Determine TTL + // 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 @@ -1080,7 +1085,9 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) { // previewPruneTombstones checks what tombstones would be pruned without modifying files. // Used for dry-run mode in cleanup command (bd-08ea). -func previewPruneTombstones() (*TombstonePruneResult, error) { +// 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") @@ -1111,8 +1118,11 @@ func previewPruneTombstones() (*TombstonePruneResult, error) { allIssues = append(allIssues, &issue) } - // Determine TTL + // 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 diff --git a/cmd/bd/compact_test.go b/cmd/bd/compact_test.go index 456765f2..e3bc1a76 100644 --- a/cmd/bd/compact_test.go +++ b/cmd/bd/compact_test.go @@ -457,8 +457,8 @@ func TestPruneExpiredTombstones(t *testing.T) { defer func() { dbPath = originalDBPath }() dbPath = filepath.Join(beadsDir, "beads.db") - // Run pruning - result, err := pruneExpiredTombstones() + // Run pruning (0 = use default TTL) + result, err := pruneExpiredTombstones(0) if err != nil { t.Fatalf("pruneExpiredTombstones failed: %v", err) } @@ -551,8 +551,8 @@ func TestPruneExpiredTombstones_NoTombstones(t *testing.T) { defer func() { dbPath = originalDBPath }() dbPath = filepath.Join(beadsDir, "beads.db") - // Run pruning - should return zero pruned - result, err := pruneExpiredTombstones() + // Run pruning - should return zero pruned (0 = use default TTL) + result, err := pruneExpiredTombstones(0) if err != nil { t.Fatalf("pruneExpiredTombstones failed: %v", err) }