feat(deletions): auto-compact during sync and git history fallback fixes

- Add Count function to deletions package for fast line counting
- Add maybeAutoCompactDeletions to sync (opt-in via deletions.auto_compact config)
- Fix regex escaping in batchCheckGitHistory (bd-bgs)
- Add 30s timeout to git history commands (bd-f0n)
- Use git rev-parse --show-toplevel for proper repo root detection (bd-bhd)
- Add tests for Count and auto-compact functionality

Closes: bd-qsm, bd-bgs, bd-f0n, bd-bhd
This commit is contained in:
Steve Yegge
2025-11-25 15:08:12 -08:00
parent 2f5ef33c08
commit 4898c424aa
5 changed files with 439 additions and 10 deletions
+29
View File
@@ -180,6 +180,35 @@ func DefaultPath(beadsDir string) string {
return filepath.Join(beadsDir, "deletions.jsonl")
}
// Count returns the number of lines in the deletions manifest.
// This is a fast operation that doesn't parse JSON, just counts lines.
// Returns 0 if the file doesn't exist or is empty.
func Count(path string) (int, error) {
f, err := os.Open(path) // #nosec G304 - controlled path from caller
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("failed to open deletions file: %w", err)
}
defer f.Close()
count := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
count++
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("error reading deletions file: %w", err)
}
return count, nil
}
// DefaultRetentionDays is the default number of days to retain deletion records.
const DefaultRetentionDays = 7
+61
View File
@@ -546,3 +546,64 @@ func TestPruneDeletions_ZeroRetention(t *testing.T) {
t.Errorf("expected 1 pruned with 0 retention, got %d", result.PrunedCount)
}
}
func TestCount_Empty(t *testing.T) {
// Non-existent file should return 0
count, err := Count("/nonexistent/path/deletions.jsonl")
if err != nil {
t.Fatalf("expected no error for non-existent file, got: %v", err)
}
if count != 0 {
t.Errorf("expected 0 count for non-existent file, got %d", count)
}
}
func TestCount_WithRecords(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
now := time.Now()
records := []DeletionRecord{
{ID: "bd-001", Timestamp: now, Actor: "user1"},
{ID: "bd-002", Timestamp: now, Actor: "user2"},
{ID: "bd-003", Timestamp: now, Actor: "user3"},
}
for _, r := range records {
if err := AppendDeletion(path, r); err != nil {
t.Fatalf("AppendDeletion failed: %v", err)
}
}
count, err := Count(path)
if err != nil {
t.Fatalf("Count failed: %v", err)
}
if count != 3 {
t.Errorf("expected 3, got %d", count)
}
}
func TestCount_WithEmptyLines(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "deletions.jsonl")
// Write content with empty lines
content := `{"id":"bd-001","ts":"2024-01-01T00:00:00Z","by":"user1"}
{"id":"bd-002","ts":"2024-01-02T00:00:00Z","by":"user2"}
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
count, err := Count(path)
if err != nil {
t.Fatalf("Count failed: %v", err)
}
// Should count only non-empty lines
if count != 2 {
t.Errorf("expected 2 (excluding empty lines), got %d", count)
}
}