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:
26
AGENTS.md
26
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
194
cmd/bd/deleted.go
Normal 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)
|
||||
}
|
||||
@@ -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
211
docs/DELETIONS.md
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user