refactor: Split large cmd/bd files to meet 800-line limit (bd-xtf5)
Split 6 files exceeding 800 lines by extracting cohesive function groups: - show.go (1592→578): extracted show_thread.go, close.go, edit.go, update.go - doctor.go (1295→690): extracted doctor_fix.go, doctor_health.go, doctor_pollution.go - sync.go (1201→749): extracted sync_git.go - compact.go (1199→775): extracted compact_tombstone.go, compact_rpc.go - linear.go (1190→641): extracted linear_sync.go, linear_conflict.go - main.go (1148→800): extracted main_help.go, main_errors.go, main_daemon.go All files now under 800-line acceptance criteria. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
466
cmd/bd/sync_git.go
Normal file
466
cmd/bd/sync_git.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
// isGitRepo checks if the current directory is in a git repository
|
||||
func isGitRepo() bool {
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// gitHasUnmergedPaths checks for unmerged paths or merge in progress
|
||||
func gitHasUnmergedPaths() (bool, error) {
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for unmerged status codes (DD, AU, UD, UA, DU, AA, UU)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if len(line) >= 2 {
|
||||
s := line[:2]
|
||||
if s == "DD" || s == "AU" || s == "UD" || s == "UA" || s == "DU" || s == "AA" || s == "UU" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if MERGE_HEAD exists (merge in progress)
|
||||
if exec.Command("git", "rev-parse", "-q", "--verify", "MERGE_HEAD").Run() == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// gitHasUpstream checks if the current branch has an upstream configured
|
||||
// Uses git config directly for compatibility with Git for Windows
|
||||
func gitHasUpstream() bool {
|
||||
// Get current branch name
|
||||
branchCmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
|
||||
branchOutput, err := branchCmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
branch := strings.TrimSpace(string(branchOutput))
|
||||
|
||||
// Check if remote and merge refs are configured
|
||||
remoteCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
|
||||
mergeCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", branch))
|
||||
|
||||
remoteErr := remoteCmd.Run()
|
||||
mergeErr := mergeCmd.Run()
|
||||
|
||||
return remoteErr == nil && mergeErr == nil
|
||||
}
|
||||
|
||||
// gitHasChanges checks if the specified file has uncommitted changes
|
||||
func gitHasChanges(ctx context.Context, filePath string) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", filePath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
}
|
||||
|
||||
// getRepoRootForWorktree returns the main repository root for running git commands
|
||||
// This is always the main repository root, never the worktree root
|
||||
func getRepoRootForWorktree(_ context.Context) string {
|
||||
repoRoot, err := git.GetMainRepoRoot()
|
||||
if err != nil {
|
||||
// Fallback to current directory if GetMainRepoRoot fails
|
||||
return "."
|
||||
}
|
||||
return repoRoot
|
||||
}
|
||||
|
||||
// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes
|
||||
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
|
||||
// Get the absolute path to .beads directory
|
||||
beadsDir := beads.FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
return false, fmt.Errorf("no .beads directory found")
|
||||
}
|
||||
|
||||
// Get the repository root (handles worktrees properly)
|
||||
repoRoot := getRepoRootForWorktree(ctx)
|
||||
if repoRoot == "" {
|
||||
return false, fmt.Errorf("cannot determine repository root")
|
||||
}
|
||||
|
||||
// Compute relative path from repo root to .beads
|
||||
relPath, err := filepath.Rel(repoRoot, beadsDir)
|
||||
if err != nil {
|
||||
// Fall back to absolute path if relative path fails
|
||||
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir)
|
||||
statusOutput, err := statusCmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
|
||||
}
|
||||
|
||||
// Run git status with relative path from repo root
|
||||
statusCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "status", "--porcelain", relPath)
|
||||
statusOutput, err := statusCmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
|
||||
}
|
||||
|
||||
// buildGitCommitArgs returns git commit args with config-based author and signing options (GH#600)
|
||||
// This allows users to configure a separate author and disable GPG signing for beads commits.
|
||||
func buildGitCommitArgs(repoRoot, message string, extraArgs ...string) []string {
|
||||
args := []string{"-C", repoRoot, "commit"}
|
||||
|
||||
// Add --author if configured
|
||||
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")
|
||||
}
|
||||
|
||||
// Add message
|
||||
args = append(args, "-m", message)
|
||||
|
||||
// Add any extra args (like -- pathspec)
|
||||
args = append(args, extraArgs...)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// gitCommit commits the specified file (worktree-aware)
|
||||
func gitCommit(ctx context.Context, filePath string, message string) error {
|
||||
// Get the repository root (handles worktrees properly)
|
||||
repoRoot := getRepoRootForWorktree(ctx)
|
||||
if repoRoot == "" {
|
||||
return fmt.Errorf("cannot determine repository root")
|
||||
}
|
||||
|
||||
// Make file path relative to repo root for git operations
|
||||
relPath, err := filepath.Rel(repoRoot, filePath)
|
||||
if err != nil {
|
||||
relPath = filePath // Fall back to absolute path
|
||||
}
|
||||
|
||||
// Stage the file from repo root context
|
||||
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relPath)
|
||||
if err := addCmd.Run(); err != nil {
|
||||
return fmt.Errorf("git add failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate message if not provided
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Commit from repo root context with config-based author and signing options
|
||||
// Use pathspec to commit ONLY this file
|
||||
// This prevents accidentally committing other staged files
|
||||
commitArgs := buildGitCommitArgs(repoRoot, message, "--", relPath)
|
||||
commitCmd := exec.CommandContext(ctx, "git", commitArgs...)
|
||||
output, err := commitCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git commit failed: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitCommitBeadsDir stages and commits only sync-related files in .beads/
|
||||
// This ensures bd sync doesn't accidentally commit other staged files.
|
||||
// Only stages specific sync files (issues.jsonl, deletions.jsonl, metadata.json)
|
||||
// to avoid staging gitignored snapshot files that may be tracked.
|
||||
// Worktree-aware: handles cases where .beads is in the main repo but we're running from a worktree.
|
||||
func gitCommitBeadsDir(ctx context.Context, message string) error {
|
||||
beadsDir := beads.FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
return fmt.Errorf("no .beads directory found")
|
||||
}
|
||||
|
||||
// Get the repository root (handles worktrees properly)
|
||||
repoRoot := getRepoRootForWorktree(ctx)
|
||||
if repoRoot == "" {
|
||||
return fmt.Errorf("cannot determine repository root")
|
||||
}
|
||||
|
||||
// Stage only the specific sync-related files
|
||||
// This avoids staging gitignored snapshot files (beads.*.jsonl, *.meta.json)
|
||||
// that may still be tracked from before they were added to .gitignore
|
||||
syncFiles := []string{
|
||||
filepath.Join(beadsDir, "issues.jsonl"),
|
||||
filepath.Join(beadsDir, "deletions.jsonl"),
|
||||
filepath.Join(beadsDir, "interactions.jsonl"),
|
||||
filepath.Join(beadsDir, "metadata.json"),
|
||||
}
|
||||
|
||||
// Only add files that exist
|
||||
var filesToAdd []string
|
||||
for _, f := range syncFiles {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
// Convert to relative path from repo root for git operations
|
||||
relPath, err := filepath.Rel(repoRoot, f)
|
||||
if err != nil {
|
||||
relPath = f // Fall back to absolute path if relative fails
|
||||
}
|
||||
filesToAdd = append(filesToAdd, relPath)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filesToAdd) == 0 {
|
||||
return fmt.Errorf("no sync files found to commit")
|
||||
}
|
||||
|
||||
// Stage only the sync files from repo root context (worktree-aware)
|
||||
args := append([]string{"-C", repoRoot, "add"}, filesToAdd...)
|
||||
addCmd := exec.CommandContext(ctx, "git", args...)
|
||||
if err := addCmd.Run(); err != nil {
|
||||
return fmt.Errorf("git add failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate message if not provided
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Commit only .beads/ files using -- pathspec
|
||||
// This prevents accidentally committing other staged files that the user
|
||||
// may have staged but wasn't ready to commit yet.
|
||||
// Convert beadsDir to relative path for git commit (worktree-aware)
|
||||
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
|
||||
if err != nil {
|
||||
relBeadsDir = beadsDir // Fall back to absolute path if relative fails
|
||||
}
|
||||
|
||||
// Use config-based author and signing options with pathspec
|
||||
commitArgs := buildGitCommitArgs(repoRoot, message, "--", relBeadsDir)
|
||||
commitCmd := exec.CommandContext(ctx, "git", commitArgs...)
|
||||
output, err := commitCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git commit failed: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasGitRemote checks if a git remote exists in the repository
|
||||
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
|
||||
}
|
||||
|
||||
// isInRebase checks if we're currently in a git rebase state
|
||||
func isInRebase() bool {
|
||||
// Get actual git directory (handles worktrees)
|
||||
gitDir, err := git.GetGitDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for rebase-merge directory (interactive rebase)
|
||||
rebaseMergePath := filepath.Join(gitDir, "rebase-merge")
|
||||
if _, err := os.Stat(rebaseMergePath); err == nil {
|
||||
return true
|
||||
}
|
||||
// Check for rebase-apply directory (non-interactive rebase)
|
||||
rebaseApplyPath := filepath.Join(gitDir, "rebase-apply")
|
||||
if _, err := os.Stat(rebaseApplyPath); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasJSONLConflict checks if the beads JSONL file has a merge conflict
|
||||
// Returns true only if the JSONL file (issues.jsonl or beads.jsonl) is the only file in conflict
|
||||
func hasJSONLConflict() bool {
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var hasJSONLConflict bool
|
||||
var hasOtherConflict bool
|
||||
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if len(line) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for unmerged status codes (UU = both modified, AA = both added, etc.)
|
||||
status := line[:2]
|
||||
if status == "UU" || status == "AA" || status == "DD" ||
|
||||
status == "AU" || status == "UA" || status == "DU" || status == "UD" {
|
||||
filepath := strings.TrimSpace(line[3:])
|
||||
|
||||
// Check for beads JSONL files (issues.jsonl or beads.jsonl in .beads/)
|
||||
if strings.HasSuffix(filepath, "issues.jsonl") || strings.HasSuffix(filepath, "beads.jsonl") {
|
||||
hasJSONLConflict = true
|
||||
} else {
|
||||
hasOtherConflict = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return true if ONLY the JSONL file has a conflict
|
||||
return hasJSONLConflict && !hasOtherConflict
|
||||
}
|
||||
|
||||
// runGitRebaseContinue continues a rebase after resolving conflicts
|
||||
func runGitRebaseContinue(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "git", "rebase", "--continue")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git rebase --continue failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPull pulls from the current branch's upstream
|
||||
// Returns nil if no remote configured (local-only mode)
|
||||
func checkMergeDriverConfig() {
|
||||
// Get current merge driver configuration
|
||||
cmd := exec.Command("git", "config", "merge.beads.driver")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No merge driver configured - this is OK, user may not need it
|
||||
return
|
||||
}
|
||||
|
||||
currentConfig := strings.TrimSpace(string(output))
|
||||
|
||||
// Check if using old incorrect placeholders
|
||||
if strings.Contains(currentConfig, "%L") || strings.Contains(currentConfig, "%R") {
|
||||
fmt.Fprintf(os.Stderr, "\n⚠️ WARNING: Git merge driver is misconfigured!\n")
|
||||
fmt.Fprintf(os.Stderr, " Current: %s\n", currentConfig)
|
||||
fmt.Fprintf(os.Stderr, " Problem: Git only supports %%O (base), %%A (current), %%B (other)\n")
|
||||
fmt.Fprintf(os.Stderr, " Using %%L/%%R will cause merge failures!\n")
|
||||
fmt.Fprintf(os.Stderr, "\n Fix now: bd doctor --fix\n")
|
||||
fmt.Fprintf(os.Stderr, " Or manually: git config merge.beads.driver \"bd merge %%A %%O %%A %%B\"\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func gitPull(ctx context.Context) error {
|
||||
// Check if any remote exists (support local-only repos)
|
||||
if !hasGitRemote(ctx) {
|
||||
return nil // Gracefully skip - local-only mode
|
||||
}
|
||||
|
||||
// Get current branch name
|
||||
// Use symbolic-ref to work in fresh repos without commits
|
||||
branchCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
|
||||
branchOutput, err := branchCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current branch: %w", err)
|
||||
}
|
||||
branch := strings.TrimSpace(string(branchOutput))
|
||||
|
||||
// Get remote name for current branch (usually "origin")
|
||||
remoteCmd := exec.CommandContext(ctx, "git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
|
||||
remoteOutput, err := remoteCmd.Output()
|
||||
if err != nil {
|
||||
// If no remote configured, default to "origin"
|
||||
remoteOutput = []byte("origin\n")
|
||||
}
|
||||
remote := strings.TrimSpace(string(remoteOutput))
|
||||
|
||||
// Pull with explicit remote and branch
|
||||
cmd := exec.CommandContext(ctx, "git", "pull", remote, branch)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPush pushes to the current branch's upstream
|
||||
// Returns nil if no remote configured (local-only mode)
|
||||
func gitPush(ctx context.Context) error {
|
||||
// Check if any remote exists (support local-only repos)
|
||||
if !hasGitRemote(ctx) {
|
||||
return nil // Gracefully skip - local-only mode
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "push")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreBeadsDirFromBranch restores .beads/ directory from the current branch's committed state.
|
||||
// This is used after sync when sync.branch is configured to keep the working directory clean.
|
||||
// The actual beads data lives on the sync branch; the main branch's .beads/ is just a snapshot.
|
||||
func restoreBeadsDirFromBranch(ctx context.Context) error {
|
||||
beadsDir := beads.FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
return fmt.Errorf("no .beads directory found")
|
||||
}
|
||||
|
||||
// Restore .beads/ from HEAD (current branch's committed state)
|
||||
// Using -- to ensure .beads/ is treated as a path, not a branch name
|
||||
cmd := exec.CommandContext(ctx, "git", "checkout", "HEAD", "--", beadsDir)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git checkout failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDefaultBranch returns the default branch name (main or master) for origin remote
|
||||
// Checks remote HEAD first, then falls back to checking if main/master exist
|
||||
func getDefaultBranch(ctx context.Context) string {
|
||||
return getDefaultBranchForRemote(ctx, "origin")
|
||||
}
|
||||
|
||||
// getDefaultBranchForRemote returns the default branch name for a specific remote
|
||||
// Checks remote HEAD first, then falls back to checking if main/master exist
|
||||
func getDefaultBranchForRemote(ctx context.Context, remote string) string {
|
||||
// Try to get default branch from remote
|
||||
cmd := exec.CommandContext(ctx, "git", "symbolic-ref", fmt.Sprintf("refs/remotes/%s/HEAD", remote))
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
ref := strings.TrimSpace(string(output))
|
||||
// Extract branch name from refs/remotes/<remote>/main
|
||||
prefix := fmt.Sprintf("refs/remotes/%s/", remote)
|
||||
if strings.HasPrefix(ref, prefix) {
|
||||
return strings.TrimPrefix(ref, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check if <remote>/main exists
|
||||
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/main", remote)).Run() == nil {
|
||||
return "main"
|
||||
}
|
||||
|
||||
// Fallback: check if <remote>/master exists
|
||||
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/master", remote)).Run() == nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
// Default to main
|
||||
return "main"
|
||||
}
|
||||
Reference in New Issue
Block a user