feat(deletions): complete deletions manifest epic with integration tests

Completes the deletion propagation epic (bd-imj) with all 9 subtasks:
- Cross-clone deletion propagation via deletions.jsonl
- bd deleted command for audit trail
- Auto-compact during sync (opt-in)
- Git history fallback with timeout and regex escaping
- JSON output for pruning results
- Integration tests for deletion scenarios
- Documentation in AGENTS.md, README.md, and docs/DELETIONS.md

🤖 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 16:36:46 -08:00
parent 6bab015616
commit 4088e68da7
10 changed files with 749 additions and 26 deletions

View File

@@ -375,6 +375,32 @@ bd close bd-42 "Done" # Updates via git sync
See [docs/AGENT_MAIL_QUICKSTART.md](docs/AGENT_MAIL_QUICKSTART.md) for 5-minute setup, or [docs/AGENT_MAIL.md](docs/AGENT_MAIL.md) for complete documentation. Example code in [examples/python-agent/AGENT_MAIL_EXAMPLE.md](examples/python-agent/AGENT_MAIL_EXAMPLE.md).
### Deletion Tracking
When issues are deleted (via `bd delete` or `bd cleanup`), they are recorded in `.beads/deletions.jsonl`. This manifest:
- **Propagates deletions across clones**: When you pull, deleted issues from other clones are removed from your local database
- **Provides audit trail**: See what was deleted, when, and by whom with `bd deleted`
- **Auto-prunes**: Old records are automatically cleaned up during `bd sync` (configurable retention)
**Commands:**
```bash
bd delete bd-42 # Delete issue (records to manifest)
bd cleanup -f # Delete closed issues (records all to manifest)
bd deleted # Show recent deletions (last 7 days)
bd deleted --since=30d # Show deletions in last 30 days
bd deleted bd-xxx # Show deletion details for specific issue
bd deleted --json # Machine-readable output
```
**How it works:**
1. `bd delete` or `bd cleanup` appends deletion records to `deletions.jsonl`
2. The file is committed and pushed via `bd sync`
3. On other clones, `bd sync` imports the deletions and removes those issues from local DB
4. Git history fallback handles edge cases (pruned records, shallow clones)
### Issue Types
- `bug` - Something broken that needs fixing

View File

@@ -168,6 +168,7 @@ echo ".beads/beads.jsonl merge=beads" >> .gitattributes
**Should be committed to git:**
- `.gitattributes` - Configures git merge driver for intelligent JSONL merging (critical for team collaboration)
- `.beads/beads.jsonl` - Issue data in JSONL format (source of truth, synced via git)
- `.beads/deletions.jsonl` - Deletion manifest for cross-clone propagation (tracks deleted issues)
- `.beads/config.yaml` - Repository configuration template
- `.beads/README.md` - Documentation about beads for repository visitors
- `.beads/metadata.json` - Database metadata

View File

