feat: add bd worktree command for parallel development

Adds user-facing worktree management commands:
- bd worktree create <name> - creates worktree with beads redirect
- bd worktree list - shows all worktrees and their beads state
- bd worktree remove <name> - removes worktree with safety checks
- bd worktree info - shows current worktree info

The create command automatically sets up .beads/redirect to point to
the main repos .beads directory, ensuring all worktrees share
the same issue database. This enables parallel development with
multiple agents or features.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-28 01:01:52 -08:00
parent ecff74e2af
commit f631299a87

608
cmd/bd/worktree_cmd.go Normal file
View File

@@ -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 <name> [--branch=<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 ./<name> (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 <name>",
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] + "..."
}