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