@@ -864,6 +864,9 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
elapsed := time.Since(start)
// Prune old deletion records (do this before JSON output so we can include results)
pruneResult, retentionDays := pruneDeletionsManifest()
if jsonOutput {
output := map[string]interface{}{
"success": true,
@@ -875,6 +878,13 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
"reduction_pct": reductionPct,
"elapsed_ms": elapsed.Milliseconds(),
}
// Include pruning results if any deletions were pruned (bd-v29)
if pruneResult != nil && pruneResult.PrunedCount > 0 {
output["deletions_pruned"] = map[string]interface{}{
"count": pruneResult.PrunedCount,
"retention_days": retentionDays,
}
}
outputJSON(output)
return
}
@@ -883,17 +893,19 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
fmt.Printf(" %d → %d bytes (saved %d, %.1f%%)\n", originalSize, compactedSize, savingBytes, reductionPct)
fmt.Printf(" Time: %v\n", elapsed)
// Prune old deletion records
pruneDeletionsManifest()
// Report pruning results for human-readable output
if pruneResult != nil && pruneResult.PrunedCount > 0 {
fmt.Printf("\nDeletions pruned: %d records older than %d days removed\n", pruneResult.PrunedCount, retentionDays)
}
// Schedule auto-flush to export changes
markDirtyAndScheduleFlush()
}
// pruneDeletionsManifest prunes old deletion records based on retention settings.
// It outputs results to stdout (or JSON) and returns any error.
// Returns the prune result and retention days used, so callers can include in output.
// Uses the global dbPath to determine the .beads directory.
func pruneDeletionsManifest() {
func pruneDeletionsManifest() (*deletions.PruneResult, int) {
beadsDir := filepath.Dir(dbPath)
// Determine retention days
retentionDays := compactRetention
@@ -918,17 +930,10 @@ func pruneDeletionsManifest() {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "Warning: failed to prune deletions: %v\n", err)
}
return
return nil, retentionDays
}
// Only report if there were deletions to prune
if result.PrunedCount > 0 {
if jsonOutput {
// JSON output will be included in the main response
return
}
fmt.Printf("\nDeletions pruned: %d records older than %d days removed\n", result.PrunedCount, retentionDays)
}
return result, retentionDays
}
func init() {

194
cmd/bd/deleted.go Normal file
View File

@@ -0,0 +1,194 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/deletions"
)
var (
deletedSince string
deletedAll bool
)
var deletedCmd = &cobra.Command{
Use: "deleted [issue-id]",
Short: "Show deleted issues from the deletions manifest",
Long: `Show issues that have been deleted and are tracked in the deletions manifest.
This command provides an audit trail of deleted issues, showing:
- Which issues were deleted
- When they were deleted
- Who deleted them
- Optional reason for deletion
Examples:
bd deleted # Show recent deletions (last 7 days)
bd deleted --since=30d # Show deletions in last 30 days
bd deleted --all # Show all tracked deletions
bd deleted bd-xxx # Show deletion details for specific issue`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
beadsDir := findBeadsDir()
if beadsDir == "" {
fmt.Fprintf(os.Stderr, "Error: not in a beads repository (no .beads directory found)\n")
os.Exit(1)
}
deletionsPath := deletions.DefaultPath(beadsDir)
result, err := deletions.LoadDeletions(deletionsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading deletions: %v\n", err)
os.Exit(1)
}
// Print any warnings
for _, w := range result.Warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", w)
}
// If looking for specific issue
if len(args) == 1 {
issueID := args[0]
displaySingleDeletion(result.Records, issueID)
return
}
// Filter by time range
var cutoff time.Time
if !deletedAll {
duration, err := parseDuration(deletedSince)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid --since value '%s': %v\n", deletedSince, err)
os.Exit(1)
}
cutoff = time.Now().Add(-duration)
}
// Collect and sort records
var records []deletions.DeletionRecord
for _, r := range result.Records {
if deletedAll || r.Timestamp.After(cutoff) {
records = append(records, r)
}
}
// Sort by timestamp descending (most recent first)
sort.Slice(records, func(i, j int) bool {
return records[i].Timestamp.After(records[j].Timestamp)
})
if jsonOutput {
outputJSON(records)
return
}
displayDeletions(records, deletedSince, deletedAll)
},
}
func displaySingleDeletion(records map[string]deletions.DeletionRecord, issueID string) {
record, found := records[issueID]
if !found {
if jsonOutput {
outputJSON(map[string]interface{}{
"found": false,
"id": issueID,
})
return
}
fmt.Printf("Issue %s not found in deletions manifest\n", issueID)
fmt.Println("(This could mean the issue was never deleted, or the deletion record was pruned)")
return
}
if jsonOutput {
outputJSON(map[string]interface{}{
"found": true,
"record": record,
})
return
}
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Deletion record for %s:\n\n", cyan("🗑️"), issueID)
fmt.Printf(" ID: %s\n", record.ID)
fmt.Printf(" Deleted: %s\n", record.Timestamp.Local().Format("2006-01-02 15:04:05"))
fmt.Printf(" By: %s\n", record.Actor)
if record.Reason != "" {
fmt.Printf(" Reason: %s\n", record.Reason)
}
fmt.Println()
}
func displayDeletions(records []deletions.DeletionRecord, since string, all bool) {
if len(records) == 0 {
green := color.New(color.FgGreen).SprintFunc()
if all {
fmt.Printf("\n%s No deletions tracked in manifest\n\n", green("✨"))
} else {
fmt.Printf("\n%s No deletions in the last %s\n\n", green("✨"), since)
}
return
}
cyan := color.New(color.FgCyan).SprintFunc()
if all {
fmt.Printf("\n%s All tracked deletions (%d total):\n\n", cyan("🗑️"), len(records))
} else {
fmt.Printf("\n%s Deletions in the last %s (%d total):\n\n", cyan("🗑️"), since, len(records))
}
for _, r := range records {
ts := r.Timestamp.Local().Format("2006-01-02 15:04")
reason := ""
if r.Reason != "" {
reason = " " + r.Reason
}
fmt.Printf(" %-12s %s %-12s%s\n", r.ID, ts, r.Actor, reason)
}
fmt.Println()
}
// parseDuration parses a duration string like "7d", "30d", "2w"
func parseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(strings.ToLower(s))
if s == "" {
return 7 * 24 * time.Hour, nil // default 7 days
}
// Check for special suffixes
if strings.HasSuffix(s, "d") {
days := s[:len(s)-1]
var d int
if _, err := fmt.Sscanf(days, "%d", &d); err != nil {
return 0, fmt.Errorf("invalid days format: %s", s)
}
return time.Duration(d) * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "w") {
weeks := s[:len(s)-1]
var w int
if _, err := fmt.Sscanf(weeks, "%d", &w); err != nil {
return 0, fmt.Errorf("invalid weeks format: %s", s)
}
return time.Duration(w) * 7 * 24 * time.Hour, nil
}
// Try standard Go duration
return time.ParseDuration(s)
}
func init() {
deletedCmd.Flags().StringVar(&deletedSince, "since", "7d", "Show deletions within this time range (e.g., 7d, 30d, 2w)")
deletedCmd.Flags().BoolVar(&deletedAll, "all", false, "Show all tracked deletions")
deletedCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format")
rootCmd.AddCommand(deletedCmd)
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/deletions"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/syncbranch"
@@ -1171,7 +1172,7 @@ func maybeAutoCompactDeletions(ctx context.Context, jsonlPath string) error {
}
// Get retention days (default 7)
retentionDays := deletions.DefaultRetentionDays
retentionDays := configfile.DefaultDeletionsRetentionDays
if retentionStr, err := store.GetConfig(ctx, "deletions.retention_days"); err == nil && retentionStr != "" {
if parsed, err := strconv.Atoi(retentionStr); err == nil && parsed > 0 {
retentionDays = parsed

211
docs/DELETIONS.md Normal file
View File

@@ -0,0 +1,211 @@
# Deletion Tracking
This document describes how bd tracks and propagates deletions across repository clones.
## Overview
When issues are deleted in one clone, those deletions need to propagate to other clones. Without this mechanism, deleted issues would "resurrect" when another clone's database is imported.
The **deletions manifest** (`.beads/deletions.jsonl`) is an append-only log that records every deletion. This file is committed to git and synced across all clones.
## File Format
The deletions manifest is a JSON Lines file where each line is a deletion record:
```jsonl
{"id":"bd-abc","ts":"2025-01-15T10:00:00Z","by":"stevey","reason":"duplicate of bd-xyz"}
{"id":"bd-def","ts":"2025-01-15T10:05:00Z","by":"claude","reason":"cleanup"}
```
### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Issue ID that was deleted |
| `ts` | string | Yes | ISO 8601 UTC timestamp |
| `by` | string | Yes | Actor who performed the deletion |
| `reason` | string | No | Optional context (e.g., "duplicate", "cleanup") |
## Commands
### Deleting Issues
```bash
bd delete bd-42 # Delete single issue
bd delete bd-42 bd-43 bd-44 # Delete multiple issues
bd cleanup -f # Delete all closed issues
```
All deletions are automatically recorded to the manifest.
### Viewing Deletions
```bash
bd deleted # Recent deletions (last 7 days)
bd deleted --since=30d # Deletions in last 30 days
bd deleted --all # All tracked deletions
bd deleted bd-xxx # Lookup specific issue
bd deleted --json # Machine-readable output
```
## Propagation Mechanism
### Export (Local Delete)
1. `bd delete` removes issue from SQLite
2. Deletion record appended to `deletions.jsonl`
3. `bd sync` commits and pushes the manifest
### Import (Remote Delete)
1. `bd sync` pulls updated manifest
2. Import checks each DB issue against manifest
3. If issue ID is in manifest, it's deleted from local DB
4. If issue ID is NOT in manifest and NOT in JSONL:
- Check git history (see fallback below)
- If found in history → deleted upstream, remove locally
- If not found → local unpushed work, keep it
## Git History Fallback
The manifest is pruned periodically to prevent unbounded growth. When a deletion record is pruned but the issue still exists in some clone's DB:
1. Import detects: "DB issue not in JSONL, not in manifest"
2. Falls back to git history search
3. Uses `git log -S` to check if issue ID was ever in JSONL
4. If found in history → it was deleted, remove from DB
5. **Backfill**: Re-append the deletion to manifest (self-healing)
This fallback ensures deletions propagate even after manifest pruning.
## Configuration
### Retention Period
By default, deletion records are kept for 7 days. Configure via:
```bash
bd config set deletions.retention_days 30
```
Or in `.beads/config.yaml`:
```yaml
deletions:
retention_days: 30
```
### Auto-Compact Threshold
Auto-compaction during `bd sync` is opt-in:
```bash
bd config set deletions.auto_compact_threshold 100
```
When the manifest exceeds this threshold, old records are pruned during sync. Set to 0 to disable (default).
### Manual Pruning
```bash
bd compact --retention 7 # Prune records older than 7 days
bd compact --retention 0 # Prune all records (use git fallback)
```
## Size Estimates
- Each record: ~80 bytes
- 7-day retention with 100 deletions/day: ~56KB
- Git compressed: ~10KB
The manifest stays small even with heavy deletion activity.
## Conflict Resolution
When multiple clones delete issues simultaneously:
1. Both append their deletion records
2. Git merges (append-only = no conflicts)
3. Result: duplicate entries for same ID (different timestamps)
4. `LoadDeletions` deduplicates by ID (keeps any entry)
5. Result: deletion propagates correctly
Duplicate records are harmless and cleaned up during pruning.
## Troubleshooting
### Deleted Issue Reappearing
If a deleted issue reappears after sync:
```bash
# Check if in manifest
bd deleted bd-xxx
# Force re-import
bd import --force
# If still appearing, check git history
git log -S '"id":"bd-xxx"' -- .beads/beads.jsonl
```
### Manifest Not Being Committed
Ensure deletions.jsonl is tracked:
```bash
git add .beads/deletions.jsonl
```
And NOT in .gitignore.
### Large Manifest
If the manifest is growing too large:
```bash
# Check size
wc -l .beads/deletions.jsonl
# Manual prune
bd compact --retention 7
# Enable auto-compact
bd config set deletions.auto_compact_threshold 100
```
## Design Rationale
### Why JSONL?
- Append-only: natural for deletion logs
- Human-readable: easy to audit
- Git-friendly: line-based diffs
- No merge conflicts: append = trivial merge
### Why Not Delete from JSONL?
Removing lines from `beads.jsonl` would work but:
- Loses audit trail (who deleted what when)
- Harder to merge (line deletions can conflict)
- Can't distinguish "deleted" from "never existed"
### Why Time-Based Pruning?
- Bounds manifest size
- Git history fallback handles edge cases
- 7-day default handles most sync scenarios
- Configurable for teams with longer sync cycles
### Why Git Fallback?
- Handles pruned records gracefully
- Self-healing via backfill
- Works with shallow clones (partial fallback)
- No data loss from aggressive pruning
## Related
- [CONFIG.md](CONFIG.md) - Configuration options
- [DAEMON.md](DAEMON.md) - Daemon auto-sync behavior
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - General troubleshooting

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
@@ -209,9 +210,6 @@ func Count(path string) (int, error) {
return count, nil
}
// 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
@@ -239,7 +237,16 @@ func PruneDeletions(path string, retentionDays int) (*PruneResult, error) {
cutoff := time.Now().AddDate(0, 0, -retentionDays)
var kept []DeletionRecord
// Convert map to sorted slice for deterministic iteration (bd-wmo)
var allRecords []DeletionRecord
for _, record := range loadResult.Records {
allRecords = append(allRecords, record)
}
sort.Slice(allRecords, func(i, j int) bool {
return allRecords[i].ID < allRecords[j].ID
})
for _, record := range allRecords {
if record.Timestamp.After(cutoff) || record.Timestamp.Equal(cutoff) {
kept = append(kept, record)
} else {

View File

@@ -899,7 +899,7 @@ func checkGitHistoryForDeletions(beadsDir string, ids []string) []string {
if len(ids) <= 10 {
// Small batch: check each ID individually for accuracy
for _, id := range ids {
if wasInGitHistory(repoRoot, jsonlPath, id) {
if wasEverInJSONL(repoRoot, jsonlPath, id) {
deleted = append(deleted, id)
}
}
@@ -916,9 +916,11 @@ func checkGitHistoryForDeletions(beadsDir string, ids []string) []string {
// Prevents hangs on large repositories (bd-f0n).
const gitHistoryTimeout = 30 * time.Second
// wasInGitHistory checks if a single ID was ever in the JSONL via git history.
// Returns true if the ID was found in history (meaning it was deleted).
func wasInGitHistory(repoRoot, jsonlPath, id string) bool {
// wasEverInJSONL checks if a single ID was ever present in the JSONL via git history.
// Returns true if the ID was found in any commit (added or removed).
// The caller is responsible for confirming the ID is NOT currently in JSONL
// to determine that it was deleted (vs still present).
func wasEverInJSONL(repoRoot, jsonlPath, id string) bool {
// git log --all -S "\"id\":\"bd-xxx\"" --oneline -- .beads/beads.jsonl
// This searches for commits that added or removed the ID string
// Note: -S uses literal string matching, not regex, so no escaping needed
@@ -942,8 +944,8 @@ func wasInGitHistory(repoRoot, jsonlPath, id string) bool {
return false
}
// If output is non-empty, the ID was in git history
// This means it was added and then removed (deleted)
// If output is non-empty, the ID was found in git history (was once in JSONL).
// Since caller already verified ID is NOT currently in JSONL, this means deleted.
return len(bytes.TrimSpace(stdout.Bytes())) > 0
}
@@ -978,7 +980,7 @@ func batchCheckGitHistory(repoRoot, jsonlPath string, ids []string) []string {
// Individual checks also have timeout protection
var deleted []string
for _, id := range ids {
if wasInGitHistory(repoRoot, jsonlPath, id) {
if wasEverInJSONL(repoRoot, jsonlPath, id) {
deleted = append(deleted, id)
}
}

View File

@@ -5,10 +5,13 @@ package importer
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/steveyegge/beads/internal/deletions"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
@@ -91,3 +94,276 @@ func TestConcurrentExternalRefUpdates(t *testing.T) {
t.Errorf("Expected last update to win, got title: %s", finalIssue.Title)
}
}
// TestCrossCloneDeletionPropagation tests that deletions propagate across clones
// via the deletions manifest. Simulates:
// 1. Clone A and Clone B both have issue bd-test-123
// 2. Clone A deletes bd-test-123 (recorded in deletions.jsonl)
// 3. Clone B pulls and imports - issue should be purged from Clone B's DB
func TestCrossCloneDeletionPropagation(t *testing.T) {
ctx := context.Background()
// Create temp directory structure for "Clone B" (the clone that receives the deletion)
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 database in .beads/ (required for purgeDeletedIssues to find deletions.jsonl)
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Create an issue in Clone B's database (simulating it was synced before)
issueToDelete := &types.Issue{
ID: "bd-test-123",
Title: "Issue that will be deleted in Clone A",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issueToDelete, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Also create another issue that should NOT be deleted
issueToKeep := &types.Issue{
ID: "bd-test-456",
Title: "Issue that stays",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issueToKeep, "test"); err != nil {
t.Fatalf("Failed to create kept issue: %v", err)
}
// Verify both issues exist
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to search issues: %v", err)
}
if len(issues) != 2 {
t.Fatalf("Expected 2 issues before import, got %d", len(issues))
}
// Simulate Clone A deleting bd-test-123 by writing to deletions manifest
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
record := deletions.DeletionRecord{
ID: "bd-test-123",
Timestamp: time.Now().UTC(),
Actor: "clone-a-user",
Reason: "test deletion",
}
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
t.Fatalf("Failed to write deletion record: %v", err)
}
// Create JSONL with only the kept issue (simulating git pull from remote)
// The deleted issue is NOT in the JSONL (it was removed in Clone A)
jsonlIssues := []*types.Issue{issueToKeep}
// Import with Options that uses the database path (triggers purgeDeletedIssues)
result, err := ImportIssues(ctx, dbPath, store, jsonlIssues, Options{})
if err != nil {
t.Fatalf("Import failed: %v", err)
}
// Verify the purge happened
if result.Purged != 1 {
t.Errorf("Expected 1 purged issue, got %d", result.Purged)
}
if len(result.PurgedIDs) != 1 || result.PurgedIDs[0] != "bd-test-123" {
t.Errorf("Expected purged ID bd-test-123, got %v", result.PurgedIDs)
}
// Verify database state
finalIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to search final issues: %v", err)
}
if len(finalIssues) != 1 {
t.Errorf("Expected 1 issue after import, got %d", len(finalIssues))
}
// The kept issue should still exist
keptIssue, err := store.GetIssue(ctx, "bd-test-456")
if err != nil {
t.Fatalf("Failed to get kept issue: %v", err)
}
if keptIssue == nil {
t.Error("Expected bd-test-456 to still exist")
}
// The deleted issue should be gone
deletedIssue, err := store.GetIssue(ctx, "bd-test-123")
if err != nil {
t.Fatalf("Failed to query deleted issue: %v", err)
}
if deletedIssue != nil {
t.Error("Expected bd-test-123 to be purged")
}
}
// TestLocalUnpushedIssueNotDeleted verifies that local issues that were never
// in git are NOT deleted during import (they are local work, not deletions)
func TestLocalUnpushedIssueNotDeleted(t *testing.T) {
ctx := context.Background()
// Create temp directory structure
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)
}
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Create a local issue that was never exported/pushed
localIssue := &types.Issue{
ID: "bd-local-work",
Title: "Local work in progress",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, localIssue, "test"); err != nil {
t.Fatalf("Failed to create local issue: %v", err)
}
// Create an issue that exists in JSONL (remote)
remoteIssue := &types.Issue{
ID: "bd-remote-123",
Title: "Synced from remote",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, remoteIssue, "test"); err != nil {
t.Fatalf("Failed to create remote issue: %v", err)
}
// Empty deletions manifest (no deletions)
// Don't create the file - LoadDeletions handles missing file gracefully
// JSONL only contains the remote issue (local issue was never exported)
jsonlIssues := []*types.Issue{remoteIssue}
// Import - local issue should NOT be purged
result, err := ImportIssues(ctx, dbPath, store, jsonlIssues, Options{})
if err != nil {
t.Fatalf("Import failed: %v", err)
}
// No purges should happen (not in deletions manifest, not in git history)
if result.Purged != 0 {
t.Errorf("Expected 0 purged issues, got %d (purged: %v)", result.Purged, result.PurgedIDs)
}
// Both issues should still exist
finalIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatalf("Failed to search final issues: %v", err)
}
if len(finalIssues) != 2 {
t.Errorf("Expected 2 issues after import, got %d", len(finalIssues))
}
// Local work should still exist
localFound, _ := store.GetIssue(ctx, "bd-local-work")
if localFound == nil {
t.Error("Local issue was incorrectly purged")
}
}
// TestDeletionWithReason verifies that deletion reason is properly recorded
func TestDeletionWithReason(t *testing.T) {
ctx := context.Background()
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)
}
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Create issue
issue := &types.Issue{
ID: "bd-dup-001",
Title: "Duplicate issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Record deletion with reason "duplicate of bd-orig-001"
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
record := deletions.DeletionRecord{
ID: "bd-dup-001",
Timestamp: time.Now().UTC(),
Actor: "dedup-bot",
Reason: "duplicate of bd-orig-001",
}
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
t.Fatalf("Failed to write deletion: %v", err)
}
// Verify record was written with reason
loadResult, err := deletions.LoadDeletions(deletionsPath)
if err != nil {
t.Fatalf("Failed to load deletions: %v", err)
}
if loaded, ok := loadResult.Records["bd-dup-001"]; !ok {
t.Error("Deletion record not found")
} else {
if loaded.Reason != "duplicate of bd-orig-001" {
t.Errorf("Expected reason 'duplicate of bd-orig-001', got '%s'", loaded.Reason)
}
if loaded.Actor != "dedup-bot" {
t.Errorf("Expected actor 'dedup-bot', got '%s'", loaded.Actor)
}
}
// Import empty JSONL (issue was deleted)
result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{}, Options{})
if err != nil {
t.Fatalf("Import failed: %v", err)
}
if result.Purged != 1 {
t.Errorf("Expected 1 purged, got %d", result.Purged)
}
}

View File

@@ -1090,10 +1090,10 @@ func TestCheckGitHistoryForDeletions_NonGitDir(t *testing.T) {
}
}
func TestWasInGitHistory_NonGitDir(t *testing.T) {
func TestWasEverInJSONL_NonGitDir(t *testing.T) {
// Non-git directory should return false (conservative behavior)
tmpDir := t.TempDir()
result := wasInGitHistory(tmpDir, ".beads/beads.jsonl", "bd-test")
result := wasEverInJSONL(tmpDir, ".beads/beads.jsonl", "bd-test")
if result {
t.Error("Expected false for non-git dir")
}