Files
beads/cmd/bd/move.go
beads/crew/dave 5dfb838d60 feat(completion): optimize ID prefix filtering and add completions to more commands
Improvements to shell completions from PR #935:

1. Add IDPrefix field to IssueFilter for efficient database-level filtering
   - Queries are now filtered at SQL level instead of fetching all issues
   - Updated sqlite, transaction, and memory stores to support IDPrefix

2. Add ValidArgsFunction to additional commands:
   - dep (add, remove, list, tree)
   - comments, comment (add)
   - delete
   - graph
   - label (add, remove, list)
   - duplicate, supersede
   - audit
   - move
   - relate, unrelate
   - refile
   - gate (show, resolve, add-waiter)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
2026-01-06 19:05:34 -08:00

282 lines
9.3 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
var moveCmd = &cobra.Command{
Use: "move <issue-id> --to <rig|prefix>",
GroupID: "issues",
Short: "Move an issue to a different rig with dependency remapping",
Long: `Move an issue from one rig to another, updating dependencies.
This command:
1. Creates a new issue in the target rig with the same content
2. Updates dependencies that reference the old ID (see below)
3. Closes the source issue with a redirect note
The target rig can be specified as:
- A rig name: beads, gastown
- A prefix: bd-, gt-
- A prefix without hyphen: bd, gt
Dependency handling for cross-rig moves:
- Issues that depend ON the moved issue: updated to external refs
- Issues that the moved issue DEPENDS ON: removed (recreate manually in target)
Note: Labels are copied. Comments and event history are not transferred.
Examples:
bd move hq-c21fj --to beads # Move to beads by rig name
bd move hq-q3tki --to gt- # Move to gastown by prefix
bd move hq-1h2to --to gt # Move to gastown (prefix without hyphen)`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("move")
sourceID := args[0]
targetRig, _ := cmd.Flags().GetString("to")
if targetRig == "" {
FatalError("--to flag is required. Specify target rig (e.g., --to beads, --to gt-)")
}
keepOpen, _ := cmd.Flags().GetBool("keep-open")
skipDeps, _ := cmd.Flags().GetBool("skip-deps")
ctx := rootCtx
// Step 1: Get the source issue (via routing if needed)
result, err := resolveAndGetIssueWithRouting(ctx, store, sourceID)
if err != nil {
FatalError("failed to find source issue: %v", err)
}
if result == nil || result.Issue == nil {
FatalError("source issue %s not found", sourceID)
}
defer result.Close()
sourceIssue := result.Issue
resolvedSourceID := result.ResolvedID
sourceStore := result.Store
// Warn if source issue is already closed
if sourceIssue.Status == types.StatusClosed {
fmt.Fprintf(os.Stderr, "%s Source issue %s is already closed\n", ui.RenderWarn("⚠"), resolvedSourceID)
}
// Warn if ephemeral
if sourceIssue.Ephemeral {
fmt.Fprintf(os.Stderr, "%s Source issue %s is ephemeral (wisp). Moving ephemeral issues may not be appropriate.\n", ui.RenderWarn("⚠"), resolvedSourceID)
}
// Step 2: Find the town-level beads directory
townBeadsDir, err := findTownBeadsDir()
if err != nil {
FatalError("cannot move: %v", err)
}
// Step 3: Resolve the target rig's beads directory
targetBeadsDir, targetPrefix, err := routing.ResolveBeadsDirForRig(targetRig, townBeadsDir)
if err != nil {
FatalError("%v", err)
}
// Check we're not moving to the same rig
sourcePrefix := routing.ExtractPrefix(resolvedSourceID)
if sourcePrefix == targetPrefix {
FatalError("source issue %s is already in rig %q", resolvedSourceID, targetRig)
}
// Step 4: Open storage for the target rig
targetDBPath := filepath.Join(targetBeadsDir, "beads.db")
targetStore, err := sqlite.New(ctx, targetDBPath)
if err != nil {
FatalError("failed to open target rig database: %v", err)
}
defer func() {
if err := targetStore.Close(); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to close target rig database: %v\n", err)
}
}()
// Step 5: Create the new issue in target rig (copy all fields)
newIssue := &types.Issue{
// Don't copy ID - let target rig generate new one
Title: sourceIssue.Title,
Description: sourceIssue.Description,
Design: sourceIssue.Design,
AcceptanceCriteria: sourceIssue.AcceptanceCriteria,
Notes: sourceIssue.Notes,
Status: types.StatusOpen, // Always start as open
Priority: sourceIssue.Priority,
IssueType: sourceIssue.IssueType,
Assignee: sourceIssue.Assignee,
ExternalRef: sourceIssue.ExternalRef,
EstimatedMinutes: sourceIssue.EstimatedMinutes,
SourceRepo: sourceIssue.SourceRepo,
Ephemeral: sourceIssue.Ephemeral,
MolType: sourceIssue.MolType,
RoleType: sourceIssue.RoleType,
Rig: sourceIssue.Rig,
DueAt: sourceIssue.DueAt,
DeferUntil: sourceIssue.DeferUntil,
CreatedBy: actor,
}
// Append moved note to description
if newIssue.Description != "" {
newIssue.Description += "\n\n"
}
newIssue.Description += fmt.Sprintf("(Moved from %s)", resolvedSourceID)
if err := targetStore.CreateIssue(ctx, newIssue, actor); err != nil {
FatalError("failed to create issue in target rig: %v", err)
}
newID := newIssue.ID
// Step 6: Copy labels if any
labels, err := sourceStore.GetLabels(ctx, resolvedSourceID)
if err == nil && len(labels) > 0 {
for _, label := range labels {
if err := targetStore.AddLabel(ctx, newID, label, actor); err != nil {
WarnError("failed to copy label %s: %v", label, err)
}
}
}
// Step 7: Remap dependencies in the source store
// targetRig is used to create external references for cross-rig moves
var depsRemapped int
if !skipDeps {
depsRemapped, err = remapDependencies(ctx, sourceStore, resolvedSourceID, newID, targetRig, actor)
if err != nil {
WarnError("failed to remap some dependencies: %v", err)
}
}
// Step 8: Close the source issue (unless --keep-open)
if !keepOpen {
closeReason := fmt.Sprintf("Moved to %s", newID)
if err := sourceStore.CloseIssue(ctx, resolvedSourceID, closeReason, actor, ""); err != nil {
WarnError("failed to close source issue: %v", err)
}
// Schedule auto-flush if source was local store
if !result.Routed {
markDirtyAndScheduleFlush()
}
}
// Output
if jsonOutput {
outputJSON(map[string]interface{}{
"source": resolvedSourceID,
"target": newID,
"closed": !keepOpen,
"deps_remapped": depsRemapped,
})
} else {
fmt.Printf("%s Moved %s → %s\n", ui.RenderPass("✓"), resolvedSourceID, newID)
if depsRemapped > 0 {
fmt.Printf(" Remapped %d dependencies\n", depsRemapped)
}
if !keepOpen {
fmt.Printf(" Source issue closed\n")
}
}
},
}
// remapDependencies updates all dependencies in the store that reference oldID to use newID.
// For cross-rig moves (which is the only supported case), dependencies TO the old ID are
// converted to external references. Dependencies FROM the old ID are removed since they
// can't be recreated in the source store.
// Returns the number of dependencies remapped.
func remapDependencies(ctx context.Context, s storage.Storage, oldID, newID, targetRig, actor string) (int, error) {
count := 0
// Get dependencies where oldID is the issue (oldID depends on something)
// These must be removed since the new issue is in a different rig's store
depsFrom, err := s.GetDependencyRecords(ctx, oldID)
if err != nil {
return count, fmt.Errorf("getting dependencies from %s: %w", oldID, err)
}
// Remove deps FROM the old ID (user needs to recreate in target rig)
for _, dep := range depsFrom {
if err := s.RemoveDependency(ctx, oldID, dep.DependsOnID, actor); err != nil {
fmt.Fprintf(os.Stderr, " warning: failed to remove dep %s->%s: %v\n", oldID, dep.DependsOnID, err)
}
}
if len(depsFrom) > 0 {
fmt.Fprintf(os.Stderr, " note: %d dependencies FROM %s were removed (recreate in target rig if needed)\n", len(depsFrom), oldID)
}
// Get dependents (issues that depend on oldID)
dependents, err := s.GetDependents(ctx, oldID)
if err != nil {
return count, fmt.Errorf("getting dependents of %s: %w", oldID, err)
}
// For each issue that depends on oldID, update to use external ref to newID
for _, dependent := range dependents {
// Get the dependency record to preserve type/metadata
depRecords, err := s.GetDependencyRecords(ctx, dependent.ID)
if err != nil {
fmt.Fprintf(os.Stderr, " warning: failed to get deps for %s: %v\n", dependent.ID, err)
continue
}
for _, dep := range depRecords {
if dep.DependsOnID != oldID {
continue
}
// Remove old dependency
if err := s.RemoveDependency(ctx, dependent.ID, oldID, actor); err != nil {
fmt.Fprintf(os.Stderr, " warning: failed to remove dep %s->%s: %v\n", dependent.ID, oldID, err)
continue
}
// Point to external reference in target rig
externalRef := fmt.Sprintf("external:%s:%s", targetRig, newID)
// Add new dependency with external ref
newDep := &types.Dependency{
IssueID: dependent.ID,
DependsOnID: externalRef,
Type: dep.Type,
CreatedBy: actor,
Metadata: dep.Metadata,
ThreadID: dep.ThreadID,
}
if err := s.AddDependency(ctx, newDep, actor); err != nil {
fmt.Fprintf(os.Stderr, " warning: failed to add dep %s->%s: %v\n", dependent.ID, externalRef, err)
continue
}
count++
}
}
return count, nil
}
func init() {
moveCmd.Flags().String("to", "", "Target rig or prefix (required)")
moveCmd.Flags().Bool("keep-open", false, "Keep the source issue open (don't close it)")
moveCmd.Flags().Bool("skip-deps", false, "Skip dependency remapping")
moveCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(moveCmd)
}