Files
beads/cmd/bd/compact_test.go
Steve Yegge 36feb15d83 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>
2025-12-24 00:08:10 -08:00

747 lines
19 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestCompactSuite(t *testing.T) {
tmpDir := t.TempDir()
testDB := filepath.Join(tmpDir, ".beads", "beads.db")
s := newTestStore(t, testDB)
ctx := context.Background()
t.Run("DryRun", func(t *testing.T) {
// Create a closed issue
issue := &types.Issue{
ID: "test-dryrun-1",
Title: "Test Issue",
Description: "This is a long description that should be compacted. " + string(make([]byte, 500)),
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)),
}
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
// Test dry run - should check eligibility without error even without API key
eligible, reason, err := s.CheckEligibility(ctx, "test-dryrun-1", 1)
if err != nil {
t.Fatalf("CheckEligibility failed: %v", err)
}
if !eligible {
t.Fatalf("Issue should be eligible for compaction: %s", reason)
}
})
t.Run("Stats", func(t *testing.T) {
// Create mix of issues - some eligible, some not
issues := []*types.Issue{
{
ID: "test-stats-1",
Title: "Old closed",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)),
},
{
ID: "test-stats-2",
Title: "Recent closed",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-10 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-5 * 24 * time.Hour)),
},
{
ID: "test-stats-3",
Title: "Still open",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-40 * 24 * time.Hour),
},
}
for _, issue := range issues {
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Verify issues were created
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
// Count issues with stats prefix
statCount := 0
for _, issue := range allIssues {
if len(issue.ID) >= 11 && issue.ID[:11] == "test-stats-" {
statCount++
}
}
if statCount != 3 {
t.Errorf("Expected 3 stats issues, got %d", statCount)
}
// Test eligibility check for old closed issue
eligible, _, err := s.CheckEligibility(ctx, "test-stats-1", 1)
if err != nil {
t.Fatalf("CheckEligibility failed: %v", err)
}
if !eligible {
t.Error("Old closed issue should be eligible for Tier 1")
}
})
t.Run("RunCompactStats", func(t *testing.T) {
// Create some closed issues
for i := 1; i <= 3; i++ {
id := fmt.Sprintf("test-runstats-%d", i)
issue := &types.Issue{
ID: id,
Title: "Test Issue",
Description: string(make([]byte, 500)),
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)),
}
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Test stats - should work without API key
savedJSONOutput := jsonOutput
jsonOutput = false
defer func() { jsonOutput = savedJSONOutput }()
// Actually call runCompactStats to increase coverage
runCompactStats(ctx, s)
// Also test with JSON output
jsonOutput = true
runCompactStats(ctx, s)
})
t.Run("CompactStatsJSON", func(t *testing.T) {
// Create a closed issue eligible for Tier 1
issue := &types.Issue{
ID: "test-json-1",
Title: "Test Issue",
Description: string(make([]byte, 500)),
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)),
}
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
// Test with JSON output
savedJSONOutput := jsonOutput
jsonOutput = true
defer func() { jsonOutput = savedJSONOutput }()
// Should not panic and should execute JSON path
runCompactStats(ctx, s)
})
t.Run("RunCompactSingleDryRun", func(t *testing.T) {
// Create a closed issue eligible for compaction
issue := &types.Issue{
ID: "test-single-1",
Title: "Test Compact Issue",
Description: string(make([]byte, 500)),
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)),
}
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
// Test eligibility in dry run mode
eligible, _, err := s.CheckEligibility(ctx, "test-single-1", 1)
if err != nil {
t.Fatalf("CheckEligibility failed: %v", err)
}
if !eligible {
t.Error("Issue should be eligible for Tier 1 compaction")
}
})
t.Run("RunCompactAllDryRun", func(t *testing.T) {
// Create multiple closed issues
for i := 1; i <= 3; i++ {
issue := &types.Issue{
ID: fmt.Sprintf("test-all-%d", i),
Title: "Test Issue",
Description: string(make([]byte, 500)),
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)),
}
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Verify issues eligible for compaction
closedStatus := types.StatusClosed
issues, err := s.SearchIssues(ctx, "", types.IssueFilter{Status: &closedStatus})
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
eligibleCount := 0
for _, issue := range issues {
// Only count our test-all issues
if len(issue.ID) < 9 || issue.ID[:9] != "test-all-" {
continue
}
eligible, _, err := s.CheckEligibility(ctx, issue.ID, 1)
if err != nil {
t.Fatalf("CheckEligibility failed for %s: %v", issue.ID, err)
}
if eligible {
eligibleCount++
}
}
if eligibleCount != 3 {
t.Errorf("Expected 3 eligible issues, got %d", eligibleCount)
}
})
}
func TestCompactValidation(t *testing.T) {
tests := []struct {
name string
compactID string
compactAll bool
dryRun bool
force bool
wantError bool
}{
{
name: "both id and all",
compactID: "test-1",
compactAll: true,
wantError: true,
},
{
name: "force without id",
force: true,
wantError: true,
},
{
name: "no flags",
wantError: true,
},
{
name: "dry run only",
dryRun: true,
wantError: false,
},
{
name: "id only",
compactID: "test-1",
wantError: false,
},
{
name: "all only",
compactAll: true,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.compactID != "" && tt.compactAll {
// Should fail
if !tt.wantError {
t.Error("Expected error for both --id and --all")
}
}
if tt.force && tt.compactID == "" {
// Should fail
if !tt.wantError {
t.Error("Expected error for --force without --id")
}
}
if tt.compactID == "" && !tt.compactAll && !tt.dryRun {
// Should fail
if !tt.wantError {
t.Error("Expected error when no action specified")
}
}
})
}
}
func TestCompactProgressBar(t *testing.T) {
// Test progress bar formatting
pb := progressBar(50, 100)
if len(pb) == 0 {
t.Error("Progress bar should not be empty")
}
pb = progressBar(100, 100)
if len(pb) == 0 {
t.Error("Full progress bar should not be empty")
}
pb = progressBar(0, 100)
if len(pb) == 0 {
t.Error("Zero progress bar should not be empty")
}
}
func TestFormatUptime(t *testing.T) {
tests := []struct {
name string
seconds float64
want string
}{
{
name: "seconds",
seconds: 45.0,
want: "45.0 seconds",
},
{
name: "minutes",
seconds: 300.0,
want: "5m 0s",
},
{
name: "hours",
seconds: 7200.0,
want: "2h 0m",
},
{
name: "days",
seconds: 90000.0,
want: "1d 1h",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatUptime(tt.seconds)
if got != tt.want {
t.Errorf("formatUptime(%v) = %q, want %q", tt.seconds, got, tt.want)
}
})
}
}
func ptrTime(t time.Time) *time.Time {
return &t
}
func TestCompactInitCommand(t *testing.T) {
if compactCmd == nil {
t.Fatal("compactCmd should be initialized")
}
if compactCmd.Use != "compact" {
t.Errorf("Expected Use='compact', got %q", compactCmd.Use)
}
if len(compactCmd.Long) == 0 {
t.Error("compactCmd should have Long description")
}
// Verify --json flag exists
jsonFlag := compactCmd.Flags().Lookup("json")
if jsonFlag == nil {
t.Error("compact command should have --json flag")
}
}
func TestPruneExpiredTombstones(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)
}
// Create issues.jsonl with mix of live issues, fresh tombstones, and expired tombstones
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
now := time.Now()
freshTombstoneTime := now.Add(-10 * 24 * time.Hour) // 10 days ago - NOT expired
expiredTombstoneTime := now.Add(-60 * 24 * time.Hour) // 60 days ago - expired (> 30 day TTL)
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-fresh-tombstone",
Title: "(deleted)",
Status: types.StatusTombstone,
Priority: 0,
IssueType: types.TypeTask,
CreatedAt: now.Add(-20 * 24 * time.Hour),
UpdatedAt: freshTombstoneTime,
DeletedAt: &freshTombstoneTime,
DeletedBy: "alice",
DeleteReason: "duplicate",
},
{
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")
// Run pruning (0 = use default TTL)
result, err := pruneExpiredTombstones(0)
if err != nil {
t.Fatalf("pruneExpiredTombstones failed: %v", err)
}
// Verify results
if result.PrunedCount != 1 {
t.Errorf("Expected 1 pruned tombstone, got %d", result.PrunedCount)
}
if len(result.PrunedIDs) != 1 || result.PrunedIDs[0] != "test-expired-tombstone" {
t.Errorf("Expected PrunedIDs [test-expired-tombstone], got %v", result.PrunedIDs)
}
if result.TTLDays != 30 {
t.Errorf("Expected TTLDays 30, got %d", result.TTLDays)
}
// Verify the file was updated correctly
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)
}
if len(remaining) != 2 {
t.Fatalf("Expected 2 remaining issues, got %d", len(remaining))
}
// Verify live issue and fresh tombstone remain
ids := make(map[string]bool)
for _, issue := range remaining {
ids[issue.ID] = true
}
if !ids["test-live"] {
t.Error("Live issue should remain")
}
if !ids["test-fresh-tombstone"] {
t.Error("Fresh tombstone should remain")
}
if ids["test-expired-tombstone"] {
t.Error("Expired tombstone should have been pruned")
}
}
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()
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()
issue := &types.Issue{
ID: "test-live",
Title: "Live issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: now,
UpdatedAt: now,
}
file, err := os.Create(issuesPath)
if err != nil {
t.Fatalf("Failed to create issues.jsonl: %v", err)
}
encoder := json.NewEncoder(file)
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 - should return zero pruned (0 = use default TTL)
result, err := pruneExpiredTombstones(0)
if err != nil {
t.Fatalf("pruneExpiredTombstones failed: %v", err)
}
if result.PrunedCount != 0 {
t.Errorf("Expected 0 pruned tombstones, got %d", result.PrunedCount)
}
}