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:
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user