Merge pull request #584 from rsnodgrass/repair-hashes

fix(rename-prefix): use hash IDs instead of sequential in --repair mode
This commit is contained in:
Steve Yegge
2025-12-16 00:42:32 -08:00
committed by GitHub
7 changed files with 1155 additions and 291 deletions

View File

@@ -5,12 +5,30 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/merge"
"github.com/steveyegge/beads/internal/storage"
)
// isIssueNotFoundError checks if the error indicates the issue doesn't exist in the database.
//
// During 3-way merge, we try to delete issues that were removed remotely. However, the issue
// may already be gone from the local database due to:
// - Already tombstoned by a previous sync/import
// - Never existed locally (multi-repo scenarios, partial clones)
// - Deleted by user between export and import phases
//
// In all these cases, "issue not found" is success from the merge's perspective - the goal
// is to ensure the issue is deleted, and it already is. We only fail on actual database errors.
func isIssueNotFoundError(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "issue not found:")
}
// getVersion returns the current bd version
func getVersion() string {
return Version
@@ -74,11 +92,26 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
return false, fmt.Errorf("failed to compute accepted deletions: %w", err)
}
// Prune accepted deletions from the database
// Collect all deletion errors - fail the operation if any delete fails
// Prune accepted deletions from the database.
//
// "Accepted deletions" are issues that:
// 1. Existed in the base snapshot (last successful import)
// 2. Were NOT modified locally (still in left snapshot, unchanged)
// 3. Are NOT in the merged result (deleted remotely)
//
// We tolerate "issue not found" errors because the issue may already be gone:
// - Tombstoned by auto-import's git-history-backfill
// - Deleted manually by the user
// - Never existed in this clone (multi-repo, partial history)
// The goal is ensuring deletion, so already-deleted is success.
var deletionErrors []error
var alreadyGone int
for _, id := range acceptedDeletions {
if err := store.DeleteIssue(ctx, id); err != nil {
if isIssueNotFoundError(err) {
alreadyGone++
continue
}
deletionErrors = append(deletionErrors, fmt.Errorf("issue %s: %w", id, err))
}
}
@@ -89,9 +122,15 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
// Print stats if deletions were found
stats := sm.GetStats()
if stats.DeletionsFound > 0 {
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database (base: %d, left: %d, merged: %d)\n",
stats.DeletionsFound, stats.BaseCount, stats.LeftCount, stats.MergedCount)
actuallyDeleted := len(acceptedDeletions) - alreadyGone
if stats.DeletionsFound > 0 || alreadyGone > 0 {
if alreadyGone > 0 {
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database, %d already gone (base: %d, left: %d, merged: %d)\n",
actuallyDeleted, alreadyGone, stats.BaseCount, stats.LeftCount, stats.MergedCount)
} else {
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database (base: %d, left: %d, merged: %d)\n",
actuallyDeleted, stats.BaseCount, stats.LeftCount, stats.MergedCount)
}
}
return true, nil

View File

