From d72f37551b745ba1b3c54ff4f8f875a58c96be49 Mon Sep 17 00:00:00 2001 From: beads/crew/emma Date: Tue, 20 Jan 2026 18:49:44 -0800 Subject: [PATCH] feat(rename): add bd rename command for renaming issue IDs Add new `bd rename ` command that: - Updates the issue's primary ID - Updates text references in other issues (title, description, etc.) - Handles dependencies via storage layer's UpdateIssueID Also fix sync.go build error where hasSyncBranchConfig/syncBranchName were referenced but not defined - should use sbc.IsConfigured()/sbc.Branch. Co-Authored-By: Claude Opus 4.5 --- cmd/bd/rename.go | 146 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/bd/sync.go | 6 +- 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 cmd/bd/rename.go diff --git a/cmd/bd/rename.go b/cmd/bd/rename.go new file mode 100644 index 00000000..18b5cd08 --- /dev/null +++ b/cmd/bd/rename.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "fmt" + "regexp" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" +) + +var renameCmd = &cobra.Command{ + Use: "rename ", + Short: "Rename an issue ID", + Long: `Rename an issue from one ID to another. + +This updates: +- The issue's primary ID +- All references in other issues (descriptions, titles, notes, etc.) +- Dependencies pointing to/from this issue +- Labels, comments, and events + +Examples: + bd rename bd-w382l bd-dolt # Rename to memorable ID + bd rename gt-abc123 gt-auth # Use descriptive ID + +Note: The new ID must use a valid prefix for this database.`, + Args: cobra.ExactArgs(2), + RunE: runRename, +} + +func init() { + rootCmd.AddCommand(renameCmd) +} + +func runRename(cmd *cobra.Command, args []string) error { + oldID := args[0] + newID := args[1] + + // Validate IDs + if oldID == newID { + return fmt.Errorf("old and new IDs are the same") + } + + // Basic ID format validation + idPattern := regexp.MustCompile(`^[a-z]+-[a-zA-Z0-9._-]+$`) + if !idPattern.MatchString(newID) { + return fmt.Errorf("invalid new ID format %q: must be prefix-suffix (e.g., bd-dolt)", newID) + } + + ctx := context.Background() + if err := ensureStoreActive(); err != nil { + return fmt.Errorf("failed to get storage: %w", err) + } + + // Check if old issue exists + oldIssue, err := store.GetIssue(ctx, oldID) + if err != nil { + return fmt.Errorf("failed to get issue %s: %w", oldID, err) + } + if oldIssue == nil { + return fmt.Errorf("issue %s not found", oldID) + } + + // Check if new ID already exists + existing, err := store.GetIssue(ctx, newID) + if err != nil { + return fmt.Errorf("failed to check for existing issue: %w", err) + } + if existing != nil { + return fmt.Errorf("issue %s already exists", newID) + } + + // Update the issue ID + oldIssue.ID = newID + actor := getActorWithGit() + if err := store.UpdateIssueID(ctx, oldID, newID, oldIssue, actor); err != nil { + return fmt.Errorf("failed to rename issue: %w", err) + } + + // Update references in other issues + if err := updateReferencesInAllIssues(ctx, store, oldID, newID, actor); err != nil { + // Non-fatal - the primary rename succeeded + fmt.Printf("Warning: failed to update some references: %v\n", err) + } + + fmt.Printf("Renamed %s -> %s\n", ui.RenderWarn(oldID), ui.RenderAccent(newID)) + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + return nil +} + +// updateReferencesInAllIssues updates text references to the old ID in all issues +func updateReferencesInAllIssues(ctx context.Context, store storage.Storage, oldID, newID, actor string) error { + // Get all issues + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + return fmt.Errorf("failed to list issues: %w", err) + } + + // Pattern to match the old ID as a word boundary + oldPattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(oldID) + `\b`) + + for _, issue := range issues { + if issue.ID == newID { + continue // Skip the renamed issue itself + } + + updated := false + updates := make(map[string]interface{}) + + // Check and update each text field + if oldPattern.MatchString(issue.Title) { + updates["title"] = oldPattern.ReplaceAllString(issue.Title, newID) + updated = true + } + if oldPattern.MatchString(issue.Description) { + updates["description"] = oldPattern.ReplaceAllString(issue.Description, newID) + updated = true + } + if oldPattern.MatchString(issue.Design) { + updates["design"] = oldPattern.ReplaceAllString(issue.Design, newID) + updated = true + } + if oldPattern.MatchString(issue.Notes) { + updates["notes"] = oldPattern.ReplaceAllString(issue.Notes, newID) + updated = true + } + if oldPattern.MatchString(issue.AcceptanceCriteria) { + updates["acceptance_criteria"] = oldPattern.ReplaceAllString(issue.AcceptanceCriteria, newID) + updated = true + } + + if updated { + if err := store.UpdateIssue(ctx, issue.ID, updates, actor); err != nil { + return fmt.Errorf("failed to update references in %s: %w", issue.ID, err) + } + } + } + + return nil +} diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 9c85035f..b508e684 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -344,11 +344,11 @@ The --full flag provides the legacy full sync behavior for backwards compatibili // GH#1166: Block sync if currently on the sync branch // This must happen BEFORE worktree operations - after entering a worktree, // GetCurrentBranch() would return the worktree's branch, not the original. - if hasSyncBranchConfig { - if syncbranch.IsSyncBranchSameAsCurrent(ctx, syncBranchName) { + if sbc.IsConfigured() { + if syncbranch.IsSyncBranchSameAsCurrent(ctx, sbc.Branch) { FatalError("Cannot sync to '%s': it's your current branch. "+ "Checkout a different branch first, or use a dedicated sync branch like 'beads-sync'.", - syncBranchName) + sbc.Branch) } }