Previously, --older-than=0 was indistinguishable from "flag not set" because both resulted in compactOlderThan==0. The check `> 0` treated both as "use default 30 days". Fix: - Change flag default to -1 (sentinel for "use default") - Treat 0 as "expire all tombstones" by passing 1ns TTL - Explicit positive values work as before (N days) Closes bd-gigi 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
511 lines
14 KiB
Go
511 lines
14 KiB
Go
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)
|
|
}
|