@@ -2,16 +2,19 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
@@ -225,7 +228,7 @@ type issueSort struct {
// repairPrefixes consolidates multiple prefixes into a single target prefix
// Issues with the correct prefix are left unchanged.
// Issues with incorrect prefixes are sorted and renumbered sequentially.
// Issues with incorrect prefixes get new hash-based IDs.
func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, targetPrefix string, issues []*types.Issue, prefixes map[string]int, dryRun bool) error {
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
@@ -235,16 +238,12 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t
var correctIssues []*types.Issue
var incorrectIssues []issueSort
maxCorrectNumber := 0
for _, issue := range issues {
prefix := utils.ExtractIssuePrefix(issue.ID)
number := utils.ExtractIssueNumber(issue.ID)
if prefix == targetPrefix {
correctIssues = append(correctIssues, issue)
if number > maxCorrectNumber {
maxCorrectNumber = number
}
} else {
incorrectIssues = append(incorrectIssues, issueSort{
issue: issue,
@@ -262,43 +261,58 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t
return incorrectIssues[i].number < incorrectIssues[j].number
})
// Get a database connection for ID generation
conn, err := st.UnderlyingConn(ctx)
if err != nil {
return fmt.Errorf("failed to get database connection: %w", err)
}
defer func() { _ = conn.Close() }()
// Build a map of all renames for text replacement using hash IDs
// Track used IDs to avoid collisions within the batch
renameMap := make(map[string]string)
usedIDs := make(map[string]bool)
// Mark existing correct IDs as used
for _, issue := range correctIssues {
usedIDs[issue.ID] = true
}
// Generate hash IDs for all incorrect issues
for _, is := range incorrectIssues {
newID, err := generateRepairHashID(ctx, conn, targetPrefix, is.issue, actorName, usedIDs)
if err != nil {
return fmt.Errorf("failed to generate hash ID for %s: %w", is.issue.ID, err)
}
renameMap[is.issue.ID] = newID
usedIDs[newID] = true
}
if dryRun {
fmt.Printf("DRY RUN: Would repair %d issues with incorrect prefixes\n\n", len(incorrectIssues))
fmt.Printf("Issues with correct prefix (%s): %d (highest number: %d)\n", cyan(targetPrefix), len(correctIssues), maxCorrectNumber)
fmt.Printf("Issues with correct prefix (%s): %d\n", cyan(targetPrefix), len(correctIssues))
fmt.Printf("Issues to repair: %d\n\n", len(incorrectIssues))
fmt.Printf("Planned renames (showing first 10):\n")
nextNumber := maxCorrectNumber + 1
for i, is := range incorrectIssues {
if i >= 10 {
fmt.Printf("... and %d more\n", len(incorrectIssues)-10)
break
}
oldID := is.issue.ID
newID := fmt.Sprintf("%s-%d", targetPrefix, nextNumber)
newID := renameMap[oldID]
fmt.Printf(" %s -> %s\n", yellow(oldID), cyan(newID))
nextNumber++
}
return nil
}
// Perform the repairs
fmt.Printf("Repairing database with multiple prefixes...\n")
fmt.Printf(" Issues with correct prefix (%s): %d (highest: %s-%d)\n",
cyan(targetPrefix), len(correctIssues), targetPrefix, maxCorrectNumber)
fmt.Printf(" Issues with correct prefix (%s): %d\n", cyan(targetPrefix), len(correctIssues))
fmt.Printf(" Issues to repair: %d\n\n", len(incorrectIssues))
oldPrefixPattern := regexp.MustCompile(`\b[a-z][a-z0-9-]*-(\d+)\b`)
// Build a map of all renames for text replacement
renameMap := make(map[string]string)
nextNumber := maxCorrectNumber + 1
for _, is := range incorrectIssues {
oldID := is.issue.ID
newID := fmt.Sprintf("%s-%d", targetPrefix, nextNumber)
renameMap[oldID] = newID
nextNumber++
}
// Pattern to match any issue ID reference in text (both hash and sequential IDs)
oldPrefixPattern := regexp.MustCompile(`\b[a-z][a-z0-9-]*-[a-z0-9]+\b`)
// Rename each issue
for _, is := range incorrectIssues {
@@ -369,11 +383,10 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t
if jsonOutput {
result := map[string]interface{}{
"target_prefix": targetPrefix,
"prefixes_found": len(prefixes),
"issues_repaired": len(incorrectIssues),
"issues_unchanged": len(correctIssues),
"highest_number": nextNumber - 1,
"target_prefix": targetPrefix,
"prefixes_found": len(prefixes),
"issues_repaired": len(incorrectIssues),
"issues_unchanged": len(correctIssues),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
@@ -434,6 +447,37 @@ func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues [
return nil
}
// generateRepairHashID generates a hash-based ID for an issue during repair
// Uses the sqlite.GenerateIssueID function but also checks usedIDs for batch collision avoidance
func generateRepairHashID(ctx context.Context, conn *sql.Conn, prefix string, issue *types.Issue, actor string, usedIDs map[string]bool) (string, error) {
// Try to generate a unique ID using the standard generation function
// This handles collision detection against existing database IDs
newID, err := sqlite.GenerateIssueID(ctx, conn, prefix, issue, actor)
if err != nil {
return "", err
}
// Check if this ID was already used in this batch
// If so, we need to generate a new one with a different timestamp
attempts := 0
for usedIDs[newID] && attempts < 100 {
// Slightly modify the creation time to get a different hash
modifiedIssue := *issue
modifiedIssue.CreatedAt = issue.CreatedAt.Add(time.Duration(attempts+1) * time.Nanosecond)
newID, err = sqlite.GenerateIssueID(ctx, conn, prefix, &modifiedIssue, actor)
if err != nil {
return "", err
}
attempts++
}
if usedIDs[newID] {
return "", fmt.Errorf("failed to generate unique ID after %d attempts", attempts)
}
return newID, nil
}
func init() {
renamePrefixCmd.Flags().Bool("dry-run", false, "Preview changes without applying them")
renamePrefixCmd.Flags().Bool("repair", false, "Repair database with multiple prefixes by consolidating them")

View File

@@ -93,19 +93,28 @@ func TestRepairMultiplePrefixes(t *testing.T) {
}
}
// Verify the others were renumbered
issue, err := store.GetIssue(ctx, "test-3")
if err != nil || issue == nil {
t.Fatalf("expected test-3 to exist (renamed from another-1)")
// Verify the others were renamed with hash IDs (not sequential)
// We have 5 total issues, 2 original (test-1, test-2), 3 renamed
if len(allIssues) != 5 {
t.Fatalf("expected 5 issues total, got %d", len(allIssues))
}
issue, err = store.GetIssue(ctx, "test-4")
if err != nil || issue == nil {
t.Fatalf("expected test-4 to exist (renamed from old-1)")
// Count issues with correct prefix and verify old IDs no longer exist
testPrefixCount := 0
for _, issue := range allIssues {
if len(issue.ID) > 5 && issue.ID[:5] == "test-" {
testPrefixCount++
}
}
if testPrefixCount != 5 {
t.Fatalf("expected all 5 issues to have 'test-' prefix, got %d", testPrefixCount)
}
issue, err = store.GetIssue(ctx, "test-5")
if err != nil || issue == nil {
t.Fatalf("expected test-5 to exist (renamed from old-2)")
// Verify old IDs no longer exist
for _, oldID := range []string{"old-1", "old-2", "another-1"} {
issue, err := store.GetIssue(ctx, oldID)
if err == nil && issue != nil {
t.Fatalf("expected old ID %s to no longer exist", oldID)
}
}
}