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:
@@ -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).
|
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
|
### Issue Types
|
||||||
|
|
||||||
- `bug` - Something broken that needs fixing
|
- `bug` - Something broken that needs fixing
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ echo ".beads/beads.jsonl merge=beads" >> .gitattributes
|
|||||||
**Should be committed to git:**
|
**Should be committed to git:**
|
||||||
- `.gitattributes` - Configures git merge driver for intelligent JSONL merging (critical for team collaboration)
|
- `.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/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/config.yaml` - Repository configuration template
|
||||||
- `.beads/README.md` - Documentation about beads for repository visitors
|
- `.beads/README.md` - Documentation about beads for repository visitors
|
||||||
- `.beads/metadata.json` - Database metadata
|
- `.beads/metadata.json` - Database metadata
|
||||||
|
|||||||
+18
-13
@@ -864,6 +864,9 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Prune old deletion records (do this before JSON output so we can include results)
|
||||||
|
pruneResult, retentionDays := pruneDeletionsManifest()
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
output := map[string]interface{}{
|
output := map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -875,6 +878,13 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
"reduction_pct": reductionPct,
|
"reduction_pct": reductionPct,
|
||||||
"elapsed_ms": elapsed.Milliseconds(),
|
"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)
|
outputJSON(output)
|
||||||
return
|
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(" %d → %d bytes (saved %d, %.1f%%)\n", originalSize, compactedSize, savingBytes, reductionPct)
|
||||||
fmt.Printf(" Time: %v\n", elapsed)
|
fmt.Printf(" Time: %v\n", elapsed)
|
||||||
|
|
||||||
// Prune old deletion records
|
// Report pruning results for human-readable output
|
||||||
pruneDeletionsManifest()
|
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
|
// Schedule auto-flush to export changes
|
||||||
markDirtyAndScheduleFlush()
|
markDirtyAndScheduleFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// pruneDeletionsManifest prunes old deletion records based on retention settings.
|
// 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.
|
// Uses the global dbPath to determine the .beads directory.
|
||||||
func pruneDeletionsManifest() {
|
func pruneDeletionsManifest() (*deletions.PruneResult, int) {
|
||||||
beadsDir := filepath.Dir(dbPath)
|
beadsDir := filepath.Dir(dbPath)
|
||||||
// Determine retention days
|
// Determine retention days
|
||||||
retentionDays := compactRetention
|
retentionDays := compactRetention
|
||||||
@@ -918,17 +930,10 @@ func pruneDeletionsManifest() {
|
|||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to prune deletions: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Warning: failed to prune deletions: %v\n", err)
|
||||||
}
|
}
|
||||||
return
|
return nil, retentionDays
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only report if there were deletions to prune
|
return result, retentionDays
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+2
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/deletions"
|
"github.com/steveyegge/beads/internal/deletions"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/syncbranch"
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
@@ -1171,7 +1172,7 @@ func maybeAutoCompactDeletions(ctx context.Context, jsonlPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get retention days (default 7)
|
// Get retention days (default 7)
|
||||||
retentionDays := deletions.DefaultRetentionDays
|
retentionDays := configfile.DefaultDeletionsRetentionDays
|
||||||
if retentionStr, err := store.GetConfig(ctx, "deletions.retention_days"); err == nil && retentionStr != "" {
|
if retentionStr, err := store.GetConfig(ctx, "deletions.retention_days"); err == nil && retentionStr != "" {
|
||||||
if parsed, err := strconv.Atoi(retentionStr); err == nil && parsed > 0 {
|
if parsed, err := strconv.Atoi(retentionStr); err == nil && parsed > 0 {
|
||||||
retentionDays = parsed
|
retentionDays = parsed
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -209,9 +210,6 @@ func Count(path string) (int, error) {
|
|||||||
return count, nil
|
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.
|
// PruneResult contains the result of a prune operation.
|
||||||
type PruneResult struct {
|
type PruneResult struct {
|
||||||
KeptCount int
|
KeptCount int
|
||||||
@@ -239,7 +237,16 @@ func PruneDeletions(path string, retentionDays int) (*PruneResult, error) {
|
|||||||
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
||||||
var kept []DeletionRecord
|
var kept []DeletionRecord
|
||||||
|
|
||||||
|
// Convert map to sorted slice for deterministic iteration (bd-wmo)
|
||||||
|
var allRecords []DeletionRecord
|
||||||
for _, record := range loadResult.Records {
|
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) {
|
if record.Timestamp.After(cutoff) || record.Timestamp.Equal(cutoff) {
|
||||||
kept = append(kept, record)
|
kept = append(kept, record)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -899,7 +899,7 @@ func checkGitHistoryForDeletions(beadsDir string, ids []string) []string {
|
|||||||
if len(ids) <= 10 {
|
if len(ids) <= 10 {
|
||||||
// Small batch: check each ID individually for accuracy
|
// Small batch: check each ID individually for accuracy
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if wasInGitHistory(repoRoot, jsonlPath, id) {
|
if wasEverInJSONL(repoRoot, jsonlPath, id) {
|
||||||
deleted = append(deleted, id)
|
deleted = append(deleted, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -916,9 +916,11 @@ func checkGitHistoryForDeletions(beadsDir string, ids []string) []string {
|
|||||||
// Prevents hangs on large repositories (bd-f0n).
|
// Prevents hangs on large repositories (bd-f0n).
|
||||||
const gitHistoryTimeout = 30 * time.Second
|
const gitHistoryTimeout = 30 * time.Second
|
||||||
|
|
||||||
// wasInGitHistory checks if a single ID was ever in the JSONL via git history.
|
// wasEverInJSONL checks if a single ID was ever present in the JSONL via git history.
|
||||||
// Returns true if the ID was found in history (meaning it was deleted).
|
// Returns true if the ID was found in any commit (added or removed).
|
||||||
func wasInGitHistory(repoRoot, jsonlPath, id string) bool {
|
// 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
|
// git log --all -S "\"id\":\"bd-xxx\"" --oneline -- .beads/beads.jsonl
|
||||||
// This searches for commits that added or removed the ID string
|
// This searches for commits that added or removed the ID string
|
||||||
// Note: -S uses literal string matching, not regex, so no escaping needed
|
// 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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If output is non-empty, the ID was in git history
|
// If output is non-empty, the ID was found in git history (was once in JSONL).
|
||||||
// This means it was added and then removed (deleted)
|
// Since caller already verified ID is NOT currently in JSONL, this means deleted.
|
||||||
return len(bytes.TrimSpace(stdout.Bytes())) > 0
|
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
|
// Individual checks also have timeout protection
|
||||||
var deleted []string
|
var deleted []string
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if wasInGitHistory(repoRoot, jsonlPath, id) {
|
if wasEverInJSONL(repoRoot, jsonlPath, id) {
|
||||||
deleted = append(deleted, id)
|
deleted = append(deleted, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ package importer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/deletions"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
// Non-git directory should return false (conservative behavior)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
result := wasInGitHistory(tmpDir, ".beads/beads.jsonl", "bd-test")
|
result := wasEverInJSONL(tmpDir, ".beads/beads.jsonl", "bd-test")
|
||||||
if result {
|
if result {
|
||||||
t.Error("Expected false for non-git dir")
|
t.Error("Expected false for non-git dir")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user