Files
beads/cmd/bd/daemon_sync_branch.go
Peter Chanthamynavong dbd505656d fix(daemon): add sync-branch guard to daemon code paths (#1271)
* fix(daemon): skip export when sync-branch matches current

Prevent redundant export operations by checking if the daemon's sync
branch matches the current active branch.

Previously, the daemon would attempt to perform an export even when
already on the target branch. This logic now skips the export step in
such cases to avoid unnecessary overhead and potential conflicts.
Includes a new integration test to verify the guard logic.

* fix(daemon): prevent sync on guarded branches

Add checks to verify if a branch is guarded before performing automated
sync cycles, auto-imports, or branch-specific commit and pull operations.
This prevents the daemon from modifying protected branches or running
synchronization tasks where they are restricted.

Includes comprehensive integration tests to verify the guard logic
during sync-branch operations.

* fix(daemon): warn on sync branch misconfiguration at startup

The daemon now checks for sync branch name conflicts during its startup
loop. This provides early feedback if the sync branch is configured
in a way that might conflict with existing branches or other settings.

The warnIfSyncBranchMisconfigured function performs the validation
and logs a warning to the console. Integration tests verify that
the daemon correctly identifies and reports these misconfigurations
at initialization.
2026-01-24 17:10:08 -08:00

361 lines
14 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/config"
"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
}
// Guard: Skip if sync-branch == current-branch (GH#1258)
if shouldSkipDueToSameBranch(ctx, store, "sync-branch commit", log) {
return false, nil
}
// 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
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
// GH#810: Normalize path for bare repo worktrees
normalizedRelPath := git.NormalizeBeadsRelPath(jsonlRelPath)
worktreeJSONLPath := filepath.Join(worktreePath, normalizedRelPath)
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 {
// Get configured remote from bd config (sync.remote), default to empty (will use git config)
configuredRemote, _ := store.GetConfig(ctx, "sync.remote")
if err := gitPushFromWorktree(ctx, worktreePath, syncBranch, configuredRemote); 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
// Use --sparse to work correctly with sparse-checkout enabled worktrees (fixes #1076)
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", "--sparse", 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)
}
// Build commit args with config-based author and signing options (GH#1051)
// Also use --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
args := []string{"-C", worktreePath, "commit", "--no-verify"}
// Add --author if configured (GH#1051: apply git.author config to daemon commits)
if author := config.GetString("git.author"); author != "" {
args = append(args, "--author", author)
}
// Add --no-gpg-sign if configured
if config.GetBool("git.no-gpg-sign") {
args = append(args, "--no-gpg-sign")
}
args = append(args, "-m", message)
commitCmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 - args built from trusted config values
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.
// The configuredRemote parameter allows passing the bd config sync.remote value.
func gitPushFromWorktree(ctx context.Context, worktreePath, branch, configuredRemote string) error {
// Use configured remote if provided, otherwise check git branch config
remote := configuredRemote
if remote == "" {
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
}
// Guard: Skip if sync-branch == current-branch (GH#1258)
if shouldSkipDueToSameBranch(ctx, store, "sync-branch pull", log) {
return false, nil
}
// 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 - check bd config first, then git branch config, then default to "origin"
remote := ""
if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" {
remote = configuredRemote
} else {
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
// GH#810: Normalize path for bare repo worktrees
normalizedRelPath := git.NormalizeBeadsRelPath(jsonlRelPath)
worktreeJSONLPath := filepath.Join(worktreePath, normalizedRelPath)
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
}