Files
beads/cmd/bd/sync_branch.go
Bo 6a4a6170e7 fix(sync): add --sparse flag to git add for worktree compatibility (#1143)
Fixes #1076.

When git worktrees have sparse-checkout enabled, git add operations can
fail with 'outside of sparse-checkout cone' errors. Adding the --sparse
flag allows git to stage files outside the sparse-checkout cone.

Changes:
- Add --sparse flag to git add in daemon_sync_branch.go (line 165)
- Add --sparse flag to git add in migrate_sync.go (line 343)
- Add --sparse flag to git add in sync_branch.go (line 308)
- Add --sparse flag to git add in worktree.go (line 732)

All changes include comments referencing #1076 for traceability.
2026-01-19 10:21:12 -08:00

365 lines
13 KiB
Go

package main
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/syncbranch"
)
// getCurrentBranch returns the name of the current git branch in the beads repo.
// Uses symbolic-ref instead of rev-parse to work in fresh repos without commits (bd-flil)
// GH#1110: Now uses RepoContext to ensure we query the beads repo, not CWD.
// Falls back to CWD-based query if no beads context found (for tests/standalone git).
func getCurrentBranch(ctx context.Context) (string, error) {
var cmd *exec.Cmd
if rc, err := beads.GetRepoContext(); err == nil {
cmd = rc.GitCmd(ctx, "symbolic-ref", "--short", "HEAD")
} else {
// Fallback to CWD for tests or repos without beads
cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
}
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get current branch: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// getCurrentBranchOrHEAD returns the current branch name, or "HEAD" if in detached HEAD state.
// This is useful for jj/jujutsu compatibility where HEAD is always detached but we still
// need a reference for git operations like log and diff.
func getCurrentBranchOrHEAD(ctx context.Context) string {
branch, err := getCurrentBranch(ctx)
if err != nil {
// Detached HEAD - return "HEAD" as the reference
return "HEAD"
}
return branch
}
// getSyncBranch returns the configured sync branch name
func getSyncBranch(ctx context.Context) (string, error) {
// Ensure store is initialized
if err := ensureStoreActive(); err != nil {
return "", fmt.Errorf("failed to initialize store: %w", err)
}
syncBranch, err := syncbranch.Get(ctx, store)
if err != nil {
return "", fmt.Errorf("failed to get sync branch config: %w", err)
}
if syncBranch == "" {
return "", fmt.Errorf("sync.branch not configured (run 'bd config set sync.branch <branch-name>')")
}
return syncBranch, nil
}
// showSyncStatus shows the diff between sync branch and main branch.
// GH#1110: Now uses RepoContext to ensure git commands run in beads repo.
func showSyncStatus(ctx context.Context) error {
if !isGitRepo() {
return fmt.Errorf("not in a git repository")
}
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("failed to get repo context: %w", err)
}
currentBranch := getCurrentBranchOrHEAD(ctx)
syncBranch, err := getSyncBranch(ctx)
if err != nil {
return err
}
// Check if sync branch exists
checkCmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
if err := checkCmd.Run(); err != nil {
return fmt.Errorf("sync branch '%s' does not exist", syncBranch)
}
fmt.Printf("Current branch: %s\n", currentBranch)
fmt.Printf("Sync branch: %s\n\n", syncBranch)
// Show commit diff
fmt.Println("Commits in sync branch not in main:")
logCmd := rc.GitCmd(ctx, "log", "--oneline", currentBranch+".."+syncBranch)
logOutput, err := logCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get commit log: %w\n%s", err, logOutput)
}
if len(strings.TrimSpace(string(logOutput))) == 0 {
fmt.Println(" (none)")
} else {
fmt.Print(string(logOutput))
}
fmt.Println("\nCommits in main not in sync branch:")
logCmd = rc.GitCmd(ctx, "log", "--oneline", syncBranch+".."+currentBranch)
logOutput, err = logCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get commit log: %w\n%s", err, logOutput)
}
if len(strings.TrimSpace(string(logOutput))) == 0 {
fmt.Println(" (none)")
} else {
fmt.Print(string(logOutput))
}
// Show file diff for .beads/issues.jsonl
fmt.Println("\nFile differences in .beads/issues.jsonl:")
diffCmd := rc.GitCmd(ctx, "diff", currentBranch+"..."+syncBranch, "--", ".beads/issues.jsonl")
diffOutput, err := diffCmd.CombinedOutput()
if err != nil {
// diff returns non-zero when there are differences, which is fine
if len(diffOutput) == 0 {
return fmt.Errorf("failed to get diff: %w", err)
}
}
if len(strings.TrimSpace(string(diffOutput))) == 0 {
fmt.Println(" (no differences)")
} else {
fmt.Print(string(diffOutput))
}
return nil
}
// mergeSyncBranch merges the sync branch back to the main branch.
// GH#1110: Now uses RepoContext to ensure git commands run in beads repo.
func mergeSyncBranch(ctx context.Context, dryRun bool) error {
if !isGitRepo() {
return fmt.Errorf("not in a git repository")
}
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("failed to get repo context: %w", err)
}
currentBranch, err := getCurrentBranch(ctx)
if err != nil {
return err
}
syncBranch, err := getSyncBranch(ctx)
if err != nil {
return err
}
// Check if sync branch exists
checkCmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
if err := checkCmd.Run(); err != nil {
return fmt.Errorf("sync branch '%s' does not exist", syncBranch)
}
// Check if there are uncommitted changes
statusCmd := rc.GitCmd(ctx, "status", "--porcelain")
statusOutput, err := statusCmd.Output()
if err != nil {
return fmt.Errorf("failed to check git status: %w", err)
}
if len(strings.TrimSpace(string(statusOutput))) > 0 {
return fmt.Errorf("uncommitted changes detected - commit or stash them first")
}
// GH#1103: Skip-worktree conflict handling removed.
// The architectural fix ensures daemon/bd writes ONLY to the worktree JSONL
// when sync-branch is configured, so main's JSONL never has uncommitted changes
// that would conflict with merge. See autoflush.go:findJSONLPath()
fmt.Printf("Merging sync branch '%s' into '%s'...\n", syncBranch, currentBranch)
if dryRun {
fmt.Println("→ [DRY RUN] Would merge sync branch")
// Show what would be merged
logCmd := rc.GitCmd(ctx, "log", "--oneline", currentBranch+".."+syncBranch)
logOutput, _ := logCmd.CombinedOutput()
if len(strings.TrimSpace(string(logOutput))) > 0 {
fmt.Println("\nCommits that would be merged:")
fmt.Print(string(logOutput))
} else {
fmt.Println("No commits to merge")
}
return nil
}
// Perform the merge
mergeCmd := rc.GitCmd(ctx, "merge", syncBranch, "-m", fmt.Sprintf("Merge sync branch '%s'", syncBranch))
mergeOutput, err := mergeCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("merge failed: %w\n%s", err, mergeOutput)
}
fmt.Print(string(mergeOutput))
fmt.Println("\n✓ Merge complete")
// Suggest next steps
fmt.Println("\nNext steps:")
fmt.Println("1. Review the merged changes")
fmt.Println("2. Run 'bd sync --import-only' to sync the database with merged JSONL")
fmt.Println("3. Run 'bd sync' to push changes to remote")
return nil
}
// isExternalBeadsDir checks if the beads directory is in a different git repo than cwd.
// This is used to detect when BEADS_DIR points to a separate repository.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
//
// GH#810: Use git-common-dir for comparison instead of repo root.
// For bare repo worktrees, GetRepoRoot returns incorrect values, causing
// local worktrees to be incorrectly identified as "external". Using
// git-common-dir correctly identifies that worktrees of the same repo
// share the same git directory.
func isExternalBeadsDir(ctx context.Context, beadsDir string) bool {
// Get git common dir of cwd
cwdCommonDir, err := getGitCommonDir(ctx, ".")
if err != nil {
return false // Can't determine, assume local
}
// Get git common dir of beads dir
beadsCommonDir, err := getGitCommonDir(ctx, beadsDir)
if err != nil {
return false // Can't determine, assume local
}
return cwdCommonDir != beadsCommonDir
}
// getGitCommonDir returns the shared git directory for a path.
// For regular repos, this is the .git directory.
// For worktrees, this returns the shared git directory (common to all worktrees).
// This is the correct way to determine if two paths are in the same git repo,
// especially for bare repos and worktrees.
// GH#810: Added to fix bare repo worktree detection.
func getGitCommonDir(ctx context.Context, path string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--git-common-dir")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git common dir for %s: %w", path, err)
}
result := strings.TrimSpace(string(output))
// Make absolute for reliable comparison
if !filepath.IsAbs(result) {
// Resolve the input path to absolute first
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
result = filepath.Join(absPath, result)
}
result = filepath.Clean(result)
// Resolve symlinks for consistent comparison (macOS /var -> /private/var)
if resolved, err := filepath.EvalSymlinks(result); err == nil {
result = resolved
}
return result, nil
}
// getRepoRootFromPath returns the git repository root for a given path.
// Unlike syncbranch.GetRepoRoot which uses cwd, this allows getting the repo root
// for any path.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func getRepoRootFromPath(ctx context.Context, path string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git root for %s: %w", path, err)
}
return strings.TrimSpace(string(output)), nil
}
// detectAndClearSkipWorktree and restoreSkipWorktree were removed in GH#1103.
// The architectural fix ensures daemon/bd writes ONLY to the worktree JSONL
// when sync-branch is configured, eliminating the need for skip-worktree manipulation.
// See autoflush.go:findJSONLPath() for the implementation.
// commitToExternalBeadsRepo commits changes directly to an external beads repo.
// Used when BEADS_DIR points to a different git repository than cwd.
// This bypasses the worktree-based sync which fails when beads dir is external.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func commitToExternalBeadsRepo(ctx context.Context, beadsDir, message string, push bool) (bool, error) {
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
if err != nil {
return false, fmt.Errorf("failed to get repo root: %w", err)
}
// Stage beads files (use relative path from repo root)
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
if err != nil {
relBeadsDir = beadsDir // Fallback to absolute path
}
// Use --sparse to work correctly with sparse-checkout enabled worktrees (fixes #1076)
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", "--sparse", relBeadsDir) //nolint:gosec // paths from trusted sources
if output, err := addCmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("git add failed: %w\n%s", err, output)
}
// Check if there are staged changes
diffCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "diff", "--cached", "--quiet") //nolint:gosec // repoRoot from git
if diffCmd.Run() == nil {
return false, nil // No changes to commit
}
// Commit with config-based author and signing options
// Use pathspec to commit ONLY beads files (bd-trgb fix)
// This prevents accidentally committing other staged files
if message == "" {
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
}
commitArgs := buildGitCommitArgs(repoRoot, message, "--", relBeadsDir)
commitCmd := exec.CommandContext(ctx, "git", commitArgs...) //nolint:gosec // args from buildGitCommitArgs
if output, err := commitCmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("git commit failed: %w\n%s", err, output)
}
// Push if requested
if push {
pushCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "push") //nolint:gosec // repoRoot from git
if pushOutput, err := runGitCmdWithTimeoutMsg(ctx, pushCmd, "git push", 5*time.Second); err != nil {
return true, fmt.Errorf("git push failed: %w\n%s", err, pushOutput)
}
}
return true, nil
}
// pullFromExternalBeadsRepo pulls changes in an external beads repo.
// Used when BEADS_DIR points to a different git repository than cwd.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error {
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
if err != nil {
return fmt.Errorf("failed to get repo root: %w", err)
}
// Check if remote exists
remoteCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "remote") //nolint:gosec // repoRoot from git
remoteOutput, err := remoteCmd.Output()
if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 {
return nil // No remote, skip pull
}
pullCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "pull") //nolint:gosec // repoRoot from git
if output, err := pullCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git pull failed: %w\n%s", err, output)
}
return nil
}