Files
beads/internal/git/worktree.go
Charles P. Cross 665e5b3a87 fix(worktree): integrate health check into CreateBeadsWorktree to prevent redundant checks
The error message 'path exists but is not a valid git worktree' was appearing
in daemon.log when the daemon attempted to use an existing worktree that was
in the git worktree list but had other issues (broken sparse checkout, etc.).

Root cause:
- CreateBeadsWorktree only checked isValidWorktree (is it in git worktree list)
- CheckWorktreeHealth was called separately and checked additional things
- If the worktree passed isValidWorktree but failed health check, an error
  was logged and repair was attempted

Fix:
- CreateBeadsWorktree now performs a full health check when it finds an
  existing worktree that's in the git worktree list
- If the health check fails, it automatically removes and recreates the
  worktree
- Removed redundant CheckWorktreeHealth calls in daemon_sync_branch.go and
  syncbranch/worktree.go since CreateBeadsWorktree now handles this internally

This eliminates the confusing error message and ensures worktrees are always
in a healthy state after CreateBeadsWorktree returns successfully.
2025-12-22 18:57:43 -05:00

456 lines
16 KiB
Go

package git
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/merge"
)
// WorktreeManager handles git worktree lifecycle for separate beads branches
type WorktreeManager struct {
repoPath string // Path to the main repository
}
// NewWorktreeManager creates a new worktree manager for the given repository
func NewWorktreeManager(repoPath string) *WorktreeManager {
return &WorktreeManager{
repoPath: repoPath,
}
}
// CreateBeadsWorktree creates a git worktree for the beads branch with sparse checkout
// Returns the path to the created worktree
func (wm *WorktreeManager) CreateBeadsWorktree(branch, worktreePath string) error {
// Prune stale worktree entries first
pruneCmd := exec.Command("git", "worktree", "prune")
pruneCmd.Dir = wm.repoPath
_ = pruneCmd.Run() // Best effort, ignore errors
// Check if worktree already exists
if _, err := os.Stat(worktreePath); err == nil {
// Worktree path exists, check if it's a valid worktree
if valid, err := wm.isValidWorktree(worktreePath); err == nil && valid {
// Worktree exists and is in git worktree list, verify full health
if err := wm.CheckWorktreeHealth(worktreePath); err == nil {
return nil // Already exists and is fully healthy
}
// Health check failed, try to repair by removing and recreating
if err := wm.RemoveBeadsWorktree(worktreePath); err != nil {
// Log but continue - we'll try to recreate anyway
_ = os.RemoveAll(worktreePath)
}
} else {
// Path exists but isn't a valid worktree, remove it
if err := os.RemoveAll(worktreePath); err != nil {
return fmt.Errorf("failed to remove invalid worktree path: %w", err)
}
}
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(worktreePath), 0750); err != nil {
return fmt.Errorf("failed to create worktree parent directory: %w", err)
}
// Check if branch exists remotely or locally
branchExists := wm.branchExists(branch)
// Create worktree without checking out files initially
// Use -f (force) to handle "missing but already registered" state (issue #609)
// This occurs when the worktree directory was deleted but git registration persists
var cmd *exec.Cmd
if branchExists {
// Checkout existing branch
cmd = exec.Command("git", "worktree", "add", "-f", "--no-checkout", worktreePath, branch)
} else {
// Create new branch
cmd = exec.Command("git", "worktree", "add", "-f", "--no-checkout", "-b", branch, worktreePath)
}
cmd.Dir = wm.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create worktree: %w\nOutput: %s", err, string(output))
}
// Configure sparse checkout to only include .beads/
if err := wm.configureSparseCheckout(worktreePath); err != nil {
// Cleanup worktree on failure
_ = wm.RemoveBeadsWorktree(worktreePath)
return fmt.Errorf("failed to configure sparse checkout: %w", err)
}
// Now checkout the branch with sparse checkout active
checkoutCmd := exec.Command("git", "checkout", branch)
checkoutCmd.Dir = worktreePath
output, err = checkoutCmd.CombinedOutput()
if err != nil {
_ = wm.RemoveBeadsWorktree(worktreePath)
return fmt.Errorf("failed to checkout branch in worktree: %w\nOutput: %s", err, string(output))
}
return nil
}
// RemoveBeadsWorktree removes a git worktree and cleans up
func (wm *WorktreeManager) RemoveBeadsWorktree(worktreePath string) error {
// First, try to remove via git worktree remove
cmd := exec.Command("git", "worktree", "remove", worktreePath, "--force")
cmd.Dir = wm.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
// If git worktree remove fails, manually remove the directory
// and prune the worktree list
if removeErr := os.RemoveAll(worktreePath); removeErr != nil {
return fmt.Errorf("failed to remove worktree directory: %w (git error: %v, output: %s)",
removeErr, err, string(output))
}
// Prune stale worktree entries
pruneCmd := exec.Command("git", "worktree", "prune")
pruneCmd.Dir = wm.repoPath
_ = pruneCmd.Run() // Best effort, ignore errors
}
return nil
}
// CheckWorktreeHealth verifies the worktree is in a good state and attempts to repair if needed
func (wm *WorktreeManager) CheckWorktreeHealth(worktreePath string) error {
// Check if path exists
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
return fmt.Errorf("worktree path does not exist: %s", worktreePath)
}
// Check if it's a valid worktree
valid, err := wm.isValidWorktree(worktreePath)
if err != nil {
return fmt.Errorf("failed to check worktree validity: %w", err)
}
if !valid {
return fmt.Errorf("path exists but is not a valid git worktree: %s", worktreePath)
}
// Check if .git file exists and points to the right place
gitFile := filepath.Join(worktreePath, ".git")
if _, err := os.Stat(gitFile); err != nil {
return fmt.Errorf("worktree .git file missing: %w", err)
}
// Verify sparse checkout is configured correctly
if err := wm.verifySparseCheckout(worktreePath); err != nil {
// Try to fix by reconfiguring
if fixErr := wm.configureSparseCheckout(worktreePath); fixErr != nil {
return fmt.Errorf("sparse checkout invalid and failed to fix: %w (original error: %v)", fixErr, err)
}
}
return nil
}
// SyncOptions configures the behavior of SyncJSONLToWorktree
type SyncOptions struct {
// ForceOverwrite bypasses the merge logic and always overwrites the worktree
// JSONL with the local JSONL. This should be set to true when the daemon
// knows that a mutation (especially delete) occurred, so the local state
// is authoritative and should not be merged with potentially stale worktree data.
ForceOverwrite bool
}
// SyncJSONLToWorktree syncs the JSONL file from main repo to worktree.
// If the worktree has issues that the local repo doesn't have, it merges them
// instead of overwriting. This prevents data loss when a fresh clone syncs
// with fewer issues than the remote. (bd-52q fix for GitHub #464)
// Note: This is a convenience wrapper that calls SyncJSONLToWorktreeWithOptions
// with default options (ForceOverwrite=false).
func (wm *WorktreeManager) SyncJSONLToWorktree(worktreePath, jsonlRelPath string) error {
return wm.SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRelPath, SyncOptions{})
}
// SyncJSONLToWorktreeWithOptions syncs the JSONL file from main repo to worktree
// with configurable options.
// If ForceOverwrite is true, the local JSONL is always copied to the worktree,
// bypassing the merge logic. This is used when the daemon knows a mutation
// (especially delete) occurred and the local state is authoritative.
// If ForceOverwrite is false (default), the function uses merge logic to prevent
// data loss when a fresh clone syncs with fewer issues than the remote.
func (wm *WorktreeManager) SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRelPath string, opts SyncOptions) error {
// Source: main repo JSONL
srcPath := filepath.Join(wm.repoPath, jsonlRelPath)
// Destination: worktree JSONL
dstPath := filepath.Join(worktreePath, jsonlRelPath)
// Ensure destination directory exists
dstDir := filepath.Dir(dstPath)
if err := os.MkdirAll(dstDir, 0750); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Read source file
srcData, err := os.ReadFile(srcPath) // #nosec G304 - controlled path from config
if err != nil {
return fmt.Errorf("failed to read source JSONL: %w", err)
}
// Check if destination exists and has content
dstData, dstErr := os.ReadFile(dstPath) // #nosec G304 - controlled path
if dstErr != nil || len(dstData) == 0 {
// Destination doesn't exist or is empty - just copy
if err := os.WriteFile(dstPath, srcData, 0644); err != nil { // #nosec G306 - JSONL needs to be readable
return fmt.Errorf("failed to write destination JSONL: %w", err)
}
return nil
}
// ForceOverwrite: When the daemon knows a mutation occurred (especially delete),
// the local state is authoritative and should overwrite the worktree without merging.
// This fixes the bug where `bd delete` mutations were not reflected in the sync branch
// because the merge logic would re-add the deleted issue.
if opts.ForceOverwrite {
if err := os.WriteFile(dstPath, srcData, 0644); err != nil { // #nosec G306 - JSONL needs to be readable
return fmt.Errorf("failed to write destination JSONL: %w", err)
}
return nil
}
// Count issues in both files
srcCount := countJSONLIssues(srcData)
dstCount := countJSONLIssues(dstData)
// If source has same or more issues, just copy (source is authoritative)
if srcCount >= dstCount {
if err := os.WriteFile(dstPath, srcData, 0644); err != nil { // #nosec G306 - JSONL needs to be readable
return fmt.Errorf("failed to write destination JSONL: %w", err)
}
return nil
}
// Source has fewer issues than destination - this indicates the local repo
// doesn't have all the issues from the sync branch. Merge instead of overwrite.
// (bd-52q: This prevents fresh clones from accidentally deleting remote issues)
mergedData, err := wm.mergeJSONLFiles(srcData, dstData)
if err != nil {
// If merge fails, fall back to copy behavior but log warning
// This shouldn't happen but ensures we don't break existing behavior
fmt.Fprintf(os.Stderr, "Warning: JSONL merge failed (%v), falling back to overwrite\n", err)
if writeErr := os.WriteFile(dstPath, srcData, 0644); writeErr != nil { // #nosec G306
return fmt.Errorf("failed to write destination JSONL: %w", writeErr)
}
return nil
}
if err := os.WriteFile(dstPath, mergedData, 0644); err != nil { // #nosec G306 - JSONL needs to be readable
return fmt.Errorf("failed to write merged JSONL: %w", err)
}
return nil
}
// countJSONLIssues counts the number of valid JSON lines in JSONL data
func countJSONLIssues(data []byte) int {
count := 0
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && strings.HasPrefix(line, "{") {
count++
}
}
return count
}
// mergeJSONLFiles merges two JSONL files using 3-way merge with empty base.
// This combines issues from both files, with the source (local) taking precedence
// for issues that exist in both.
func (wm *WorktreeManager) mergeJSONLFiles(srcData, dstData []byte) ([]byte, error) {
// Create temp files for merge
tmpDir, err := os.MkdirTemp("", "bd-worktree-merge-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
baseFile := filepath.Join(tmpDir, "base.jsonl")
leftFile := filepath.Join(tmpDir, "left.jsonl") // source (local)
rightFile := filepath.Join(tmpDir, "right.jsonl") // destination (worktree)
outputFile := filepath.Join(tmpDir, "merged.jsonl")
// Empty base - treat this as both sides adding issues
if err := os.WriteFile(baseFile, []byte{}, 0600); err != nil {
return nil, fmt.Errorf("failed to write base file: %w", err)
}
// Source (local) is "left" - takes precedence for conflicts
if err := os.WriteFile(leftFile, srcData, 0600); err != nil {
return nil, fmt.Errorf("failed to write left file: %w", err)
}
// Destination (worktree) is "right"
if err := os.WriteFile(rightFile, dstData, 0600); err != nil {
return nil, fmt.Errorf("failed to write right file: %w", err)
}
// Perform 3-way merge
err = merge.Merge3Way(outputFile, baseFile, leftFile, rightFile, false)
if err != nil {
// Check if it's just a conflict warning (merge still produced output)
if !strings.Contains(err.Error(), "merge completed with") {
return nil, fmt.Errorf("3-way merge failed: %w", err)
}
// Conflicts are auto-resolved, continue
}
// Read merged result
mergedData, err := os.ReadFile(outputFile) // #nosec G304 - temp file we created
if err != nil {
return nil, fmt.Errorf("failed to read merged file: %w", err)
}
return mergedData, nil
}
// isValidWorktree checks if the path is a valid git worktree
func (wm *WorktreeManager) isValidWorktree(worktreePath string) (bool, error) {
cmd := exec.Command("git", "worktree", "list", "--porcelain")
cmd.Dir = wm.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("failed to list worktrees: %w", err)
}
// Parse output to see if our worktree is listed
// Use EvalSymlinks to resolve any symlinks (e.g., /tmp -> /private/tmp on macOS)
absWorktreePath, err := filepath.EvalSymlinks(worktreePath)
if err != nil {
// If path doesn't exist yet, just use Abs
absWorktreePath, err = filepath.Abs(worktreePath)
if err != nil {
return false, err
}
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "worktree ") {
path := strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
// Resolve symlinks for the git-reported path too
absPath, err := filepath.EvalSymlinks(path)
if err != nil {
absPath, err = filepath.Abs(path)
if err != nil {
continue
}
}
if absPath == absWorktreePath {
return true, nil
}
}
}
return false, nil
}
// branchExists checks if a branch exists locally or remotely
func (wm *WorktreeManager) branchExists(branch string) bool {
// Check local branches
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch) // #nosec G204 - branch name from config
cmd.Dir = wm.repoPath
if err := cmd.Run(); err == nil {
return true
}
// Check remote branches
cmd = exec.Command("git", "show-ref", "--verify", "--quiet", "refs/remotes/origin/"+branch) // #nosec G204 - branch name from config
cmd.Dir = wm.repoPath
if err := cmd.Run(); err == nil {
return true
}
return false
}
// configureSparseCheckout sets up sparse checkout to only include .beads/
func (wm *WorktreeManager) configureSparseCheckout(worktreePath string) error {
// Get the actual git directory (for worktrees, .git is a file)
gitFile := filepath.Join(worktreePath, ".git")
gitContent, err := os.ReadFile(gitFile) // #nosec G304 - controlled path
if err != nil {
return fmt.Errorf("failed to read .git file: %w", err)
}
// Parse "gitdir: /path/to/git/dir"
gitDirLine := strings.TrimSpace(string(gitContent))
if !strings.HasPrefix(gitDirLine, "gitdir: ") {
return fmt.Errorf("invalid .git file format: %s", gitDirLine)
}
gitDir := strings.TrimPrefix(gitDirLine, "gitdir: ")
// Enable sparse checkout config
cmd := exec.Command("git", "config", "core.sparseCheckout", "true")
cmd.Dir = worktreePath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to enable sparse checkout: %w\nOutput: %s", err, string(output))
}
// Create info directory if it doesn't exist
infoDir := filepath.Join(gitDir, "info")
if err := os.MkdirAll(infoDir, 0750); err != nil {
return fmt.Errorf("failed to create info directory: %w", err)
}
// Write sparse-checkout file to include only .beads/
sparseFile := filepath.Join(infoDir, "sparse-checkout")
sparseContent := ".beads/*\n"
if err := os.WriteFile(sparseFile, []byte(sparseContent), 0644); err != nil { // #nosec G306 - sparse-checkout config file needs standard permissions
return fmt.Errorf("failed to write sparse-checkout file: %w", err)
}
return nil
}
// verifySparseCheckout checks if sparse checkout is configured correctly
func (wm *WorktreeManager) verifySparseCheckout(worktreePath string) error {
// Check if sparse-checkout file exists and contains .beads
sparseFile := filepath.Join(worktreePath, ".git", "info", "sparse-checkout")
// For worktrees, .git is a file pointing to the actual git dir
// We need to read the actual git directory location
gitFile := filepath.Join(worktreePath, ".git")
gitContent, err := os.ReadFile(gitFile) // #nosec G304 - controlled path
if err != nil {
return fmt.Errorf("failed to read .git file: %w", err)
}
// Parse "gitdir: /path/to/git/dir"
gitDirLine := strings.TrimSpace(string(gitContent))
if !strings.HasPrefix(gitDirLine, "gitdir: ") {
return fmt.Errorf("invalid .git file format")
}
gitDir := strings.TrimPrefix(gitDirLine, "gitdir: ")
// Sparse checkout file is in the git directory
sparseFile = filepath.Join(gitDir, "info", "sparse-checkout")
data, err := os.ReadFile(sparseFile) // #nosec G304 - controlled path
if err != nil {
return fmt.Errorf("sparse-checkout file not found: %w", err)
}
// Verify it contains .beads
if !strings.Contains(string(data), ".beads") {
return fmt.Errorf("sparse-checkout does not include .beads")
}
return nil
}