Centralizes repository context resolution via RepoContext API, fixing bugs where git commands run in the wrong repo when BEADS_DIR points elsewhere or in worktree scenarios.
752 lines
21 KiB
Go
752 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
// 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 {
|
|
CheckReadonly("worktree create")
|
|
ctx := context.Background()
|
|
|
|
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)
|
|
}
|
|
|
|
// Get repository context (validates .beads exists and resolves paths)
|
|
rc, err := beads.GetRepoContext()
|
|
if err != nil {
|
|
return fmt.Errorf("no .beads directory found; run 'bd init' first: %w", err)
|
|
}
|
|
|
|
// Worktree operations use CWD repo (where user is working), not BEADS_DIR repo
|
|
repoRoot := rc.CWDRepoRoot
|
|
if repoRoot == "" {
|
|
return fmt.Errorf("not in a git repository")
|
|
}
|
|
|
|
// Use BeadsDir from RepoContext (already follows redirects)
|
|
mainBeadsDir := rc.BeadsDir
|
|
|
|
// Determine branch name
|
|
branch := worktreeBranch
|
|
if branch == "" {
|
|
branch = filepath.Base(name)
|
|
}
|
|
|
|
// Create the worktree using secure git command
|
|
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "add", "-b", branch, worktreePath)
|
|
output, err := gitCmd.CombinedOutput()
|
|
if err != nil {
|
|
// Try without -b if branch already exists
|
|
gitCmd = gitCmdInDir(ctx, repoRoot, "worktree", "add", worktreePath, branch)
|
|
output, err = gitCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create worktree: %w\n%s", err, string(output))
|
|
}
|
|
}
|
|
|
|
// Helper to clean up worktree on failure
|
|
cleanupWorktree := func() {
|
|
cleanupCmd := gitCmdInDir(ctx, repoRoot, "worktree", "remove", "--force", worktreePath)
|
|
_ = cleanupCmd.Run()
|
|
}
|
|
|
|
// Create .beads directory in worktree
|
|
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
|
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
|
cleanupWorktree()
|
|
return fmt.Errorf("failed to create .beads directory: %w", err)
|
|
}
|
|
|
|
// Create redirect file
|
|
redirectPath := filepath.Join(worktreeBeadsDir, beads.RedirectFileName)
|
|
// Ensure mainBeadsDir is absolute for correct filepath.Rel() computation (GH#1098)
|
|
// beads.FindBeadsDir() may return a relative path in some contexts
|
|
absMainBeadsDir := utils.CanonicalizeIfRelative(mainBeadsDir)
|
|
relPath, err := filepath.Rel(worktreeBeadsDir, absMainBeadsDir)
|
|
if err != nil {
|
|
// Fall back to absolute path
|
|
relPath = absMainBeadsDir
|
|
}
|
|
// #nosec G306 - redirect file needs to be readable
|
|
if err := os.WriteFile(redirectPath, []byte(relPath+"\n"), 0644); err != nil {
|
|
cleanupWorktree()
|
|
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)) {
|
|
// Use relative path from repo root for gitignore entry
|
|
relWorktreePath, err := filepath.Rel(repoRoot, worktreePath)
|
|
if err != nil {
|
|
relWorktreePath = filepath.Base(worktreePath)
|
|
}
|
|
if err := addToGitignore(repoRoot, relWorktreePath); 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 {
|
|
ctx := context.Background()
|
|
|
|
// Get repository context
|
|
rc, err := beads.GetRepoContext()
|
|
if err != nil {
|
|
// Allow listing worktrees even without .beads (but no beads state info)
|
|
// Fall back to git.GetRepoRoot() for this case
|
|
repoRoot := git.GetRepoRoot()
|
|
if repoRoot == "" {
|
|
return fmt.Errorf("not in a git repository")
|
|
}
|
|
return listWorktreesWithoutBeads(ctx, repoRoot)
|
|
}
|
|
|
|
// Worktree operations use CWD repo (where user is working)
|
|
repoRoot := rc.CWDRepoRoot
|
|
if repoRoot == "" {
|
|
return fmt.Errorf("not in a git repository")
|
|
}
|
|
|
|
// List worktrees using secure git command
|
|
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "list", "--porcelain")
|
|
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 (using BeadsDir from RepoContext)
|
|
mainBeadsDir := rc.BeadsDir
|
|
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 {
|
|
CheckReadonly("worktree remove")
|
|
ctx := context.Background()
|
|
|
|
name := args[0]
|
|
|
|
// Get repository context - worktree remove works even without .beads
|
|
// but we try RepoContext first for consistency
|
|
var repoRoot string
|
|
rc, err := beads.GetRepoContext()
|
|
if err != nil {
|
|
// Fallback to git.GetRepoRoot() if no .beads
|
|
repoRoot = git.GetRepoRoot()
|
|
} else {
|
|
repoRoot = rc.CWDRepoRoot
|
|
}
|
|
if repoRoot == "" {
|
|
return fmt.Errorf("not in a git repository")
|
|
}
|
|
|
|
// Resolve worktree path
|
|
worktreePath, err := resolveWorktreePath(ctx, repoRoot, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Don't allow removing the main repository
|
|
absWorktree, _ := filepath.Abs(worktreePath)
|
|
absMain, _ := filepath.Abs(repoRoot)
|
|
if absWorktree == absMain {
|
|
return fmt.Errorf("cannot remove main repository as worktree")
|
|
}
|
|
|
|
// Safety checks unless --force
|
|
if !worktreeForce {
|
|
if err := checkWorktreeSafety(ctx, worktreePath); err != nil {
|
|
return fmt.Errorf("safety check failed: %w\nUse --force to skip safety checks", err)
|
|
}
|
|
}
|
|
|
|
// Remove worktree using secure git command
|
|
var gitCmd *exec.Cmd
|
|
if worktreeForce {
|
|
gitCmd = gitCmdInDir(ctx, repoRoot, "worktree", "remove", "--force", worktreePath)
|
|
} else {
|
|
gitCmd = gitCmdInDir(ctx, repoRoot, "worktree", "remove", worktreePath)
|
|
}
|
|
output, err := gitCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove worktree: %w\n%s", err, string(output))
|
|
}
|
|
|
|
// Remove from .gitignore - use relative path from repo root
|
|
relWorktreePath, err := filepath.Rel(repoRoot, worktreePath)
|
|
if err != nil {
|
|
relWorktreePath = filepath.Base(worktreePath)
|
|
}
|
|
if err := removeFromGitignore(repoRoot, relWorktreePath); 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 {
|
|
ctx := context.Background()
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current directory: %w", err)
|
|
}
|
|
|
|
// Check if we're in a worktree (use RepoContext if available, fallback to git)
|
|
var isWorktree bool
|
|
rc, rcErr := beads.GetRepoContext()
|
|
if rcErr == nil {
|
|
isWorktree = rc.IsWorktree
|
|
} else {
|
|
isWorktree = git.IsWorktree()
|
|
}
|
|
|
|
if !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(ctx, cwd)
|
|
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
|
|
|
|
// gitCmdInDir creates a git command that runs in the specified directory.
|
|
// This is used for worktree operations that need to run in a specific location
|
|
// (either the CWD repo root or a specific worktree path).
|
|
//
|
|
// Security: Sets GIT_HOOKS_PATH and GIT_TEMPLATE_DIR to disable hooks/templates
|
|
// for defense-in-depth, matching the pattern in RepoContext.GitCmd().
|
|
func gitCmdInDir(ctx context.Context, dir string, args ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = dir
|
|
// Security: Disable git hooks and templates (SEC-001, SEC-002)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_HOOKS_PATH=",
|
|
"GIT_TEMPLATE_DIR=",
|
|
)
|
|
return cmd
|
|
}
|
|
|
|
// listWorktreesWithoutBeads lists worktrees when no .beads directory exists.
|
|
// This fallback allows the command to work in repos that haven't been initialized.
|
|
func listWorktreesWithoutBeads(ctx context.Context, repoRoot string) error {
|
|
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "list", "--porcelain")
|
|
output, err := gitCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list worktrees: %w", err)
|
|
}
|
|
|
|
worktrees := parseWorktreeList(string(output))
|
|
|
|
// Set beads state to "none" for all worktrees
|
|
for i := range worktrees {
|
|
worktrees[i].BeadsState = "none"
|
|
}
|
|
|
|
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)"
|
|
}
|
|
fmt.Printf("%-20s %-40s %-20s %s\n",
|
|
truncate(name, 20),
|
|
truncate(wt.Path, 40),
|
|
truncate(wt.Branch, 20),
|
|
"none")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
path := strings.TrimPrefix(line, "worktree ")
|
|
current = WorktreeInfo{
|
|
Path: path,
|
|
Name: filepath.Base(path),
|
|
}
|
|
} 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)
|
|
// #nosec G304 - path is constructed from worktreePath which comes from git worktree list
|
|
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(ctx context.Context, 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
|
|
}
|
|
|
|
// Consult git's worktree registry - match by name (basename) or path
|
|
// This handles worktrees created in subdirectories (e.g., .worktrees/foo)
|
|
// where the name shown in "bd worktree list" doesn't match a simple path
|
|
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "list", "--porcelain")
|
|
output, err := gitCmd.CombinedOutput()
|
|
if err == nil {
|
|
worktrees := parseWorktreeList(string(output))
|
|
for _, wt := range worktrees {
|
|
if wt.Name == name || wt.Path == name {
|
|
if _, err := os.Stat(wt.Path); err == nil {
|
|
return wt.Path, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("worktree not found: %s", name)
|
|
}
|
|
|
|
func checkWorktreeSafety(ctx context.Context, worktreePath string) error {
|
|
// Check for uncommitted changes
|
|
gitCmd := gitCmdInDir(ctx, worktreePath, "status", "--porcelain")
|
|
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 = gitCmdInDir(ctx, worktreePath, "log", "@{upstream}..", "--oneline")
|
|
output, _ = gitCmd.CombinedOutput() // Ignore error (no upstream is ok)
|
|
if len(strings.TrimSpace(string(output))) > 0 {
|
|
return fmt.Errorf("worktree has unpushed commits")
|
|
}
|
|
|
|
// Note: We intentionally don't check stashes here because git stashes
|
|
// are stored globally in the main repo, not per-worktree. Checking
|
|
// stashes would give misleading results.
|
|
|
|
return nil
|
|
}
|
|
|
|
func getWorktreeCurrentBranch(ctx context.Context, dir string) string {
|
|
gitCmd := gitCmdInDir(ctx, dir, "branch", "--show-current")
|
|
output, err := gitCmd.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) //nolint:gosec // G304: gitignorePath from known repoRoot
|
|
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) //nolint:gosec // G302: .gitignore should be world-readable
|
|
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) //nolint:gosec // G304: gitignorePath from known repoRoot
|
|
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) //nolint:gosec // G306: .gitignore should be world-readable
|
|
}
|
|
|
|
func truncate(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|