Files
beads/cmd/bd/migrate_sync.go
Ryan Snodgrass acfdcebc0f fix: misc improvements and dependency updates
- Update nix vendorHash after fatih/color removal
- Bump version to 0.30.7
- Add GroupID to remaining commands for proper cobra grouping
- Apply semantic color rendering to list and stale commands
- Update pre-commit hook template
2025-12-20 17:09:50 -08:00

343 lines
12 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/syncbranch"
)
// TODO: Consider integrating into 'bd doctor' migration detection
var migrateSyncCmd = &cobra.Command{
Use: "migrate-sync <branch-name>",
GroupID: "maint",
Short: "Migrate to sync.branch workflow for multi-clone setups",
Long: `Migrate to using a dedicated sync branch for beads data.
This command configures the repository to commit .beads changes to a separate
branch (e.g., "beads-sync") instead of the current working branch. This is
essential for multi-clone setups like Gas Town where multiple clones work
independently but need to sync beads data.
The command will:
1. Validate the current state (not already configured, not on sync branch)
2. Create the sync branch if it doesn't exist (from remote or locally)
3. Set up the git worktree for the sync branch
4. Set the sync.branch configuration
After migration, 'bd sync' will commit beads changes to the sync branch via
a git worktree, keeping your working branch clean of beads commits.
Examples:
# Basic migration to beads-sync branch
bd migrate-sync beads-sync
# Preview what would happen without making changes
bd migrate-sync beads-sync --dry-run
# Force migration even if already configured
bd migrate-sync beads-sync --force`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
branchName := args[0]
dryRun, _ := cmd.Flags().GetBool("dry-run")
force, _ := cmd.Flags().GetBool("force")
if err := runMigrateSync(ctx, branchName, dryRun, force); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
migrateSyncCmd.Flags().Bool("dry-run", false, "Preview migration without making changes")
migrateSyncCmd.Flags().Bool("force", false, "Force migration even if already configured")
rootCmd.AddCommand(migrateSyncCmd)
}
func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool) error {
// Validate branch name
if err := syncbranch.ValidateBranchName(branchName); err != nil {
return fmt.Errorf("invalid branch name: %w", err)
}
// Check if we're in a git repository
if !isGitRepo() {
return fmt.Errorf("not in a git repository")
}
// Ensure store is initialized for config operations
if err := ensureDirectMode("migrate-sync requires direct database access"); err != nil {
return err
}
// Get current branch
currentBranch, err := getCurrentBranch(ctx)
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
// Check if already on the sync branch
if currentBranch == branchName {
return fmt.Errorf("currently on branch '%s' - switch to your main working branch first (e.g., 'git checkout main')", branchName)
}
// Check if sync.branch is already configured
existingSyncBranch, err := syncbranch.Get(ctx, store)
if err != nil {
return fmt.Errorf("failed to check existing config: %w", err)
}
if existingSyncBranch != "" && !force {
if existingSyncBranch == branchName {
fmt.Printf("✓ Already configured to use sync branch '%s'\n", branchName)
fmt.Println(" Use --force to reconfigure anyway")
return nil
}
return fmt.Errorf("sync.branch already configured as '%s' (use --force to change to '%s')", existingSyncBranch, branchName)
}
// Check if we have a remote
hasRemote := hasGitRemote(ctx)
if !hasRemote {
fmt.Println("⚠ Warning: No git remote configured. Sync branch will only exist locally.")
}
// Get repo root
repoRoot, err := syncbranch.GetRepoRoot(ctx)
if err != nil {
return fmt.Errorf("failed to get repository root: %w", err)
}
// Find JSONL path
jsonlPath := findJSONLPath()
if jsonlPath == "" {
return fmt.Errorf("not in a bd workspace (no .beads directory found)")
}
// Check if sync branch exists (locally or remotely)
branchExistsLocally := branchExistsLocal(ctx, branchName)
branchExistsRemotely := branchExistsRemote(ctx, branchName)
if dryRun {
fmt.Println("=== DRY RUN - No changes will be made ===")
fmt.Println()
fmt.Printf("Current branch: %s\n", currentBranch)
fmt.Printf("Sync branch: %s\n", branchName)
fmt.Printf("Repository root: %s\n", repoRoot)
fmt.Printf("JSONL path: %s\n", jsonlPath)
fmt.Println()
if existingSyncBranch != "" {
fmt.Printf("→ Would change sync.branch from '%s' to '%s'\n", existingSyncBranch, branchName)
} else {
fmt.Printf("→ Would set sync.branch to '%s'\n", branchName)
}
if branchExistsLocally {
fmt.Printf("→ Branch '%s' exists locally\n", branchName)
} else if branchExistsRemotely {
fmt.Printf("→ Would create local branch '%s' from remote\n", branchName)
} else {
fmt.Printf("→ Would create new branch '%s'\n", branchName)
}
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
worktreePath := filepath.Join(gitDir, "beads-worktrees", branchName)
fmt.Printf("→ Would create worktree at: %s\n", worktreePath)
fmt.Println("\n=== END DRY RUN ===")
return nil
}
// Step 1: Create the sync branch if it doesn't exist
fmt.Printf("→ Setting up sync branch '%s'...\n", branchName)
if !branchExistsLocally && !branchExistsRemotely {
// Create new branch from current HEAD
fmt.Printf(" Creating new branch '%s'...\n", branchName)
createCmd := exec.CommandContext(ctx, "git", "branch", branchName)
if output, err := createCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create branch: %w\n%s", err, output)
}
} else if !branchExistsLocally && branchExistsRemotely {
// Fetch and create local tracking branch
fmt.Printf(" Fetching remote branch '%s'...\n", branchName)
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", branchName)
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to fetch remote branch: %w\n%s", err, output)
}
// Create local branch tracking remote
createCmd := exec.CommandContext(ctx, "git", "branch", branchName, "origin/"+branchName)
if output, err := createCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create local tracking branch: %w\n%s", err, output)
}
} else {
fmt.Printf(" Branch '%s' already exists locally\n", branchName)
}
// Step 2: Create the worktree
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
worktreePath := filepath.Join(gitDir, "beads-worktrees", branchName)
fmt.Printf("→ Creating worktree at %s...\n", worktreePath)
wtMgr := git.NewWorktreeManager(repoRoot)
if err := wtMgr.CreateBeadsWorktree(branchName, worktreePath); err != nil {
return fmt.Errorf("failed to create worktree: %w", err)
}
// Step 3: Sync current JSONL to worktree
fmt.Println("→ Syncing current beads data to worktree...")
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
if err != nil {
return fmt.Errorf("failed to get relative JSONL path: %w", err)
}
if err := wtMgr.SyncJSONLToWorktree(worktreePath, jsonlRelPath); err != nil {
return fmt.Errorf("failed to sync JSONL to worktree: %w", err)
}
// Also sync other beads files
beadsDir := filepath.Dir(jsonlPath)
for _, filename := range []string{"deletions.jsonl", "metadata.json"} {
srcPath := filepath.Join(beadsDir, filename)
if _, err := os.Stat(srcPath); err == nil {
relPath, err := filepath.Rel(repoRoot, srcPath)
if err == nil {
_ = wtMgr.SyncJSONLToWorktree(worktreePath, relPath)
}
}
}
// Step 4: Commit initial state to sync branch if there are changes
fmt.Println("→ Committing initial state to sync branch...")
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
hasChanges, err := hasChangesInWorktreeDir(ctx, worktreePath)
if err != nil {
fmt.Printf(" Warning: failed to check for changes: %v\n", err)
}
if hasChanges {
if err := commitInitialSyncState(ctx, worktreePath, jsonlRelPath); err != nil {
fmt.Printf(" Warning: failed to commit initial state: %v\n", err)
} else {
fmt.Println(" Initial state committed to sync branch")
}
} else {
// Check if .beads directory exists in worktree but no changes
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
if _, err := os.Stat(worktreeBeadsDir); os.IsNotExist(err) {
// .beads doesn't exist in worktree - this is a fresh setup
fmt.Println(" No existing beads data in sync branch")
} else {
fmt.Println(" Sync branch already has current beads data")
}
}
_ = worktreeJSONLPath // silence unused warning
// Step 5: Set sync.branch config
fmt.Printf("→ Setting sync.branch to '%s'...\n", branchName)
if err := syncbranch.Set(ctx, store, branchName); err != nil {
return fmt.Errorf("failed to set sync.branch config: %w", err)
}
// Step 6: Push sync branch to remote if we have one
if hasRemote {
fmt.Printf("→ Pushing sync branch '%s' to remote...\n", branchName)
pushCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", "origin", branchName)
output, err := pushCmd.CombinedOutput()
if err != nil {
// Non-fatal - branch might already be up to date or push might fail for other reasons
if !strings.Contains(string(output), "Everything up-to-date") {
fmt.Printf(" Warning: failed to push sync branch: %v\n", err)
fmt.Printf(" You may need to push manually: git push -u origin %s\n", branchName)
}
} else {
fmt.Printf(" Pushed '%s' to origin\n", branchName)
}
}
fmt.Println()
fmt.Println("✓ Migration complete!")
fmt.Println()
fmt.Printf(" sync.branch: %s\n", branchName)
fmt.Printf(" worktree: %s\n", worktreePath)
fmt.Println()
fmt.Println("Next steps:")
fmt.Println(" • 'bd sync' will now commit beads changes to the sync branch")
fmt.Println(" • Your working branch stays clean of beads commits")
fmt.Println(" • Other clones should also run 'bd migrate-sync " + branchName + "'")
return nil
}
// branchExistsLocal checks if a branch exists locally
func branchExistsLocal(ctx context.Context, branch string) bool {
cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
return cmd.Run() == nil
}
// branchExistsRemote checks if a branch exists on origin remote
func branchExistsRemote(ctx context.Context, branch string) bool {
// First fetch to ensure we have latest remote refs
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", "--prune")
_ = fetchCmd.Run() // Best effort
cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/remotes/origin/"+branch)
return cmd.Run() == nil
}
// hasChangesInWorktreeDir checks if there are any uncommitted changes in the worktree
func hasChangesInWorktreeDir(ctx context.Context, worktreePath string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain")
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(output))) > 0, nil
}
// commitInitialSyncState commits the initial beads state to the sync branch
func commitInitialSyncState(ctx context.Context, worktreePath, jsonlRelPath string) error {
beadsRelDir := filepath.Dir(jsonlRelPath)
// Stage all beads files
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", beadsRelDir)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
}
// Commit
commitCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "commit", "--no-verify", "-m", "bd migrate-sync: initial sync branch setup")
output, err := commitCmd.CombinedOutput()
if err != nil {
// Check if there's nothing to commit
if strings.Contains(string(output), "nothing to commit") {
return nil
}
return fmt.Errorf("git commit failed: %w\n%s", err, output)
}
return nil
}