feat(tombstones): add migrate-tombstones command and compact pruning
- Add bd migrate-tombstones command (bd-8f9) to convert legacy deletions.jsonl entries to inline tombstones in issues.jsonl - Supports --dry-run to preview changes - Supports --verbose for detailed progress - Archives deletions.jsonl with .migrated suffix after migration - Update bd compact to prune expired tombstones (bd-okh) - All compact modes now prune tombstones older than 30-day TTL - Reports count of pruned tombstones in output - Add resurrection merge test (bd-bob) - Tests scenario where base is tombstone but both left/right resurrect 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/configfile"
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/deletions"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -56,6 +57,11 @@ Deletions Pruning:
|
|||||||
unbounded growth. Default retention is 3 days (configurable via --retention
|
unbounded growth. Default retention is 3 days (configurable via --retention
|
||||||
or deletions_retention_days in metadata.json).
|
or deletions_retention_days in metadata.json).
|
||||||
|
|
||||||
|
Tombstone Pruning:
|
||||||
|
All modes also prune expired tombstones from issues.jsonl. Tombstones are
|
||||||
|
soft-delete markers that prevent resurrection of deleted issues. After the
|
||||||
|
TTL expires (default 30 days), tombstones are removed to save space.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Agent-driven workflow (recommended)
|
# Agent-driven workflow (recommended)
|
||||||
bd compact --analyze --json # Get candidates with full content
|
bd compact --analyze --json # Get candidates with full content
|
||||||
@@ -306,6 +312,14 @@ func runCompactSingle(ctx context.Context, compactor *compact.Compactor, store *
|
|||||||
// Prune old deletion records
|
// Prune old deletion records
|
||||||
pruneDeletionsManifest()
|
pruneDeletionsManifest()
|
||||||
|
|
||||||
|
// Prune expired tombstones (bd-okh)
|
||||||
|
if tombstonePruneResult, err := pruneExpiredTombstones(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err)
|
||||||
|
} else if tombstonePruneResult != nil && tombstonePruneResult.PrunedCount > 0 {
|
||||||
|
fmt.Printf("\nTombstones pruned: %d expired (older than %d days)\n",
|
||||||
|
tombstonePruneResult.PrunedCount, tombstonePruneResult.TTLDays)
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule auto-flush to export changes
|
// Schedule auto-flush to export changes
|
||||||
markDirtyAndScheduleFlush()
|
markDirtyAndScheduleFlush()
|
||||||
}
|
}
|
||||||
@@ -433,6 +447,14 @@ func runCompactAll(ctx context.Context, compactor *compact.Compactor, store *sql
|
|||||||
// Prune old deletion records
|
// Prune old deletion records
|
||||||
pruneDeletionsManifest()
|
pruneDeletionsManifest()
|
||||||
|
|
||||||
|
// Prune expired tombstones (bd-okh)
|
||||||
|
if tombstonePruneResult, err := pruneExpiredTombstones(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err)
|
||||||
|
} else if tombstonePruneResult != nil && tombstonePruneResult.PrunedCount > 0 {
|
||||||
|
fmt.Printf("\nTombstones pruned: %d expired (older than %d days)\n",
|
||||||
|
tombstonePruneResult.PrunedCount, tombstonePruneResult.TTLDays)
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule auto-flush to export changes
|
// Schedule auto-flush to export changes
|
||||||
if successCount > 0 {
|
if successCount > 0 {
|
||||||
markDirtyAndScheduleFlush()
|
markDirtyAndScheduleFlush()
|
||||||
@@ -871,6 +893,12 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
// Prune old deletion records (do this before JSON output so we can include results)
|
// Prune old deletion records (do this before JSON output so we can include results)
|
||||||
pruneResult, retentionDays := pruneDeletionsManifest()
|
pruneResult, retentionDays := pruneDeletionsManifest()
|
||||||
|
|
||||||
|
// Prune expired tombstones from issues.jsonl (bd-okh)
|
||||||
|
tombstonePruneResult, tombstoneErr := pruneExpiredTombstones()
|
||||||
|
if tombstoneErr != nil && !jsonOutput {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", tombstoneErr)
|
||||||
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
output := map[string]interface{}{
|
output := map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -889,6 +917,13 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
"retention_days": retentionDays,
|
"retention_days": retentionDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include tombstone pruning results (bd-okh)
|
||||||
|
if tombstonePruneResult != nil && tombstonePruneResult.PrunedCount > 0 {
|
||||||
|
output["tombstones_pruned"] = map[string]interface{}{
|
||||||
|
"count": tombstonePruneResult.PrunedCount,
|
||||||
|
"ttl_days": tombstonePruneResult.TTLDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
outputJSON(output)
|
outputJSON(output)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -902,6 +937,12 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
fmt.Printf("\nDeletions pruned: %d records older than %d days removed\n", pruneResult.PrunedCount, retentionDays)
|
fmt.Printf("\nDeletions pruned: %d records older than %d days removed\n", pruneResult.PrunedCount, retentionDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report tombstone pruning results (bd-okh)
|
||||||
|
if tombstonePruneResult != nil && tombstonePruneResult.PrunedCount > 0 {
|
||||||
|
fmt.Printf("\nTombstones pruned: %d expired tombstones (older than %d days) removed\n",
|
||||||
|
tombstonePruneResult.PrunedCount, tombstonePruneResult.TTLDays)
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule auto-flush to export changes
|
// Schedule auto-flush to export changes
|
||||||
markDirtyAndScheduleFlush()
|
markDirtyAndScheduleFlush()
|
||||||
}
|
}
|
||||||
@@ -940,6 +981,101 @@ func pruneDeletionsManifest() (*deletions.PruneResult, int) {
|
|||||||
return result, retentionDays
|
return result, retentionDays
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TombstonePruneResult contains the results of tombstone pruning
|
||||||
|
type TombstonePruneResult struct {
|
||||||
|
PrunedCount int
|
||||||
|
PrunedIDs []string
|
||||||
|
TTLDays int
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneExpiredTombstones reads issues.jsonl, removes expired tombstones,
|
||||||
|
// and writes back the pruned file. Returns the prune result.
|
||||||
|
func pruneExpiredTombstones() (*TombstonePruneResult, error) {
|
||||||
|
beadsDir := filepath.Dir(dbPath)
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
// Check if issues.jsonl exists
|
||||||
|
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
|
||||||
|
return &TombstonePruneResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all issues
|
||||||
|
// nolint:gosec // G304: issuesPath is controlled from beadsDir
|
||||||
|
file, err := os.Open(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open issues.jsonl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allIssues []*types.Issue
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Skip corrupt lines
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allIssues = append(allIssues, &issue)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Determine TTL
|
||||||
|
ttl := types.DefaultTombstoneTTL
|
||||||
|
ttlDays := int(ttl.Hours() / 24)
|
||||||
|
|
||||||
|
// Filter out expired tombstones
|
||||||
|
var kept []*types.Issue
|
||||||
|
var prunedIDs []string
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
if issue.IsExpired(ttl) {
|
||||||
|
prunedIDs = append(prunedIDs, issue.ID)
|
||||||
|
} else {
|
||||||
|
kept = append(kept, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prunedIDs) == 0 {
|
||||||
|
return &TombstonePruneResult{TTLDays: ttlDays}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back the pruned file atomically
|
||||||
|
dir := filepath.Dir(issuesPath)
|
||||||
|
base := filepath.Base(issuesPath)
|
||||||
|
tempFile, err := os.CreateTemp(dir, base+".prune.*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(tempFile)
|
||||||
|
for _, issue := range kept {
|
||||||
|
if err := encoder.Encode(issue); err != nil {
|
||||||
|
tempFile.Close()
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return nil, fmt.Errorf("failed to write issue %s: %w", issue.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tempFile.Close(); err != nil {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return nil, fmt.Errorf("failed to close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically replace
|
||||||
|
if err := os.Rename(tempPath, issuesPath); err != nil {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return nil, fmt.Errorf("failed to replace issues.jsonl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TombstonePruneResult{
|
||||||
|
PrunedCount: len(prunedIDs),
|
||||||
|
PrunedIDs: prunedIDs,
|
||||||
|
TTLDays: ttlDays,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
|
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
|
||||||
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")
|
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -384,3 +386,178 @@ func TestCompactInitCommand(t *testing.T) {
|
|||||||
t.Error("compact command should have --json flag")
|
t.Error("compact command should have --json flag")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPruneExpiredTombstones(t *testing.T) {
|
||||||
|
// Setup: create a temp .beads directory with issues.jsonl
|
||||||
|
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 issues.jsonl with mix of live issues, fresh tombstones, and expired tombstones
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
freshTombstoneTime := now.Add(-10 * 24 * time.Hour) // 10 days ago - NOT expired
|
||||||
|
expiredTombstoneTime := now.Add(-60 * 24 * time.Hour) // 60 days ago - expired (> 30 day TTL)
|
||||||
|
|
||||||
|
issues := []*types.Issue{
|
||||||
|
{
|
||||||
|
ID: "test-live",
|
||||||
|
Title: "Live issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: now.Add(-5 * 24 * time.Hour),
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "test-fresh-tombstone",
|
||||||
|
Title: "(deleted)",
|
||||||
|
Status: types.StatusTombstone,
|
||||||
|
Priority: 0,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: now.Add(-20 * 24 * time.Hour),
|
||||||
|
UpdatedAt: freshTombstoneTime,
|
||||||
|
DeletedAt: &freshTombstoneTime,
|
||||||
|
DeletedBy: "alice",
|
||||||
|
DeleteReason: "duplicate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "test-expired-tombstone",
|
||||||
|
Title: "(deleted)",
|
||||||
|
Status: types.StatusTombstone,
|
||||||
|
Priority: 0,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: now.Add(-90 * 24 * time.Hour),
|
||||||
|
UpdatedAt: expiredTombstoneTime,
|
||||||
|
DeletedAt: &expiredTombstoneTime,
|
||||||
|
DeletedBy: "bob",
|
||||||
|
DeleteReason: "obsolete",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write issues to JSONL
|
||||||
|
file, err := os.Create(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
for _, issue := range issues {
|
||||||
|
if err := encoder.Encode(issue); err != nil {
|
||||||
|
file.Close()
|
||||||
|
t.Fatalf("Failed to write issue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Save original dbPath and restore after test
|
||||||
|
originalDBPath := dbPath
|
||||||
|
defer func() { dbPath = originalDBPath }()
|
||||||
|
dbPath = filepath.Join(beadsDir, "beads.db")
|
||||||
|
|
||||||
|
// Run pruning
|
||||||
|
result, err := pruneExpiredTombstones()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pruneExpiredTombstones failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
if result.PrunedCount != 1 {
|
||||||
|
t.Errorf("Expected 1 pruned tombstone, got %d", result.PrunedCount)
|
||||||
|
}
|
||||||
|
if len(result.PrunedIDs) != 1 || result.PrunedIDs[0] != "test-expired-tombstone" {
|
||||||
|
t.Errorf("Expected PrunedIDs [test-expired-tombstone], got %v", result.PrunedIDs)
|
||||||
|
}
|
||||||
|
if result.TTLDays != 30 {
|
||||||
|
t.Errorf("Expected TTLDays 30, got %d", result.TTLDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file was updated correctly
|
||||||
|
file, err = os.Open(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to reopen issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var remaining []*types.Issue
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Fatalf("Failed to decode issue: %v", err)
|
||||||
|
}
|
||||||
|
remaining = append(remaining, &issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remaining) != 2 {
|
||||||
|
t.Fatalf("Expected 2 remaining issues, got %d", len(remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify live issue and fresh tombstone remain
|
||||||
|
ids := make(map[string]bool)
|
||||||
|
for _, issue := range remaining {
|
||||||
|
ids[issue.ID] = true
|
||||||
|
}
|
||||||
|
if !ids["test-live"] {
|
||||||
|
t.Error("Live issue should remain")
|
||||||
|
}
|
||||||
|
if !ids["test-fresh-tombstone"] {
|
||||||
|
t.Error("Fresh tombstone should remain")
|
||||||
|
}
|
||||||
|
if ids["test-expired-tombstone"] {
|
||||||
|
t.Error("Expired tombstone should have been pruned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneExpiredTombstones_NoTombstones(t *testing.T) {
|
||||||
|
// Setup: create a temp .beads directory with only live issues
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
issue := &types.Issue{
|
||||||
|
ID: "test-live",
|
||||||
|
Title: "Live issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
if err := encoder.Encode(issue); err != nil {
|
||||||
|
file.Close()
|
||||||
|
t.Fatalf("Failed to write issue: %v", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Save original dbPath and restore after test
|
||||||
|
originalDBPath := dbPath
|
||||||
|
defer func() { dbPath = originalDBPath }()
|
||||||
|
dbPath = filepath.Join(beadsDir, "beads.db")
|
||||||
|
|
||||||
|
// Run pruning - should return zero pruned
|
||||||
|
result, err := pruneExpiredTombstones()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pruneExpiredTombstones failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.PrunedCount != 0 {
|
||||||
|
t.Errorf("Expected 0 pruned tombstones, got %d", result.PrunedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
291
cmd/bd/migrate_tombstones.go
Normal file
291
cmd/bd/migrate_tombstones.go
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/deletions"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrateTombstonesCmd = &cobra.Command{
|
||||||
|
Use: "migrate-tombstones",
|
||||||
|
Short: "Convert deletions.jsonl entries to inline tombstones",
|
||||||
|
Long: `Migrate legacy deletions.jsonl entries to inline tombstones in issues.jsonl.
|
||||||
|
|
||||||
|
This command converts existing deletion records from the legacy deletions.jsonl
|
||||||
|
manifest to inline tombstone entries in issues.jsonl. This is part of the
|
||||||
|
transition from separate deletion tracking to unified tombstone-based deletion.
|
||||||
|
|
||||||
|
The migration:
|
||||||
|
1. Reads existing deletions from deletions.jsonl
|
||||||
|
2. Checks issues.jsonl for already-existing tombstones
|
||||||
|
3. Creates tombstone entries for unmigrated deletions
|
||||||
|
4. Appends new tombstones to issues.jsonl
|
||||||
|
5. Archives deletions.jsonl with .migrated suffix
|
||||||
|
|
||||||
|
Use --dry-run to preview changes without modifying files.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd migrate-tombstones # Migrate deletions to tombstones
|
||||||
|
bd migrate-tombstones --dry-run # Preview what would be migrated
|
||||||
|
bd migrate-tombstones --verbose # Show detailed progress`,
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
verbose, _ := cmd.Flags().GetBool("verbose")
|
||||||
|
|
||||||
|
// Block writes in readonly mode
|
||||||
|
if !dryRun {
|
||||||
|
CheckReadonly("migrate-tombstones")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find .beads directory
|
||||||
|
beadsDir := findBeadsDir()
|
||||||
|
if beadsDir == "" {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "no_beads_directory",
|
||||||
|
"message": "No .beads directory found. Run 'bd init' first.",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n")
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check paths
|
||||||
|
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
// Load existing deletions
|
||||||
|
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||||
|
if err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "load_deletions_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading deletions.jsonl: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loadResult.Records) == 0 {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"status": "noop",
|
||||||
|
"message": "No deletions to migrate",
|
||||||
|
"migrated": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Println("No deletions.jsonl entries to migrate")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print warnings from loading
|
||||||
|
for _, warning := range loadResult.Warnings {
|
||||||
|
if !jsonOutput {
|
||||||
|
color.Yellow("Warning: %s\n", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing issues.jsonl to find existing tombstones
|
||||||
|
existingTombstones := make(map[string]bool)
|
||||||
|
if _, err := os.Stat(issuesPath); err == nil {
|
||||||
|
// nolint:gosec // G304: issuesPath is controlled from beadsDir
|
||||||
|
file, err := os.Open(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "load_issues_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening issues.jsonl: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Skip corrupt lines, continue reading
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if issue.IsTombstone() {
|
||||||
|
existingTombstones[issue.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which deletions need migration
|
||||||
|
var toMigrate []deletions.DeletionRecord
|
||||||
|
var skippedIDs []string
|
||||||
|
for id, record := range loadResult.Records {
|
||||||
|
if existingTombstones[id] {
|
||||||
|
skippedIDs = append(skippedIDs, id)
|
||||||
|
if verbose && !jsonOutput {
|
||||||
|
fmt.Printf(" Skipping %s (tombstone already exists)\n", id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toMigrate = append(toMigrate, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toMigrate) == 0 {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"status": "noop",
|
||||||
|
"message": "All deletions already migrated to tombstones",
|
||||||
|
"migrated": 0,
|
||||||
|
"skipped": len(skippedIDs),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Printf("All %d deletion(s) already have tombstones in issues.jsonl\n", len(skippedIDs))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dry run - just report what would happen
|
||||||
|
if dryRun {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"dry_run": true,
|
||||||
|
"would_migrate": len(toMigrate),
|
||||||
|
"skipped": len(skippedIDs),
|
||||||
|
"total": len(loadResult.Records),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Println("Dry run mode - no changes will be made")
|
||||||
|
fmt.Printf("\nWould migrate %d deletion(s) to tombstones:\n", len(toMigrate))
|
||||||
|
for _, record := range toMigrate {
|
||||||
|
fmt.Printf(" - %s (deleted %s by %s)\n",
|
||||||
|
record.ID,
|
||||||
|
record.Timestamp.Format("2006-01-02"),
|
||||||
|
record.Actor)
|
||||||
|
}
|
||||||
|
if len(skippedIDs) > 0 {
|
||||||
|
fmt.Printf("\nWould skip %d already-migrated deletion(s)\n", len(skippedIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform migration - append tombstones to issues.jsonl
|
||||||
|
if verbose && !jsonOutput {
|
||||||
|
fmt.Printf("Creating %d tombstone(s)...\n", len(toMigrate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open issues.jsonl for appending
|
||||||
|
// nolint:gosec // G304: issuesPath is controlled from beadsDir
|
||||||
|
file, err := os.OpenFile(issuesPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "open_issues_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening issues.jsonl for append: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
var migratedIDs []string
|
||||||
|
for _, record := range toMigrate {
|
||||||
|
tombstone := convertDeletionRecordToTombstone(record)
|
||||||
|
if err := encoder.Encode(tombstone); err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "write_tombstone_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
"issue_id": record.ID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error writing tombstone for %s: %v\n", record.ID, err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
migratedIDs = append(migratedIDs, record.ID)
|
||||||
|
if verbose && !jsonOutput {
|
||||||
|
fmt.Printf(" ✓ Created tombstone for %s\n", record.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive deletions.jsonl
|
||||||
|
archivePath := deletionsPath + ".migrated"
|
||||||
|
if err := os.Rename(deletionsPath, archivePath); err != nil {
|
||||||
|
// Warn but don't fail - tombstones were already created
|
||||||
|
if !jsonOutput {
|
||||||
|
color.Yellow("Warning: could not archive deletions.jsonl: %v\n", err)
|
||||||
|
}
|
||||||
|
} else if verbose && !jsonOutput {
|
||||||
|
fmt.Printf(" ✓ Archived deletions.jsonl to %s\n", filepath.Base(archivePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success output
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"migrated": len(migratedIDs),
|
||||||
|
"skipped": len(skippedIDs),
|
||||||
|
"total": len(loadResult.Records),
|
||||||
|
"archive": archivePath,
|
||||||
|
"migrated_ids": migratedIDs,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
color.Green("\n✓ Migration complete\n\n")
|
||||||
|
fmt.Printf(" Migrated: %d tombstone(s)\n", len(migratedIDs))
|
||||||
|
if len(skippedIDs) > 0 {
|
||||||
|
fmt.Printf(" Skipped: %d (already had tombstones)\n", len(skippedIDs))
|
||||||
|
}
|
||||||
|
fmt.Printf(" Archived: %s\n", filepath.Base(archivePath))
|
||||||
|
fmt.Println("\nNext steps:")
|
||||||
|
fmt.Println(" 1. Run 'bd sync' to propagate tombstones to remote")
|
||||||
|
fmt.Println(" 2. Other clones will receive tombstones on next sync")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertDeletionRecordToTombstone creates a tombstone issue from a deletion record.
|
||||||
|
// This is similar to the importer's convertDeletionToTombstone but operates on
|
||||||
|
// deletions.DeletionRecord directly.
|
||||||
|
func convertDeletionRecordToTombstone(del deletions.DeletionRecord) *types.Issue {
|
||||||
|
deletedAt := del.Timestamp
|
||||||
|
return &types.Issue{
|
||||||
|
ID: del.ID,
|
||||||
|
Title: "(deleted)",
|
||||||
|
Description: "",
|
||||||
|
Status: types.StatusTombstone,
|
||||||
|
Priority: 0, // Unknown priority (0 = unset)
|
||||||
|
IssueType: types.TypeTask, // Default type (must be valid)
|
||||||
|
CreatedAt: del.Timestamp,
|
||||||
|
UpdatedAt: del.Timestamp,
|
||||||
|
DeletedAt: &deletedAt,
|
||||||
|
DeletedBy: del.Actor,
|
||||||
|
DeleteReason: del.Reason,
|
||||||
|
OriginalType: "", // Not available in legacy deletions.jsonl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
migrateTombstonesCmd.Flags().Bool("dry-run", false, "Preview changes without modifying files")
|
||||||
|
migrateTombstonesCmd.Flags().Bool("verbose", false, "Show detailed progress")
|
||||||
|
migrateTombstonesCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||||
|
rootCmd.AddCommand(migrateTombstonesCmd)
|
||||||
|
}
|
||||||
226
cmd/bd/migrate_tombstones_test.go
Normal file
226
cmd/bd/migrate_tombstones_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/deletions"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrateTombstones_NoDeletions(t *testing.T) {
|
||||||
|
// Setup: create temp .beads directory with no deletions.jsonl
|
||||||
|
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 empty issues.jsonl
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(issuesPath, []byte{}, 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run in temp dir
|
||||||
|
oldWd, _ := os.Getwd()
|
||||||
|
defer os.Chdir(oldWd)
|
||||||
|
os.Chdir(tmpDir)
|
||||||
|
|
||||||
|
// The command should report no deletions to migrate
|
||||||
|
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||||
|
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDeletions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loadResult.Records) != 0 {
|
||||||
|
t.Errorf("Expected 0 deletions, got %d", len(loadResult.Records))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateTombstones_WithDeletions(t *testing.T) {
|
||||||
|
// Setup: create temp .beads directory
|
||||||
|
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 deletions.jsonl with some entries
|
||||||
|
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||||
|
deleteTime := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
records := []deletions.DeletionRecord{
|
||||||
|
{ID: "test-abc", Timestamp: deleteTime, Actor: "alice", Reason: "duplicate"},
|
||||||
|
{ID: "test-def", Timestamp: deleteTime.Add(-1 * time.Hour), Actor: "bob", Reason: "obsolete"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
|
||||||
|
t.Fatalf("Failed to write deletion: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty issues.jsonl
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(issuesPath, []byte{}, 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load deletions
|
||||||
|
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDeletions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loadResult.Records) != 2 {
|
||||||
|
t.Fatalf("Expected 2 deletions, got %d", len(loadResult.Records))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate migration by converting to tombstones
|
||||||
|
var tombstones []*types.Issue
|
||||||
|
for _, record := range loadResult.Records {
|
||||||
|
tombstones = append(tombstones, convertDeletionRecordToTombstone(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tombstone fields
|
||||||
|
for _, ts := range tombstones {
|
||||||
|
if ts.Status != types.StatusTombstone {
|
||||||
|
t.Errorf("Expected status tombstone, got %s", ts.Status)
|
||||||
|
}
|
||||||
|
if ts.DeletedAt == nil {
|
||||||
|
t.Error("Expected DeletedAt to be set")
|
||||||
|
}
|
||||||
|
if ts.DeletedBy == "" {
|
||||||
|
t.Error("Expected DeletedBy to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateTombstones_SkipsExistingTombstones(t *testing.T) {
|
||||||
|
// Setup: create temp .beads directory
|
||||||
|
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 deletions.jsonl with some entries
|
||||||
|
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||||
|
deleteTime := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
records := []deletions.DeletionRecord{
|
||||||
|
{ID: "test-abc", Timestamp: deleteTime, Actor: "alice", Reason: "duplicate"},
|
||||||
|
{ID: "test-def", Timestamp: deleteTime.Add(-1 * time.Hour), Actor: "bob", Reason: "obsolete"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
|
||||||
|
t.Fatalf("Failed to write deletion: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create issues.jsonl with an existing tombstone for test-abc
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
existingTombstone := types.Issue{
|
||||||
|
ID: "test-abc",
|
||||||
|
Title: "(deleted)",
|
||||||
|
Status: types.StatusTombstone,
|
||||||
|
DeletedBy: "alice",
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
if err := encoder.Encode(existingTombstone); err != nil {
|
||||||
|
file.Close()
|
||||||
|
t.Fatalf("Failed to write existing tombstone: %v", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Load existing tombstones
|
||||||
|
existingTombstones := make(map[string]bool)
|
||||||
|
file, _ = os.Open(issuesPath)
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if issue.IsTombstone() {
|
||||||
|
existingTombstones[issue.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Load deletions
|
||||||
|
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDeletions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count what should be migrated vs skipped
|
||||||
|
var toMigrate, skipped int
|
||||||
|
for id := range loadResult.Records {
|
||||||
|
if existingTombstones[id] {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
toMigrate++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toMigrate != 1 {
|
||||||
|
t.Errorf("Expected 1 to migrate, got %d", toMigrate)
|
||||||
|
}
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Errorf("Expected 1 skipped, got %d", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertDeletionRecordToTombstone(t *testing.T) {
|
||||||
|
deleteTime := time.Now().Add(-24 * time.Hour)
|
||||||
|
record := deletions.DeletionRecord{
|
||||||
|
ID: "test-xyz",
|
||||||
|
Timestamp: deleteTime,
|
||||||
|
Actor: "alice",
|
||||||
|
Reason: "test reason",
|
||||||
|
}
|
||||||
|
|
||||||
|
tombstone := convertDeletionRecordToTombstone(record)
|
||||||
|
|
||||||
|
if tombstone.ID != "test-xyz" {
|
||||||
|
t.Errorf("Expected ID test-xyz, got %s", tombstone.ID)
|
||||||
|
}
|
||||||
|
if tombstone.Status != types.StatusTombstone {
|
||||||
|
t.Errorf("Expected status tombstone, got %s", tombstone.Status)
|
||||||
|
}
|
||||||
|
if tombstone.Title != "(deleted)" {
|
||||||
|
t.Errorf("Expected title '(deleted)', got %s", tombstone.Title)
|
||||||
|
}
|
||||||
|
if tombstone.DeletedBy != "alice" {
|
||||||
|
t.Errorf("Expected DeletedBy 'alice', got %s", tombstone.DeletedBy)
|
||||||
|
}
|
||||||
|
if tombstone.DeleteReason != "test reason" {
|
||||||
|
t.Errorf("Expected DeleteReason 'test reason', got %s", tombstone.DeleteReason)
|
||||||
|
}
|
||||||
|
if tombstone.DeletedAt == nil {
|
||||||
|
t.Error("Expected DeletedAt to be set")
|
||||||
|
} else if !tombstone.DeletedAt.Equal(deleteTime) {
|
||||||
|
t.Errorf("Expected DeletedAt %v, got %v", deleteTime, *tombstone.DeletedAt)
|
||||||
|
}
|
||||||
|
if tombstone.Priority != 0 {
|
||||||
|
t.Errorf("Expected priority 0 (unknown), got %d", tombstone.Priority)
|
||||||
|
}
|
||||||
|
if tombstone.IssueType != types.TypeTask {
|
||||||
|
t.Errorf("Expected type task, got %s", tombstone.IssueType)
|
||||||
|
}
|
||||||
|
if tombstone.OriginalType != "" {
|
||||||
|
t.Errorf("Expected empty OriginalType, got %s", tombstone.OriginalType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2058,3 +2058,147 @@ func TestIsExpiredTombstone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMerge3Way_TombstoneBaseBothLiveResurrection tests the scenario where
|
||||||
|
// the base version is a tombstone but both left and right have live versions.
|
||||||
|
// This can happen if Clone A deletes an issue, Clones B and C sync (getting tombstone),
|
||||||
|
// then both B and C independently recreate an issue with same ID. (bd-bob)
|
||||||
|
func TestMerge3Way_TombstoneBaseBothLiveResurrection(t *testing.T) {
|
||||||
|
// Base is a tombstone (issue was deleted)
|
||||||
|
baseTombstone := Issue{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
Title: "Original title",
|
||||||
|
Status: StatusTombstone,
|
||||||
|
Priority: 2,
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
UpdatedAt: "2024-01-05T00:00:00Z",
|
||||||
|
CreatedBy: "user1",
|
||||||
|
DeletedAt: time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339), // 10 days ago
|
||||||
|
DeletedBy: "user2",
|
||||||
|
DeleteReason: "Obsolete",
|
||||||
|
OriginalType: "task",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left resurrects the issue with new content
|
||||||
|
leftLive := Issue{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
Title: "Resurrected by left",
|
||||||
|
Status: "open",
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: "task",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
UpdatedAt: "2024-01-10T00:00:00Z", // Left is older
|
||||||
|
CreatedBy: "user1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right also resurrects with different content
|
||||||
|
rightLive := Issue{
|
||||||
|
ID: "bd-abc123",
|
||||||
|
Title: "Resurrected by right",
|
||||||
|
Status: "in_progress",
|
||||||
|
Priority: 1, // Higher priority (lower number)
|
||||||
|
IssueType: "bug",
|
||||||
|
CreatedAt: "2024-01-01T00:00:00Z",
|
||||||
|
UpdatedAt: "2024-01-15T00:00:00Z", // Right is newer
|
||||||
|
CreatedBy: "user1",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("both sides resurrect with different content - standard merge applies", func(t *testing.T) {
|
||||||
|
base := []Issue{baseTombstone}
|
||||||
|
left := []Issue{leftLive}
|
||||||
|
right := []Issue{rightLive}
|
||||||
|
|
||||||
|
result, conflicts := merge3Way(base, left, right)
|
||||||
|
|
||||||
|
// Should not have conflicts - merge rules apply
|
||||||
|
if len(conflicts) != 0 {
|
||||||
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
||||||
|
}
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := result[0]
|
||||||
|
|
||||||
|
// Issue should be live (not tombstone)
|
||||||
|
if merged.Status == StatusTombstone {
|
||||||
|
t.Error("expected live issue after both sides resurrected, got tombstone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title: right wins because it has later UpdatedAt
|
||||||
|
if merged.Title != "Resurrected by right" {
|
||||||
|
t.Errorf("expected title from right (later UpdatedAt), got %q", merged.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority: higher priority wins (lower number = more urgent)
|
||||||
|
if merged.Priority != 1 {
|
||||||
|
t.Errorf("expected priority 1 (higher), got %d", merged.Priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status: standard 3-way merge applies. When both sides changed from base,
|
||||||
|
// left wins (standard merge conflict resolution). Note: status does NOT use
|
||||||
|
// UpdatedAt tiebreaker like title does - it uses mergeField which picks left.
|
||||||
|
if merged.Status != "open" {
|
||||||
|
t.Errorf("expected status 'open' from left (both changed from base), got %q", merged.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tombstone fields should NOT be present on merged result
|
||||||
|
if merged.DeletedAt != "" {
|
||||||
|
t.Errorf("expected empty DeletedAt on resurrected issue, got %q", merged.DeletedAt)
|
||||||
|
}
|
||||||
|
if merged.DeletedBy != "" {
|
||||||
|
t.Errorf("expected empty DeletedBy on resurrected issue, got %q", merged.DeletedBy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("both resurrect with same status - no conflict", func(t *testing.T) {
|
||||||
|
leftOpen := leftLive
|
||||||
|
leftOpen.Status = "open"
|
||||||
|
rightOpen := rightLive
|
||||||
|
rightOpen.Status = "open"
|
||||||
|
|
||||||
|
base := []Issue{baseTombstone}
|
||||||
|
left := []Issue{leftOpen}
|
||||||
|
right := []Issue{rightOpen}
|
||||||
|
|
||||||
|
result, conflicts := merge3Way(base, left, right)
|
||||||
|
|
||||||
|
if len(conflicts) != 0 {
|
||||||
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
||||||
|
}
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
if result[0].Status != "open" {
|
||||||
|
t.Errorf("expected status 'open', got %q", result[0].Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("one side closes after resurrection", func(t *testing.T) {
|
||||||
|
// Left resurrects and keeps open
|
||||||
|
leftOpen := leftLive
|
||||||
|
leftOpen.Status = "open"
|
||||||
|
|
||||||
|
// Right resurrects and then closes
|
||||||
|
rightClosed := rightLive
|
||||||
|
rightClosed.Status = "closed"
|
||||||
|
rightClosed.ClosedAt = "2024-01-16T00:00:00Z"
|
||||||
|
|
||||||
|
base := []Issue{baseTombstone}
|
||||||
|
left := []Issue{leftOpen}
|
||||||
|
right := []Issue{rightClosed}
|
||||||
|
|
||||||
|
result, conflicts := merge3Way(base, left, right)
|
||||||
|
|
||||||
|
if len(conflicts) != 0 {
|
||||||
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
||||||
|
}
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
||||||
|
}
|
||||||
|
// Closed should win over open
|
||||||
|
if result[0].Status != "closed" {
|
||||||
|
t.Errorf("expected closed to win over open, got %q", result[0].Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user