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.
This commit is contained in:
Steve Yegge
2025-12-28 23:46:05 -08:00
parent 7f1f906c28
commit 6c42b461a4
2 changed files with 291 additions and 26 deletions

View File

@@ -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)")

View File

@@ -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)
}