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.
319 lines
12 KiB
Go
319 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/git"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
)
|
|
|
|
// syncBranchCommitAndPush commits JSONL to the sync branch using a worktree.
|
|
// Returns true if changes were committed, false if no changes or sync.branch not configured.
|
|
// This is a convenience wrapper that calls syncBranchCommitAndPushWithOptions with default options.
|
|
func syncBranchCommitAndPush(ctx context.Context, store storage.Storage, autoPush bool, log daemonLogger) (bool, error) {
|
|
return syncBranchCommitAndPushWithOptions(ctx, store, autoPush, false, log)
|
|
}
|
|
|
|
// syncBranchCommitAndPushWithOptions commits JSONL to the sync branch using a worktree.
|
|
// Returns true if changes were committed, false if no changes or sync.branch not configured.
|
|
// If forceOverwrite is true, the local JSONL is copied to the worktree without merging,
|
|
// which is necessary for delete mutations to be properly reflected in the sync branch.
|
|
func syncBranchCommitAndPushWithOptions(ctx context.Context, store storage.Storage, autoPush, forceOverwrite bool, log daemonLogger) (bool, error) {
|
|
// Check if any remote exists (bd-biwp: support local-only repos)
|
|
if !hasGitRemote(ctx) {
|
|
return true, nil // Skip sync branch commit/push in local-only mode
|
|
}
|
|
|
|
// Get sync branch configuration (supports BEADS_SYNC_BRANCH override)
|
|
syncBranch, err := syncbranch.Get(ctx, store)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get sync branch: %w", err)
|
|
}
|
|
|
|
// If no sync branch configured, caller should use regular commit logic
|
|
if syncBranch == "" {
|
|
return false, nil
|
|
}
|
|
|
|
log.log("Using sync branch: %s", syncBranch)
|
|
|
|
// Get main repo root (for worktrees, this is the main repo, not worktree)
|
|
repoRoot, err := git.GetMainRepoRoot()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get main repo root: %w", err)
|
|
}
|
|
|
|
// Use worktree-aware git directory detection
|
|
gitDir, err := git.GetGitDir()
|
|
if err != nil {
|
|
return false, fmt.Errorf("not a git repository: %w", err)
|
|
}
|
|
|
|
// Worktree path is under .git/beads-worktrees/<branch>
|
|
worktreePath := filepath.Join(gitDir, "beads-worktrees", syncBranch)
|
|
|
|
// Initialize worktree manager
|
|
wtMgr := git.NewWorktreeManager(repoRoot)
|
|
|
|
// Ensure worktree exists and is healthy
|
|
// CreateBeadsWorktree now performs a full health check internally and
|
|
// automatically repairs unhealthy worktrees by removing and recreating them
|
|
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
|
return false, fmt.Errorf("failed to create worktree: %w", err)
|
|
}
|
|
|
|
// Sync JSONL file to worktree
|
|
// Get the actual JSONL path (could be issues.jsonl, beads.base.jsonl, etc.)
|
|
jsonlPath := findJSONLPath()
|
|
if jsonlPath == "" {
|
|
return false, fmt.Errorf("JSONL path not found")
|
|
}
|
|
|
|
// Convert absolute path to relative path from repo root
|
|
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get relative JSONL path: %w", err)
|
|
}
|
|
|
|
// Use SyncJSONLToWorktreeWithOptions to pass forceOverwrite flag.
|
|
// When forceOverwrite is true (mutation-triggered sync, especially delete),
|
|
// the local JSONL is copied directly without merging, ensuring deletions
|
|
// are properly reflected in the sync branch.
|
|
syncOpts := git.SyncOptions{ForceOverwrite: forceOverwrite}
|
|
if err := wtMgr.SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRelPath, syncOpts); err != nil {
|
|
return false, fmt.Errorf("failed to sync JSONL to worktree: %w", err)
|
|
}
|
|
|
|
// Check for changes in worktree
|
|
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
|
|
hasChanges, err := gitHasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check for changes in worktree: %w", err)
|
|
}
|
|
|
|
if !hasChanges {
|
|
log.log("No changes to commit in sync branch")
|
|
return false, nil
|
|
}
|
|
|
|
// Commit in worktree
|
|
message := fmt.Sprintf("bd daemon sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
|
if err := gitCommitInWorktree(ctx, worktreePath, worktreeJSONLPath, message); err != nil {
|
|
return false, fmt.Errorf("failed to commit in worktree: %w", err)
|
|
}
|
|
log.log("Committed changes to sync branch %s", syncBranch)
|
|
|
|
// Push if enabled
|
|
if autoPush {
|
|
if err := gitPushFromWorktree(ctx, worktreePath, syncBranch); err != nil {
|
|
return false, fmt.Errorf("failed to push from worktree: %w", err)
|
|
}
|
|
log.log("Pushed sync branch %s to remote", syncBranch)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// getGitRoot returns the git repository root directory
|
|
func getGitRoot(ctx context.Context) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get git root: %w", err)
|
|
}
|
|
return strings.TrimSpace(string(output)), nil
|
|
}
|
|
|
|
// gitHasChangesInWorktree checks if there are changes in the worktree
|
|
func gitHasChangesInWorktree(ctx context.Context, worktreePath, filePath string) (bool, error) {
|
|
// Make filePath relative to worktree
|
|
relPath, err := filepath.Rel(worktreePath, filePath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to make path relative: %w", err)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain", relPath) // #nosec G204 - worktreePath and relPath are derived from trusted git operations
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return false, fmt.Errorf("git status failed in worktree: %w", err)
|
|
}
|
|
return len(strings.TrimSpace(string(output))) > 0, nil
|
|
}
|
|
|
|
// gitCommitInWorktree commits changes in the worktree
|
|
func gitCommitInWorktree(ctx context.Context, worktreePath, filePath, message string) error {
|
|
// Make filePath relative to worktree
|
|
relPath, err := filepath.Rel(worktreePath, filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to make path relative: %w", err)
|
|
}
|
|
|
|
// Stage the file
|
|
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", relPath) // #nosec G204 - worktreePath and relPath are derived from trusted git operations
|
|
if err := addCmd.Run(); err != nil {
|
|
return fmt.Errorf("git add failed in worktree: %w", err)
|
|
}
|
|
|
|
// Commit with --no-verify to skip hooks (pre-commit hook would fail in worktree context)
|
|
// The worktree is internal to bd sync, so we don't need to run bd's pre-commit hook
|
|
commitCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "commit", "--no-verify", "-m", message)
|
|
output, err := commitCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("git commit failed in worktree: %w\n%s", err, output)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// gitPushFromWorktree pushes the sync branch from the worktree.
|
|
// If push fails due to remote having newer commits, it will fetch, rebase, and retry.
|
|
func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error {
|
|
// Get remote name (usually "origin")
|
|
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) // #nosec G204 - worktreePath and branch are from config
|
|
remoteOutput, err := remoteCmd.Output()
|
|
if err != nil {
|
|
// If no remote configured, default to "origin" and set up tracking
|
|
remoteOutput = []byte("origin\n")
|
|
}
|
|
remote := strings.TrimSpace(string(remoteOutput))
|
|
|
|
// Push with explicit remote and branch, set upstream if not set
|
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch) // #nosec G204 - worktreePath, remote, and branch are from config
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Check if push failed due to remote having newer commits
|
|
outputStr := string(output)
|
|
if strings.Contains(outputStr, "fetch first") || strings.Contains(outputStr, "non-fast-forward") {
|
|
// Fetch and rebase, then retry push
|
|
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, branch) // #nosec G204
|
|
if fetchOutput, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil {
|
|
return fmt.Errorf("git fetch failed in worktree: %w\n%s", fetchErr, fetchOutput)
|
|
}
|
|
|
|
// Rebase local commits on top of remote
|
|
rebaseCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", remote+"/"+branch) // #nosec G204
|
|
if rebaseOutput, rebaseErr := rebaseCmd.CombinedOutput(); rebaseErr != nil {
|
|
// If rebase fails (conflict), abort and return error
|
|
abortCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", "--abort") // #nosec G204
|
|
_ = abortCmd.Run()
|
|
return fmt.Errorf("git rebase failed in worktree (sync branch may have conflicts): %w\n%s", rebaseErr, rebaseOutput)
|
|
}
|
|
|
|
// Retry push after successful rebase
|
|
retryCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch) // #nosec G204
|
|
if retryOutput, retryErr := retryCmd.CombinedOutput(); retryErr != nil {
|
|
return fmt.Errorf("git push failed after rebase: %w\n%s", retryErr, retryOutput)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
return fmt.Errorf("git push failed from worktree: %w\n%s", err, output)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// syncBranchPull pulls changes from the sync branch into the worktree
|
|
// Returns true if pull was performed, false if sync.branch not configured
|
|
func syncBranchPull(ctx context.Context, store storage.Storage, log daemonLogger) (bool, error) {
|
|
// Check if any remote exists (bd-biwp: support local-only repos)
|
|
if !hasGitRemote(ctx) {
|
|
return true, nil // Skip sync branch pull in local-only mode
|
|
}
|
|
|
|
// Get sync branch configuration (supports BEADS_SYNC_BRANCH override)
|
|
syncBranch, err := syncbranch.Get(ctx, store)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get sync branch: %w", err)
|
|
}
|
|
|
|
// If no sync branch configured, caller should use regular pull logic
|
|
if syncBranch == "" {
|
|
return false, nil
|
|
}
|
|
|
|
// Get main repo root (for worktrees, this is the main repo, not worktree)
|
|
repoRoot, err := git.GetMainRepoRoot()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get main repo root: %w", err)
|
|
}
|
|
|
|
// Use worktree-aware git directory detection
|
|
gitDir, err := git.GetGitDir()
|
|
if err != nil {
|
|
return false, fmt.Errorf("not a git repository: %w", err)
|
|
}
|
|
|
|
// Worktree path is under .git/beads-worktrees/<branch>
|
|
worktreePath := filepath.Join(gitDir, "beads-worktrees", syncBranch)
|
|
|
|
// Initialize worktree manager
|
|
wtMgr := git.NewWorktreeManager(repoRoot)
|
|
|
|
// Ensure worktree exists
|
|
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
|
return false, fmt.Errorf("failed to create worktree: %w", err)
|
|
}
|
|
|
|
// Get remote name
|
|
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", syncBranch)) // #nosec G204 - worktreePath and syncBranch are from config
|
|
remoteOutput, err := remoteCmd.Output()
|
|
if err != nil {
|
|
// If no remote configured, default to "origin"
|
|
remoteOutput = []byte("origin\n")
|
|
}
|
|
remote := strings.TrimSpace(string(remoteOutput))
|
|
|
|
// Pull in worktree
|
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "pull", remote, syncBranch) // #nosec G204 - worktreePath, remote, and syncBranch are from config
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return false, fmt.Errorf("git pull failed in worktree: %w\n%s", err, output)
|
|
}
|
|
|
|
log.log("Pulled sync branch %s", syncBranch)
|
|
|
|
// Get the actual JSONL path
|
|
jsonlPath := findJSONLPath()
|
|
if jsonlPath == "" {
|
|
return false, fmt.Errorf("JSONL path not found")
|
|
}
|
|
|
|
// Convert to relative path
|
|
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get relative JSONL path: %w", err)
|
|
}
|
|
|
|
// Copy JSONL back to main repo
|
|
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
|
|
mainJSONLPath := jsonlPath
|
|
|
|
// Check if worktree JSONL exists
|
|
if _, err := os.Stat(worktreeJSONLPath); os.IsNotExist(err) {
|
|
// No JSONL in worktree yet, nothing to sync
|
|
return true, nil
|
|
}
|
|
|
|
// Copy JSONL from worktree to main repo
|
|
data, err := os.ReadFile(worktreeJSONLPath) // #nosec G304 - path is derived from trusted git worktree
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to read worktree JSONL: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(mainJSONLPath, data, 0644); err != nil { // #nosec G306 - JSONL needs to be readable
|
|
return false, fmt.Errorf("failed to write main JSONL: %w", err)
|
|
}
|
|
|
|
log.log("Synced JSONL from sync branch to main repo")
|
|
|
|
return true, nil
|
|
}
|