feat(deletions): add pruning and git history fallback

Implements two P1 tasks for the deletions manifest epic:

bd-v2x: Add deletions pruning to bd compact
- PruneDeletions function removes records older than retention period
- Default retention: 7 days (configurable via metadata.json)
- CLI --retention flag for override
- Atomic file rewrite prevents corruption
- Called automatically during all compact operations

bd-pnm: Add git history fallback for pruned deletions
- Catches deletions where manifest entry was pruned
- Uses git log -S to search for ID in JSONL history
- Batches multiple IDs for efficiency (git -G regex)
- Self-healing: backfills manifest on hit
- Conservative: keeps issue if git check fails (shallow clone)

Tests added for both features with edge cases covered.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-25 12:41:29 -08:00
parent 1804a91787
commit 3f84ec3774
7 changed files with 587 additions and 22 deletions

View File

@@ -179,3 +179,54 @@ func WriteDeletions(path string, records []DeletionRecord) error {
func DefaultPath(beadsDir string) string {
return filepath.Join(beadsDir, "deletions.jsonl")
}
// DefaultRetentionDays is the default number of days to retain deletion records.
const DefaultRetentionDays = 7
// PruneResult contains the result of a prune operation.
type PruneResult struct {
KeptCount int
PrunedCount int
PrunedIDs []string
}
// PruneDeletions removes deletion records older than the specified retention period.
// Returns PruneResult with counts and IDs of pruned records.
// If the file doesn't exist or is empty, returns zero counts with no error.
func PruneDeletions(path string, retentionDays int) (*PruneResult, error) {
result := &PruneResult{
PrunedIDs: []string{},
}
loadResult, err := LoadDeletions(path)
if err != nil {
return nil, fmt.Errorf("failed to load deletions: %w", err)
}
if len(loadResult.Records) == 0 {
return result, nil
}
cutoff := time.Now().AddDate(0, 0, -retentionDays)
var kept []DeletionRecord
for _, record := range loadResult.Records {
if record.Timestamp.After(cutoff) || record.Timestamp.Equal(cutoff) {
kept = append(kept, record)
} else {
result.PrunedCount++
result.PrunedIDs = append(result.PrunedIDs, record.ID)
}
}
result.KeptCount = len(kept)
// Only rewrite if we actually pruned something
if result.PrunedCount > 0 {
if err := WriteDeletions(path, kept); err != nil {
return nil, fmt.Errorf("failed to write pruned deletions: %w", err)
}
}
return result, nil
}

View File

@@ -333,3 +333,216 @@ func TestAppendDeletion_EmptyID(t *testing.T) {
t.Errorf("unexpected error message: %v", err)
}
}
func TestPruneDeletions_Empty(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
// Prune non-existent file should succeed
result, err := PruneDeletions(path, 7)
if err != nil {
t.Fatalf("PruneDeletions should not fail on non-existent file: %v", err)
}
if result.KeptCount != 0 {
t.Errorf("expected 0 kept, got %d", result.KeptCount)
}
if result.PrunedCount != 0 {
t.Errorf("expected 0 pruned, got %d", result.PrunedCount)
}
}
func TestPruneDeletions_AllRecent(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
now := time.Now()
records := []DeletionRecord{
{ID: "bd-001", Timestamp: now.Add(-1 * time.Hour), Actor: "user1"},
{ID: "bd-002", Timestamp: now.Add(-2 * time.Hour), Actor: "user2"},
{ID: "bd-003", Timestamp: now.Add(-3 * time.Hour), Actor: "user3"},
}
// Write records
for _, r := range records {
if err := AppendDeletion(path, r); err != nil {
t.Fatalf("AppendDeletion failed: %v", err)
}
}
// Prune with 7 day retention - nothing should be pruned
result, err := PruneDeletions(path, 7)
if err != nil {
t.Fatalf("PruneDeletions failed: %v", err)
}
if result.KeptCount != 3 {
t.Errorf("expected 3 kept, got %d", result.KeptCount)
}
if result.PrunedCount != 0 {
t.Errorf("expected 0 pruned, got %d", result.PrunedCount)
}
// Verify file unchanged
loaded, err := LoadDeletions(path)
if err != nil {
t.Fatalf("LoadDeletions failed: %v", err)
}
if len(loaded.Records) != 3 {
t.Errorf("expected 3 records after prune, got %d", len(loaded.Records))
}
}
func TestPruneDeletions_SomeOld(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
now := time.Now()
// Two recent, two old
records := []DeletionRecord{
{ID: "bd-001", Timestamp: now.Add(-1 * time.Hour), Actor: "user1"}, // Recent
{ID: "bd-002", Timestamp: now.AddDate(0, 0, -10), Actor: "user2"}, // 10 days old
{ID: "bd-003", Timestamp: now.Add(-2 * time.Hour), Actor: "user3"}, // Recent
{ID: "bd-004", Timestamp: now.AddDate(0, 0, -15), Actor: "user4"}, // 15 days old
}
// Write records
for _, r := range records {
if err := AppendDeletion(path, r); err != nil {
t.Fatalf("AppendDeletion failed: %v", err)
}
}
// Prune with 7 day retention
result, err := PruneDeletions(path, 7)
if err != nil {
t.Fatalf("PruneDeletions failed: %v", err)
}
if result.KeptCount != 2 {
t.Errorf("expected 2 kept, got %d", result.KeptCount)
}
if result.PrunedCount != 2 {
t.Errorf("expected 2 pruned, got %d", result.PrunedCount)
}
// Verify pruned IDs
prunedMap := make(map[string]bool)
for _, id := range result.PrunedIDs {
prunedMap[id] = true
}
if !prunedMap["bd-002"] || !prunedMap["bd-004"] {
t.Errorf("expected bd-002 and bd-004 to be pruned, got %v", result.PrunedIDs)
}
// Verify file was updated
loaded, err := LoadDeletions(path)
if err != nil {
t.Fatalf("LoadDeletions failed: %v", err)
}
if len(loaded.Records) != 2 {
t.Errorf("expected 2 records after prune, got %d", len(loaded.Records))
}
if _, ok := loaded.Records["bd-001"]; !ok {
t.Error("expected bd-001 to remain")
}
if _, ok := loaded.Records["bd-003"]; !ok {
t.Error("expected bd-003 to remain")
}
}
func TestPruneDeletions_AllOld(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
now := time.Now()
records := []DeletionRecord{
{ID: "bd-001", Timestamp: now.AddDate(0, 0, -30), Actor: "user1"},
{ID: "bd-002", Timestamp: now.AddDate(0, 0, -60), Actor: "user2"},
}
// Write records
for _, r := range records {
if err := AppendDeletion(path, r); err != nil {
t.Fatalf("AppendDeletion failed: %v", err)
}
}
// Prune with 7 day retention - all should be pruned
result, err := PruneDeletions(path, 7)
if err != nil {
t.Fatalf("PruneDeletions failed: %v", err)
}
if result.KeptCount != 0 {
t.Errorf("expected 0 kept, got %d", result.KeptCount)
}
if result.PrunedCount != 2 {
t.Errorf("expected 2 pruned, got %d", result.PrunedCount)
}
// Verify file is empty
loaded, err := LoadDeletions(path)
if err != nil {
t.Fatalf("LoadDeletions failed: %v", err)
}
if len(loaded.Records) != 0 {
t.Errorf("expected 0 records after prune, got %d", len(loaded.Records))
}
}
func TestPruneDeletions_NearBoundary(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
now := time.Now()
// Record just inside retention should be kept (6 days 23 hours)
// Record just outside retention should be pruned (7 days 1 hour)
records := []DeletionRecord{
{ID: "bd-001", Timestamp: now.AddDate(0, 0, -6).Add(-23 * time.Hour), Actor: "user1"}, // ~6.96 days (kept)
{ID: "bd-002", Timestamp: now.AddDate(0, 0, -7).Add(-1 * time.Hour), Actor: "user2"}, // ~7.04 days (pruned)
}
for _, r := range records {
if err := AppendDeletion(path, r); err != nil {
t.Fatalf("AppendDeletion failed: %v", err)
}
}
result, err := PruneDeletions(path, 7)
if err != nil {
t.Fatalf("PruneDeletions failed: %v", err)
}
if result.KeptCount != 1 {
t.Errorf("expected 1 kept (inside boundary), got %d", result.KeptCount)
}
if result.PrunedCount != 1 {
t.Errorf("expected 1 pruned (outside boundary), got %d", result.PrunedCount)
}
}
func TestPruneDeletions_ZeroRetention(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
now := time.Now()
records := []DeletionRecord{
{ID: "bd-001", Timestamp: now.Add(1 * time.Hour), Actor: "user1"}, // 1 hour in future (kept)
{ID: "bd-002", Timestamp: now.Add(-1 * time.Hour), Actor: "user2"}, // 1 hour ago (pruned with 0 retention)
}
for _, r := range records {
if err := AppendDeletion(path, r); err != nil {
t.Fatalf("AppendDeletion failed: %v", err)
}
}
// With 0 retention, cutoff is now - past records should be pruned
result, err := PruneDeletions(path, 0)
if err != nil {
t.Fatalf("PruneDeletions failed: %v", err)
}
// Future record should be kept, past record should be pruned
if result.KeptCount != 1 {
t.Errorf("expected 1 kept with 0 retention, got %d", result.KeptCount)
}
if result.PrunedCount != 1 {
t.Errorf("expected 1 pruned with 0 retention, got %d", result.PrunedCount)
}
}