feat(compact): Add --prune mode for standalone tombstone pruning (bd-c7y5)

Add a new --prune mode to `bd compact` that removes expired tombstones from
issues.jsonl without requiring AI compaction or deleting closed issues.

Features:
- `bd compact --prune` removes tombstones older than 30 days (default TTL)
- `bd compact --prune --older-than N` uses custom N-day TTL
- `bd compact --prune --dry-run` previews what would be pruned
- Supports --json output for programmatic use

This reduces sync overhead by eliminating accumulated tombstones that were
previously only pruned as a side effect of compaction or cleanup operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 23:50:15 -08:00
parent cdcb716cfc
commit 36feb15d83
3 changed files with 996 additions and 750 deletions

View File

@@ -514,6 +514,189 @@ func TestPruneExpiredTombstones(t *testing.T) {
}
}
func TestPruneExpiredTombstones_CustomTTL(t *testing.T) {
// Setup: create a temp .beads directory with issues.jsonl
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
now := time.Now()
// Both tombstones are older than 5 days, so both should be pruned with 5-day TTL
tombstoneTime := now.Add(-10 * 24 * time.Hour) // 10 days ago
issues := []*types.Issue{
{
ID: "test-live",
Title: "Live issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: now.Add(-5 * 24 * time.Hour),
UpdatedAt: now,
},
{
ID: "test-tombstone-1",
Title: "(deleted)",
Status: types.StatusTombstone,
Priority: 0,
IssueType: types.TypeTask,
CreatedAt: now.Add(-20 * 24 * time.Hour),
UpdatedAt: tombstoneTime,
DeletedAt: &tombstoneTime,
DeletedBy: "alice",
DeleteReason: "duplicate",
},
}
// Write issues to JSONL
file, err := os.Create(issuesPath)
if err != nil {
t.Fatalf("Failed to create issues.jsonl: %v", err)
}
encoder := json.NewEncoder(file)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
file.Close()
t.Fatalf("Failed to write issue: %v", err)
}
}
file.Close()
// Save original dbPath and restore after test
originalDBPath := dbPath
defer func() { dbPath = originalDBPath }()
dbPath = filepath.Join(beadsDir, "beads.db")
// Run pruning with 5-day TTL - tombstone is 10 days old, should be pruned
customTTL := 5 * 24 * time.Hour
result, err := pruneExpiredTombstones(customTTL)
if err != nil {
t.Fatalf("pruneExpiredTombstones failed: %v", err)
}
// Verify results - 5-day TTL means tombstones older than 5 days are pruned
if result.PrunedCount != 1 {
t.Errorf("Expected 1 pruned tombstone with 5-day TTL, got %d", result.PrunedCount)
}
if result.TTLDays != 5 {
t.Errorf("Expected TTLDays 5, got %d", result.TTLDays)
}
}
func TestPreviewPruneTombstones(t *testing.T) {
// Setup: create a temp .beads directory with issues.jsonl
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
now := time.Now()
expiredTombstoneTime := now.Add(-60 * 24 * time.Hour) // 60 days ago
issues := []*types.Issue{
{
ID: "test-live",
Title: "Live issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "test-expired-tombstone",
Title: "(deleted)",
Status: types.StatusTombstone,
Priority: 0,
IssueType: types.TypeTask,
CreatedAt: now.Add(-90 * 24 * time.Hour),
UpdatedAt: expiredTombstoneTime,
DeletedAt: &expiredTombstoneTime,
DeletedBy: "bob",
DeleteReason: "obsolete",
},
}
// Write issues to JSONL
file, err := os.Create(issuesPath)
if err != nil {
t.Fatalf("Failed to create issues.jsonl: %v", err)
}
encoder := json.NewEncoder(file)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
file.Close()
t.Fatalf("Failed to write issue: %v", err)
}
}
file.Close()
// Save original dbPath and restore after test
originalDBPath := dbPath
defer func() { dbPath = originalDBPath }()
dbPath = filepath.Join(beadsDir, "beads.db")
// Preview pruning - should not modify file
result, err := previewPruneTombstones(0)
if err != nil {
t.Fatalf("previewPruneTombstones failed: %v", err)
}
// Verify preview results
if result.PrunedCount != 1 {
t.Errorf("Expected 1 tombstone to prune, got %d", result.PrunedCount)
}
if result.PrunedIDs[0] != "test-expired-tombstone" {
t.Errorf("Expected PrunedIDs [test-expired-tombstone], got %v", result.PrunedIDs)
}
// Verify file was NOT modified (preview mode)
file, err = os.Open(issuesPath)
if err != nil {
t.Fatalf("Failed to reopen issues.jsonl: %v", err)
}
defer file.Close()
var remaining []*types.Issue
decoder := json.NewDecoder(file)
for {
var issue types.Issue
if err := decoder.Decode(&issue); err != nil {
if err.Error() == "EOF" {
break
}
t.Fatalf("Failed to decode issue: %v", err)
}
remaining = append(remaining, &issue)
}
// Both issues should still be in file (preview doesn't modify)
if len(remaining) != 2 {
t.Errorf("Expected 2 issues (preview mode), got %d", len(remaining))
}
}
func TestCompactPruneFlagExists(t *testing.T) {
// Verify --prune flag exists
pruneFlag := compactCmd.Flags().Lookup("prune")
if pruneFlag == nil {
t.Error("compact command should have --prune flag")
}
// Verify --older-than flag exists
olderThanFlag := compactCmd.Flags().Lookup("older-than")
if olderThanFlag == nil {
t.Error("compact command should have --older-than flag")
}
}
func TestPruneExpiredTombstones_NoTombstones(t *testing.T) {
// Setup: create a temp .beads directory with only live issues
tmpDir := t.TempDir()