When sync.branch is configured, bd sync now commits beads changes to that branch via git worktree, keeping the user's current branch (e.g., main) clean of beads sync commits. Changes: - Add internal/syncbranch/worktree.go with CommitToSyncBranch and PullFromSyncBranch functions for worktree-based operations - Modify sync.go to check sync.branch config and use worktree functions when configured - Skip pre-commit hooks in worktree commits (--no-verify) since bd's pre-commit hook would fail in worktree context - Re-export after import also uses worktree when sync.branch set This enables the orchestrator workflow where multiple workers stay on main but all beads commits flow to a dedicated beads-sync branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
10 KiB
Go
295 lines
10 KiB
Go
package syncbranch
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/git"
|
|
)
|
|
|
|
// CommitResult contains information about a worktree commit operation
|
|
type CommitResult struct {
|
|
Committed bool // True if changes were committed
|
|
Pushed bool // True if changes were pushed
|
|
Branch string // The sync branch name
|
|
Message string // Commit message used
|
|
}
|
|
|
|
// PullResult contains information about a worktree pull operation
|
|
type PullResult struct {
|
|
Pulled bool // True if pull was performed
|
|
Branch string // The sync branch name
|
|
JSONLPath string // Path to the synced JSONL in main repo
|
|
}
|
|
|
|
// CommitToSyncBranch commits JSONL changes to the sync branch using a git worktree.
|
|
// This allows committing to a different branch without changing the user's working directory.
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for cancellation
|
|
// - repoRoot: Path to the git repository root
|
|
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
|
|
// - jsonlPath: Absolute path to the JSONL file in the main repo
|
|
// - push: If true, push to remote after commit
|
|
//
|
|
// Returns CommitResult with details about what was done, or error if failed.
|
|
func CommitToSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath string, push bool) (*CommitResult, error) {
|
|
result := &CommitResult{
|
|
Branch: syncBranch,
|
|
}
|
|
|
|
// Worktree path is under .git/beads-worktrees/<branch>
|
|
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
|
|
|
|
// Initialize worktree manager
|
|
wtMgr := git.NewWorktreeManager(repoRoot)
|
|
|
|
// Ensure worktree exists
|
|
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
|
return nil, fmt.Errorf("failed to create worktree: %w", err)
|
|
}
|
|
|
|
// Check worktree health and repair if needed
|
|
if err := wtMgr.CheckWorktreeHealth(worktreePath); err != nil {
|
|
// Try to recreate worktree
|
|
if err := wtMgr.RemoveBeadsWorktree(worktreePath); err != nil {
|
|
// Log but continue - removal might fail but recreation might work
|
|
_ = err
|
|
}
|
|
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
|
return nil, fmt.Errorf("failed to recreate worktree after health check: %w", err)
|
|
}
|
|
}
|
|
|
|
// Convert absolute path to relative path from repo root
|
|
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get relative JSONL path: %w", err)
|
|
}
|
|
|
|
// Sync JSONL file to worktree
|
|
if err := wtMgr.SyncJSONLToWorktree(worktreePath, jsonlRelPath); err != nil {
|
|
return nil, fmt.Errorf("failed to sync JSONL to worktree: %w", err)
|
|
}
|
|
|
|
// Also sync other beads files (deletions.jsonl, metadata.json)
|
|
beadsDir := filepath.Dir(jsonlPath)
|
|
for _, filename := range []string{"deletions.jsonl", "metadata.json"} {
|
|
srcPath := filepath.Join(beadsDir, filename)
|
|
if _, err := os.Stat(srcPath); err == nil {
|
|
relPath, err := filepath.Rel(repoRoot, srcPath)
|
|
if err == nil {
|
|
_ = wtMgr.SyncJSONLToWorktree(worktreePath, relPath) // Best effort
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for changes in worktree
|
|
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
|
|
hasChanges, err := hasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check for changes in worktree: %w", err)
|
|
}
|
|
|
|
if !hasChanges {
|
|
return result, nil // No changes to commit
|
|
}
|
|
|
|
// Commit in worktree
|
|
result.Message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
|
if err := commitInWorktree(ctx, worktreePath, jsonlRelPath, result.Message); err != nil {
|
|
return nil, fmt.Errorf("failed to commit in worktree: %w", err)
|
|
}
|
|
result.Committed = true
|
|
|
|
// Push if enabled
|
|
if push {
|
|
if err := pushFromWorktree(ctx, worktreePath, syncBranch); err != nil {
|
|
return nil, fmt.Errorf("failed to push from worktree: %w", err)
|
|
}
|
|
result.Pushed = true
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// PullFromSyncBranch pulls changes from the sync branch and copies JSONL to the main repo.
|
|
// This fetches remote changes without affecting the user's working directory.
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for cancellation
|
|
// - repoRoot: Path to the git repository root
|
|
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
|
|
// - jsonlPath: Absolute path to the JSONL file in the main repo
|
|
//
|
|
// Returns PullResult with details about what was done, or error if failed.
|
|
func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath string) (*PullResult, error) {
|
|
result := &PullResult{
|
|
Branch: syncBranch,
|
|
JSONLPath: jsonlPath,
|
|
}
|
|
|
|
// Worktree path is under .git/beads-worktrees/<branch>
|
|
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
|
|
|
|
// Initialize worktree manager
|
|
wtMgr := git.NewWorktreeManager(repoRoot)
|
|
|
|
// Ensure worktree exists
|
|
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
|
|
return nil, fmt.Errorf("failed to create worktree: %w", err)
|
|
}
|
|
|
|
// Get remote name
|
|
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
|
|
|
|
// Pull in worktree
|
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "pull", remote, syncBranch)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Check if it's just "already up to date" or similar non-error
|
|
if strings.Contains(string(output), "Already up to date") {
|
|
result.Pulled = true
|
|
// Still copy JSONL in case worktree has changes we haven't synced
|
|
} else {
|
|
return nil, fmt.Errorf("git pull failed in worktree: %w\n%s", err, output)
|
|
}
|
|
} else {
|
|
result.Pulled = true
|
|
}
|
|
|
|
// Convert absolute path to relative path from repo root
|
|
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get relative JSONL path: %w", err)
|
|
}
|
|
|
|
// Copy JSONL from worktree to main repo
|
|
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
|
|
|
|
// Check if worktree JSONL exists
|
|
if _, err := os.Stat(worktreeJSONLPath); os.IsNotExist(err) {
|
|
// No JSONL in worktree yet, nothing to sync
|
|
return result, nil
|
|
}
|
|
|
|
// Copy JSONL from worktree to main repo
|
|
data, err := os.ReadFile(worktreeJSONLPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read worktree JSONL: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(jsonlPath, data, 0644); err != nil {
|
|
return nil, fmt.Errorf("failed to write main JSONL: %w", err)
|
|
}
|
|
|
|
// Also sync other beads files back (deletions.jsonl, metadata.json)
|
|
beadsDir := filepath.Dir(jsonlPath)
|
|
for _, filename := range []string{"deletions.jsonl", "metadata.json"} {
|
|
worktreeSrcPath := filepath.Join(worktreePath, ".beads", filename)
|
|
if data, err := os.ReadFile(worktreeSrcPath); err == nil {
|
|
dstPath := filepath.Join(beadsDir, filename)
|
|
_ = os.WriteFile(dstPath, data, 0644) // Best effort
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// hasChangesInWorktree checks if there are uncommitted changes in the worktree
|
|
func hasChangesInWorktree(ctx context.Context, worktreePath, filePath string) (bool, error) {
|
|
// Check the entire .beads directory for changes
|
|
beadsDir := filepath.Dir(filePath)
|
|
relBeadsDir, err := filepath.Rel(worktreePath, beadsDir)
|
|
if err != nil {
|
|
// Fallback to checking just the file
|
|
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)
|
|
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
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain", relBeadsDir)
|
|
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
|
|
}
|
|
|
|
// commitInWorktree stages and commits changes in the worktree
|
|
func commitInWorktree(ctx context.Context, worktreePath, jsonlRelPath, message string) error {
|
|
// Stage the entire .beads directory
|
|
beadsRelDir := filepath.Dir(jsonlRelPath)
|
|
|
|
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", beadsRelDir)
|
|
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
|
|
}
|
|
|
|
// pushFromWorktree pushes the sync branch from the worktree
|
|
func pushFromWorktree(ctx context.Context, worktreePath, branch string) error {
|
|
remote := getRemoteForBranch(ctx, worktreePath, branch)
|
|
|
|
// Push with explicit remote and branch, set upstream if not set
|
|
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("git push failed from worktree: %w\n%s", err, output)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getRemoteForBranch gets the remote name for a branch, defaulting to "origin"
|
|
func getRemoteForBranch(ctx context.Context, worktreePath, branch string) string {
|
|
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
|
|
remoteOutput, err := remoteCmd.Output()
|
|
if err != nil {
|
|
return "origin" // Default
|
|
}
|
|
return strings.TrimSpace(string(remoteOutput))
|
|
}
|
|
|
|
// GetRepoRoot returns the git repository root directory
|
|
func GetRepoRoot(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
|
|
}
|
|
|
|
// HasGitRemote checks if any git remote exists
|
|
func HasGitRemote(ctx context.Context) bool {
|
|
cmd := exec.CommandContext(ctx, "git", "remote")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return len(strings.TrimSpace(string(output))) > 0
|
|
}
|