diff --git a/AGENTS.md b/AGENTS.md index ec141c44..7a9b0bf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 6e85b67f..3b0b9edf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/bd/compact.go b/cmd/bd/compact.go index d7bbe10b..cb319893 100644 --- a/cmd/bd/compact.go +++ b/cmd/bd/compact.go @@ -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() { diff --git a/cmd/bd/deleted.go b/cmd/bd/deleted.go new file mode 100644 index 00000000..54064742 --- /dev/null +++ b/cmd/bd/deleted.go @@ -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) +} diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index e95cd042..da871164 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -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 diff --git a/docs/DELETIONS.md b/docs/DELETIONS.md new file mode 100644 index 00000000..27386558 --- /dev/null +++ b/docs/DELETIONS.md @@ -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 diff --git a/internal/deletions/deletions.go b/internal/deletions/deletions.go index af1c1699..df886858 100644 --- a/internal/deletions/deletions.go +++ b/internal/deletions/deletions.go @@ -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 { diff --git a/internal/importer/importer.go b/internal/importer/importer.go index d54dad35..a5bc3172 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -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) } } diff --git a/internal/importer/importer_integration_test.go b/internal/importer/importer_integration_test.go index a21ab26a..5fcdfd90 100644 --- a/internal/importer/importer_integration_test.go +++ b/internal/importer/importer_integration_test.go @@ -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) + } +} diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index e6cd8e3c..ba332b81 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -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") }