Add collision resolution with automatic ID remapping

Implements --resolve-collisions flag for import command to safely handle ID
collisions during branch merges. When enabled, colliding issues are remapped
to new IDs and all text references and dependencies are automatically updated.

Also adds comprehensive tests, branch-merge example, and documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-10-12 17:13:09 -07:00
parent 42e3bb315d
commit 183ded4096
11 changed files with 2086 additions and 34 deletions

View File

@@ -6,8 +6,10 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
@@ -21,11 +23,15 @@ Reads from stdin by default, or use -i flag for file input.
Behavior:
- Existing issues (same ID) are updated
- New issues are created
- Import is atomic (all or nothing)`,
- Collisions (same ID, different content) are detected
- Use --resolve-collisions to automatically remap colliding issues
- Use --dry-run to preview changes without applying them`,
Run: func(cmd *cobra.Command, args []string) {
input, _ := cmd.Flags().GetString("input")
skipUpdate, _ := cmd.Flags().GetBool("skip-existing")
strict, _ := cmd.Flags().GetBool("strict")
resolveCollisions, _ := cmd.Flags().GetBool("resolve-collisions")
dryRun, _ := cmd.Flags().GetBool("dry-run")
// Open input
in := os.Stdin
@@ -43,12 +49,11 @@ Behavior:
in = f
}
// Read and parse JSONL
// Phase 1: Read and parse all JSONL
ctx := context.Background()
scanner := bufio.NewScanner(in)
var created, updated, skipped int
var allIssues []*types.Issue // Store all issues for dependency processing
var allIssues []*types.Issue
lineNum := 0
for scanner.Scan() {
@@ -60,23 +65,106 @@ Behavior:
continue
}
// Parse JSON - first into a map to detect which fields are present
var rawData map[string]interface{}
if err := json.Unmarshal([]byte(line), &rawData); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1)
}
// Then parse into the Issue struct
// Parse JSON
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1)
}
// Store for dependency processing later
allIssues = append(allIssues, &issue)
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
os.Exit(1)
}
// Phase 2: Detect collisions
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
fmt.Fprintf(os.Stderr, "Error: collision detection requires SQLite storage backend\n")
os.Exit(1)
}
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, allIssues)
if err != nil {
fmt.Fprintf(os.Stderr, "Error detecting collisions: %v\n", err)
os.Exit(1)
}
var idMapping map[string]string
var created, updated, skipped int
// Phase 3: Handle collisions
if len(collisionResult.Collisions) > 0 {
// Print collision report
printCollisionReport(collisionResult)
if dryRun {
// In dry-run mode, just print report and exit
fmt.Fprintf(os.Stderr, "\nDry-run mode: no changes made\n")
os.Exit(0)
}
if !resolveCollisions {
// Default behavior: fail on collision (safe mode)
fmt.Fprintf(os.Stderr, "\nCollision detected! Use --resolve-collisions to automatically remap colliding issues.\n")
fmt.Fprintf(os.Stderr, "Or use --dry-run to preview without making changes.\n")
os.Exit(1)
}
// Resolve collisions by scoring and remapping
fmt.Fprintf(os.Stderr, "\nResolving collisions...\n")
// Get all existing issues for scoring
allExistingIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting existing issues: %v\n", err)
os.Exit(1)
}
// Score collisions
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
fmt.Fprintf(os.Stderr, "Error scoring collisions: %v\n", err)
os.Exit(1)
}
// Remap collisions
idMapping, err = sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
if err != nil {
fmt.Fprintf(os.Stderr, "Error remapping collisions: %v\n", err)
os.Exit(1)
}
// Print remapping report
printRemappingReport(idMapping, collisionResult.Collisions)
// Colliding issues were already created with new IDs
created = len(collisionResult.Collisions)
// Remove colliding issues from allIssues (they're already processed)
filteredIssues := make([]*types.Issue, 0)
collidingIDs := make(map[string]bool)
for _, collision := range collisionResult.Collisions {
collidingIDs[collision.ID] = true
}
for _, issue := range allIssues {
if !collidingIDs[issue.ID] {
filteredIssues = append(filteredIssues, issue)
}
}
allIssues = filteredIssues
} else if dryRun {
// No collisions in dry-run mode
fmt.Fprintf(os.Stderr, "No collisions detected.\n")
fmt.Fprintf(os.Stderr, "Would create %d new issues, update %d existing issues\n",
len(collisionResult.NewIssues), len(collisionResult.ExactMatches))
os.Exit(0)
}
// Phase 4: Process remaining issues (exact matches and new issues)
for _, issue := range allIssues {
// Check if issue exists
existing, err := store.GetIssue(ctx, issue.ID)
if err != nil {
@@ -89,7 +177,13 @@ Behavior:
skipped++
continue
}
// Update existing issue - only update fields that are present in JSON
// Update existing issue
// Parse raw JSON to detect which fields are present
var rawData map[string]interface{}
jsonBytes, _ := json.Marshal(issue)
json.Unmarshal(jsonBytes, &rawData)
updates := make(map[string]interface{})
if _, ok := rawData["title"]; ok {
updates["title"] = issue.Title
@@ -133,7 +227,7 @@ Behavior:
updated++
} else {
// Create new issue
if err := store.CreateIssue(ctx, &issue, "import"); err != nil {
if err := store.CreateIssue(ctx, issue, "import"); err != nil {
fmt.Fprintf(os.Stderr, "Error creating issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
@@ -141,12 +235,7 @@ Behavior:
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
os.Exit(1)
}
// Second pass: Process dependencies
// Phase 5: Process dependencies
// Do this after all issues are created to handle forward references
var depsCreated, depsSkipped int
for _, issue := range allIssues {
@@ -206,13 +295,71 @@ Behavior:
fmt.Fprintf(os.Stderr, " (%d already existed)", depsSkipped)
}
}
if len(idMapping) > 0 {
fmt.Fprintf(os.Stderr, ", %d issues remapped", len(idMapping))
}
fmt.Fprintf(os.Stderr, "\n")
},
}
// printCollisionReport prints a detailed report of detected collisions
func printCollisionReport(result *sqlite.CollisionResult) {
fmt.Fprintf(os.Stderr, "\n=== Collision Detection Report ===\n")
fmt.Fprintf(os.Stderr, "Exact matches (idempotent): %d\n", len(result.ExactMatches))
fmt.Fprintf(os.Stderr, "New issues: %d\n", len(result.NewIssues))
fmt.Fprintf(os.Stderr, "COLLISIONS DETECTED: %d\n\n", len(result.Collisions))
if len(result.Collisions) > 0 {
fmt.Fprintf(os.Stderr, "Colliding issues:\n")
for _, collision := range result.Collisions {
fmt.Fprintf(os.Stderr, " %s: %s\n", collision.ID, collision.IncomingIssue.Title)
fmt.Fprintf(os.Stderr, " Conflicting fields: %v\n", collision.ConflictingFields)
}
}
}
// printRemappingReport prints a report of ID remappings with reference scores
func printRemappingReport(idMapping map[string]string, collisions []*sqlite.CollisionDetail) {
fmt.Fprintf(os.Stderr, "\n=== Remapping Report ===\n")
fmt.Fprintf(os.Stderr, "Issues remapped: %d\n\n", len(idMapping))
// Sort by old ID for consistent output
type mapping struct {
oldID string
newID string
score int
}
mappings := make([]mapping, 0, len(idMapping))
scoreMap := make(map[string]int)
for _, collision := range collisions {
scoreMap[collision.ID] = collision.ReferenceScore
}
for oldID, newID := range idMapping {
mappings = append(mappings, mapping{
oldID: oldID,
newID: newID,
score: scoreMap[oldID],
})
}
sort.Slice(mappings, func(i, j int) bool {
return mappings[i].score < mappings[j].score
})
fmt.Fprintf(os.Stderr, "Remappings (sorted by reference count):\n")
for _, m := range mappings {
fmt.Fprintf(os.Stderr, " %s → %s (refs: %d)\n", m.oldID, m.newID, m.score)
}
fmt.Fprintf(os.Stderr, "\nAll text and dependency references have been updated.\n")
}
func init() {
importCmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
importCmd.Flags().BoolP("skip-existing", "s", false, "Skip existing issues instead of updating them")
importCmd.Flags().Bool("strict", false, "Fail on dependency errors instead of treating them as warnings")
importCmd.Flags().Bool("resolve-collisions", false, "Automatically resolve ID collisions by remapping")
importCmd.Flags().Bool("dry-run", false, "Preview collision detection without making changes")
rootCmd.AddCommand(importCmd)
}