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.
456 lines
16 KiB
Go
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
|
|
}
|