diff --git a/cmd/bd/worktree_cmd.go b/cmd/bd/worktree_cmd.go new file mode 100644 index 00000000..64468369 --- /dev/null +++ b/cmd/bd/worktree_cmd.go @@ -0,0 +1,608 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/git" + "github.com/steveyegge/beads/internal/ui" +) + +// WorktreeInfo contains information about a git worktree +type WorktreeInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + IsMain bool `json:"is_main"` + BeadsState string `json:"beads_state"` // "redirect", "shared", "none" + RedirectTo string `json:"redirect_to,omitempty"` +} + +var worktreeCmd = &cobra.Command{ + Use: "worktree", + Short: "Manage git worktrees for parallel development", + GroupID: "maint", + Long: `Manage git worktrees with proper beads configuration. + +Worktrees allow multiple working directories sharing the same git repository, +enabling parallel development (e.g., multiple agents or features). + +When creating a worktree, beads automatically sets up a redirect file so all +worktrees share the same .beads database. This ensures consistent issue state +across all worktrees. + +Examples: + bd worktree create feature-auth # Create worktree with beads redirect + bd worktree create bugfix --branch fix-1 # Create with specific branch name + bd worktree list # List all worktrees + bd worktree remove feature-auth # Remove worktree (with safety checks) + bd worktree info # Show info about current worktree`, +} + +var worktreeCreateCmd = &cobra.Command{ + Use: "create [--branch=]", + Short: "Create a worktree with beads redirect", + Long: `Create a git worktree with proper beads configuration. + +This command: +1. Creates a git worktree at ./ (or specified path) +2. Sets up .beads/redirect pointing to the main repository's .beads +3. Adds the worktree path to .gitignore (if inside repo root) + +The worktree will share the same beads database as the main repository, +ensuring consistent issue state across all worktrees. + +Examples: + bd worktree create feature-auth # Create at ./feature-auth + bd worktree create bugfix --branch fix-1 # Create with branch name + bd worktree create ../agents/worker-1 # Create at relative path`, + Args: cobra.ExactArgs(1), + RunE: runWorktreeCreate, +} + +var worktreeListCmd = &cobra.Command{ + Use: "list", + Short: "List all git worktrees", + Long: `List all git worktrees and their beads configuration state. + +Shows each worktree with: +- Name (directory name) +- Path (full path) +- Branch +- Beads state: "redirect" (uses shared db), "shared" (is main), "none" (no beads) + +Examples: + bd worktree list # List all worktrees + bd worktree list --json # JSON output`, + Args: cobra.NoArgs, + RunE: runWorktreeList, +} + +var worktreeRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a worktree with safety checks", + Long: `Remove a git worktree with safety checks. + +Before removing, this command checks for: +- Uncommitted changes +- Unpushed commits +- Stashes + +Use --force to skip safety checks (not recommended). + +Examples: + bd worktree remove feature-auth # Remove with safety checks + bd worktree remove feature-auth --force # Skip safety checks`, + Args: cobra.ExactArgs(1), + RunE: runWorktreeRemove, +} + +var worktreeInfoCmd = &cobra.Command{ + Use: "info", + Short: "Show worktree info for current directory", + Long: `Show information about the current worktree. + +If the current directory is in a git worktree, shows: +- Worktree path and name +- Branch +- Beads configuration (redirect or main) +- Main repository location + +Examples: + bd worktree info # Show current worktree info + bd worktree info --json # JSON output`, + Args: cobra.NoArgs, + RunE: runWorktreeInfo, +} + +var ( + worktreeBranch string + worktreeForce bool +) + +func init() { + worktreeCreateCmd.Flags().StringVar(&worktreeBranch, "branch", "", "Branch name for the worktree (default: same as name)") + worktreeRemoveCmd.Flags().BoolVar(&worktreeForce, "force", false, "Skip safety checks") + + worktreeCmd.AddCommand(worktreeCreateCmd) + worktreeCmd.AddCommand(worktreeListCmd) + worktreeCmd.AddCommand(worktreeRemoveCmd) + worktreeCmd.AddCommand(worktreeInfoCmd) + rootCmd.AddCommand(worktreeCmd) +} + +func runWorktreeCreate(cmd *cobra.Command, args []string) error { + name := args[0] + + // Determine worktree path + worktreePath, err := filepath.Abs(name) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if path already exists + if _, err := os.Stat(worktreePath); err == nil { + return fmt.Errorf("path already exists: %s", worktreePath) + } + + // Find main repository root + repoRoot := git.GetRepoRoot() + if repoRoot == "" { + return fmt.Errorf("not in a git repository") + } + + // Find main beads directory + mainBeadsDir := beads.FindBeadsDir() + if mainBeadsDir == "" { + return fmt.Errorf("no .beads directory found; run 'bd init' first") + } + + // Determine branch name + branch := worktreeBranch + if branch == "" { + branch = filepath.Base(name) + } + + // Create the worktree + gitCmd := exec.Command("git", "worktree", "add", "-b", branch, worktreePath) + gitCmd.Dir = repoRoot + output, err := gitCmd.CombinedOutput() + if err != nil { + // Try without -b if branch already exists + gitCmd = exec.Command("git", "worktree", "add", worktreePath, branch) + gitCmd.Dir = repoRoot + output, err = gitCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create worktree: %w\n%s", err, string(output)) + } + } + + // Create .beads directory in worktree + worktreeBeadsDir := filepath.Join(worktreePath, ".beads") + if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil { + return fmt.Errorf("failed to create .beads directory: %w", err) + } + + // Create redirect file + redirectPath := filepath.Join(worktreeBeadsDir, beads.RedirectFileName) + relPath, err := filepath.Rel(worktreeBeadsDir, mainBeadsDir) + if err != nil { + // Fall back to absolute path + relPath = mainBeadsDir + } + if err := os.WriteFile(redirectPath, []byte(relPath+"\n"), 0644); err != nil { + return fmt.Errorf("failed to create redirect file: %w", err) + } + + // Add to .gitignore if worktree is inside repo root + if strings.HasPrefix(worktreePath, repoRoot+string(os.PathSeparator)) { + if err := addToGitignore(repoRoot, name); err != nil { + // Non-fatal, just warn + fmt.Fprintf(os.Stderr, "Warning: failed to update .gitignore: %v\n", err) + } + } + + if jsonOutput { + result := map[string]interface{}{ + "path": worktreePath, + "branch": branch, + "redirect_to": mainBeadsDir, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("%s Created worktree: %s\n", ui.RenderPass("✓"), worktreePath) + fmt.Printf(" Branch: %s\n", branch) + fmt.Printf(" Beads: redirects to %s\n", mainBeadsDir) + return nil +} + +func runWorktreeList(cmd *cobra.Command, args []string) error { + // Get repository root + repoRoot := git.GetRepoRoot() + if repoRoot == "" { + return fmt.Errorf("not in a git repository") + } + + // List worktrees + gitCmd := exec.Command("git", "worktree", "list", "--porcelain") + gitCmd.Dir = repoRoot + output, err := gitCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to list worktrees: %w", err) + } + + // Parse worktree list + worktrees := parseWorktreeList(string(output)) + + // Enrich with beads state + mainBeadsDir := beads.FindBeadsDir() + for i := range worktrees { + worktrees[i].BeadsState = getBeadsState(worktrees[i].Path, mainBeadsDir) + if worktrees[i].BeadsState == "redirect" { + worktrees[i].RedirectTo = getRedirectTarget(worktrees[i].Path) + } + } + + if jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(worktrees) + } + + // Human-readable output + if len(worktrees) == 0 { + fmt.Println("No worktrees found") + return nil + } + + fmt.Printf("%-20s %-40s %-20s %s\n", "NAME", "PATH", "BRANCH", "BEADS") + for _, wt := range worktrees { + name := filepath.Base(wt.Path) + if wt.IsMain { + name = "(main)" + } + beadsInfo := wt.BeadsState + if wt.RedirectTo != "" { + beadsInfo = fmt.Sprintf("redirect → %s", filepath.Base(filepath.Dir(wt.RedirectTo))) + } + fmt.Printf("%-20s %-40s %-20s %s\n", + truncate(name, 20), + truncate(wt.Path, 40), + truncate(wt.Branch, 20), + beadsInfo) + } + + return nil +} + +func runWorktreeRemove(cmd *cobra.Command, args []string) error { + name := args[0] + + // Find the worktree + repoRoot := git.GetRepoRoot() + if repoRoot == "" { + return fmt.Errorf("not in a git repository") + } + + // Resolve worktree path + worktreePath, err := resolveWorktreePath(repoRoot, name) + if err != nil { + return err + } + + // Safety checks unless --force + if !worktreeForce { + if err := checkWorktreeSafety(worktreePath); err != nil { + return fmt.Errorf("safety check failed: %w\nUse --force to skip safety checks", err) + } + } + + // Remove worktree + gitCmd := exec.Command("git", "worktree", "remove", worktreePath) + if worktreeForce { + gitCmd = exec.Command("git", "worktree", "remove", "--force", worktreePath) + } + gitCmd.Dir = repoRoot + output, err := gitCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to remove worktree: %w\n%s", err, string(output)) + } + + // Remove from .gitignore + if err := removeFromGitignore(repoRoot, filepath.Base(worktreePath)); err != nil { + // Non-fatal, just warn + fmt.Fprintf(os.Stderr, "Warning: failed to update .gitignore: %v\n", err) + } + + if jsonOutput { + result := map[string]interface{}{ + "removed": worktreePath, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("%s Removed worktree: %s\n", ui.RenderPass("✓"), worktreePath) + return nil +} + +func runWorktreeInfo(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + // Check if we're in a worktree + if !git.IsWorktree() { + if jsonOutput { + result := map[string]interface{}{ + "is_worktree": false, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + fmt.Println("Not in a git worktree (this is the main repository)") + return nil + } + + // Get worktree info + mainRepoRoot, err := git.GetMainRepoRoot() + if err != nil { + mainRepoRoot = "(unknown)" + } + + branch := getWorktreeCurrentBranch() + redirectInfo := beads.GetRedirectInfo() + + if jsonOutput { + result := map[string]interface{}{ + "is_worktree": true, + "path": cwd, + "name": filepath.Base(cwd), + "branch": branch, + "main_repo": mainRepoRoot, + "beads_redirected": redirectInfo.IsRedirected, + } + if redirectInfo.IsRedirected { + result["beads_local"] = redirectInfo.LocalDir + result["beads_target"] = redirectInfo.TargetDir + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("Worktree: %s\n", cwd) + fmt.Printf(" Name: %s\n", filepath.Base(cwd)) + fmt.Printf(" Branch: %s\n", branch) + fmt.Printf(" Main repo: %s\n", mainRepoRoot) + if redirectInfo.IsRedirected { + fmt.Printf(" Beads: redirects to %s\n", redirectInfo.TargetDir) + } else { + fmt.Printf(" Beads: local (no redirect)\n") + } + + return nil +} + +// Helper functions + +func parseWorktreeList(output string) []WorktreeInfo { + var worktrees []WorktreeInfo + var current WorktreeInfo + + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "worktree ") { + if current.Path != "" { + worktrees = append(worktrees, current) + } + current = WorktreeInfo{ + Path: strings.TrimPrefix(line, "worktree "), + } + } else if strings.HasPrefix(line, "HEAD ") { + // Skip HEAD hash + } else if strings.HasPrefix(line, "branch ") { + current.Branch = strings.TrimPrefix(line, "branch refs/heads/") + } else if line == "bare" { + current.IsMain = true + current.Branch = "(bare)" + } + } + if current.Path != "" { + worktrees = append(worktrees, current) + } + + // Mark the first non-bare worktree as main + if len(worktrees) > 0 && worktrees[0].Branch != "(bare)" { + worktrees[0].IsMain = true + } + + return worktrees +} + +func getBeadsState(worktreePath, mainBeadsDir string) string { + beadsDir := filepath.Join(worktreePath, ".beads") + redirectFile := filepath.Join(beadsDir, beads.RedirectFileName) + + if _, err := os.Stat(redirectFile); err == nil { + return "redirect" + } + if _, err := os.Stat(beadsDir); err == nil { + // Check if this is the main beads dir + absBeadsDir, _ := filepath.Abs(beadsDir) + absMainBeadsDir, _ := filepath.Abs(mainBeadsDir) + if absBeadsDir == absMainBeadsDir { + return "shared" + } + return "local" + } + return "none" +} + +func getRedirectTarget(worktreePath string) string { + redirectFile := filepath.Join(worktreePath, ".beads", beads.RedirectFileName) + data, err := os.ReadFile(redirectFile) + if err != nil { + return "" + } + target := strings.TrimSpace(string(data)) + // Resolve relative paths + if !filepath.IsAbs(target) { + beadsDir := filepath.Join(worktreePath, ".beads") + target = filepath.Join(beadsDir, target) + } + target, _ = filepath.Abs(target) + return target +} + +func resolveWorktreePath(repoRoot, name string) (string, error) { + // Try as absolute path first + if filepath.IsAbs(name) { + if _, err := os.Stat(name); err == nil { + return name, nil + } + } + + // Try relative to cwd + absPath, _ := filepath.Abs(name) + if _, err := os.Stat(absPath); err == nil { + return absPath, nil + } + + // Try relative to repo root + repoPath := filepath.Join(repoRoot, name) + if _, err := os.Stat(repoPath); err == nil { + return repoPath, nil + } + + return "", fmt.Errorf("worktree not found: %s", name) +} + +func checkWorktreeSafety(worktreePath string) error { + // Check for uncommitted changes + gitCmd := exec.Command("git", "status", "--porcelain") + gitCmd.Dir = worktreePath + output, err := gitCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to check git status: %w", err) + } + if len(strings.TrimSpace(string(output))) > 0 { + return fmt.Errorf("worktree has uncommitted changes") + } + + // Check for unpushed commits + gitCmd = exec.Command("git", "log", "@{upstream}..", "--oneline") + gitCmd.Dir = worktreePath + output, _ = gitCmd.CombinedOutput() // Ignore error (no upstream is ok) + if len(strings.TrimSpace(string(output))) > 0 { + return fmt.Errorf("worktree has unpushed commits") + } + + // Check for stashes + gitCmd = exec.Command("git", "stash", "list") + gitCmd.Dir = worktreePath + output, _ = gitCmd.CombinedOutput() + if len(strings.TrimSpace(string(output))) > 0 { + return fmt.Errorf("worktree has stashed changes") + } + + return nil +} + +func getWorktreeCurrentBranch() string { + output, err := exec.Command("git", "branch", "--show-current").CombinedOutput() + if err != nil { + return "(unknown)" + } + return strings.TrimSpace(string(output)) +} + +func addToGitignore(repoRoot, entry string) error { + gitignorePath := filepath.Join(repoRoot, ".gitignore") + + // Read existing content + content, err := os.ReadFile(gitignorePath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // Check if already present + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.TrimSpace(line) == entry || strings.TrimSpace(line) == entry+"/" { + return nil // Already present + } + } + + // Append entry + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + // Add newline if file doesn't end with one + if len(content) > 0 && content[len(content)-1] != '\n' { + if _, err := f.WriteString("\n"); err != nil { + return err + } + } + + // Add comment and entry + if _, err := f.WriteString(fmt.Sprintf("# bd worktree\n%s/\n", entry)); err != nil { + return err + } + + return nil +} + +func removeFromGitignore(repoRoot, entry string) error { + gitignorePath := filepath.Join(repoRoot, ".gitignore") + + content, err := os.ReadFile(gitignorePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + lines := strings.Split(string(content), "\n") + var newLines []string + skipNext := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "# bd worktree" { + skipNext = true + continue + } + if skipNext && (trimmed == entry || trimmed == entry+"/") { + skipNext = false + continue + } + skipNext = false + newLines = append(newLines, line) + } + + return os.WriteFile(gitignorePath, []byte(strings.Join(newLines, "\n")), 0644) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +}