package main import ( "context" "encoding/json" "fmt" "os" "regexp" "strings" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" ) var renamePrefixCmd = &cobra.Command{ Use: "rename-prefix ", Short: "Rename the issue prefix for all issues", Long: `Rename the issue prefix for all issues in the database. This will update all issue IDs and all text references across all fields. Prefix validation rules: - Max length: 8 characters - Allowed characters: lowercase letters, numbers, hyphens - Must start with a letter - Must end with a hyphen (e.g., 'kw-', 'work-') - Cannot be empty or just a hyphen Example: bd rename-prefix kw- # Rename from 'knowledge-work-' to 'kw-'`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { newPrefix := args[0] dryRun, _ := cmd.Flags().GetBool("dry-run") ctx := context.Background() // rename-prefix requires direct mode (not supported by daemon) if daemonClient != nil { if err := ensureDirectMode("daemon does not support rename-prefix command"); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } else if store == nil { if err := ensureStoreActive(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } if err := validatePrefix(newPrefix); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } oldPrefix, err := store.GetConfig(ctx, "issue_prefix") if err != nil || oldPrefix == "" { fmt.Fprintf(os.Stderr, "Error: failed to get current prefix: %v\n", err) os.Exit(1) } newPrefix = strings.TrimRight(newPrefix, "-") if oldPrefix == newPrefix { fmt.Fprintf(os.Stderr, "Error: new prefix is the same as current prefix: %s\n", oldPrefix) os.Exit(1) } issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err) os.Exit(1) } if len(issues) == 0 { fmt.Printf("No issues to rename. Updating prefix to %s\n", newPrefix) if !dryRun { if err := store.SetConfig(ctx, "issue_prefix", newPrefix); err != nil { fmt.Fprintf(os.Stderr, "Error: failed to update prefix: %v\n", err) os.Exit(1) } } return } if dryRun { cyan := color.New(color.FgCyan).SprintFunc() fmt.Printf("DRY RUN: Would rename %d issues from prefix '%s' to '%s'\n\n", len(issues), oldPrefix, newPrefix) fmt.Printf("Sample changes:\n") for i, issue := range issues { if i >= 5 { fmt.Printf("... and %d more issues\n", len(issues)-5) break } oldID := fmt.Sprintf("%s-%s", oldPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-")) newID := fmt.Sprintf("%s-%s", newPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-")) fmt.Printf(" %s -> %s\n", cyan(oldID), cyan(newID)) } return } green := color.New(color.FgGreen).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc() fmt.Printf("Renaming %d issues from prefix '%s' to '%s'...\n", len(issues), oldPrefix, newPrefix) if err := renamePrefixInDB(ctx, oldPrefix, newPrefix, issues); err != nil { fmt.Fprintf(os.Stderr, "Error: failed to rename prefix: %v\n", err) os.Exit(1) } // Schedule full export (IDs changed, incremental won't work) markDirtyAndScheduleFullExport() fmt.Printf("%s Successfully renamed prefix from %s to %s\n", green("✓"), cyan(oldPrefix), cyan(newPrefix)) if jsonOutput { result := map[string]interface{}{ "old_prefix": oldPrefix, "new_prefix": newPrefix, "issues_count": len(issues), } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(result) } }, } func validatePrefix(prefix string) error { prefix = strings.TrimRight(prefix, "-") if prefix == "" { return fmt.Errorf("prefix cannot be empty") } if len(prefix) > 8 { return fmt.Errorf("prefix too long (max 8 characters): %s", prefix) } matched, _ := regexp.MatchString(`^[a-z][a-z0-9-]*$`, prefix) if !matched { return fmt.Errorf("prefix must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens: %s", prefix) } if strings.HasPrefix(prefix, "-") || strings.HasSuffix(prefix, "--") { return fmt.Errorf("prefix has invalid hyphen placement: %s", prefix) } return nil } func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues []*types.Issue) error { // NOTE: Each issue is updated in its own transaction. A failure mid-way could leave // the database in a mixed state with some issues renamed and others not. // For production use, consider implementing a single atomic RenamePrefix() method // in the storage layer that wraps all updates in one transaction. oldPrefixPattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(oldPrefix) + `-(\d+)\b`) replaceFunc := func(match string) string { return strings.Replace(match, oldPrefix+"-", newPrefix+"-", 1) } for _, issue := range issues { oldID := issue.ID numPart := strings.TrimPrefix(oldID, oldPrefix+"-") newID := fmt.Sprintf("%s-%s", newPrefix, numPart) issue.ID = newID issue.Title = oldPrefixPattern.ReplaceAllStringFunc(issue.Title, replaceFunc) issue.Description = oldPrefixPattern.ReplaceAllStringFunc(issue.Description, replaceFunc) if issue.Design != "" { issue.Design = oldPrefixPattern.ReplaceAllStringFunc(issue.Design, replaceFunc) } if issue.AcceptanceCriteria != "" { issue.AcceptanceCriteria = oldPrefixPattern.ReplaceAllStringFunc(issue.AcceptanceCriteria, replaceFunc) } if issue.Notes != "" { issue.Notes = oldPrefixPattern.ReplaceAllStringFunc(issue.Notes, replaceFunc) } if err := store.UpdateIssueID(ctx, oldID, newID, issue, actor); err != nil { return fmt.Errorf("failed to update issue %s: %w", oldID, err) } } if err := store.RenameDependencyPrefix(ctx, oldPrefix, newPrefix); err != nil { return fmt.Errorf("failed to update dependencies: %w", err) } if err := store.RenameCounterPrefix(ctx, oldPrefix, newPrefix); err != nil { return fmt.Errorf("failed to update counter: %w", err) } if err := store.SetConfig(ctx, "issue_prefix", newPrefix); err != nil { return fmt.Errorf("failed to update config: %w", err) } return nil } func init() { renamePrefixCmd.Flags().Bool("dry-run", false, "Preview changes without applying them") rootCmd.AddCommand(renamePrefixCmd) }