* feat(sync): implement pull-first synchronization strategy - Add --pull-first flag and logic to sync command - Introduce 3-way merge stub for issue synchronization - Add concurrent edit tests for the pull-first flow Ensures local changes are reconciled with remote updates before pushing to prevent data loss. * feat(sync): implement 3-way merge and state tracking - Implement 3-way merge algorithm for issue synchronization - Add base state storage to track changes between syncs - Add comprehensive tests for merge logic and persistence Ensures data consistency and prevents data loss during concurrent issue updates. * feat(sync): implement field-level conflict merging - Implement field-level merge logic for issue conflicts - Add unit tests for field-level merge strategies Reduces manual intervention by automatically resolving overlapping updates at the field level. * refactor(sync): simplify sync flow by removing ZFC checks The previous sync implementation relied on Zero-False-Convergence (ZFC) staleness checks which are redundant following the transition to structural 3-way merging. This legacy logic added complexity and maintenance overhead without providing additional safety. This commit introduces a streamlined sync pipeline: - Remove ZFC staleness validation from primary sync flow - Update safety documentation to reflect current merge strategy - Eliminate deprecated unit tests associated with ZFC logic These changes reduce codebase complexity while maintaining data integrity through the robust structural 3-way merge implementation. * feat(sync): default to pull-first sync workflow - Set pull-first as the primary synchronization workflow - Refactor core sync logic for better maintainability - Update concurrent edit tests to validate 3-way merge logic Reduces merge conflicts by ensuring local state is current before pushing changes. * refactor(sync): clean up lint issues in merge code - Remove unused error return from MergeIssues (never returned error) - Use _ prefix for unused _base parameter in mergeFieldLevel - Update callers to not expect error from MergeIssues - Keep nolint:gosec for trusted internal file path * test(sync): add mode compatibility and upgrade safety tests Add tests addressing Steve's PR #918 review concerns: - TestSyncBranchModeWithPullFirst: Verifies sync-branch config storage and git branch creation work with pull-first - TestExternalBeadsDirWithPullFirst: Verifies external BEADS_DIR detection and pullFromExternalBeadsRepo - TestUpgradeFromOldSync: Validates upgrade safety when sync_base.jsonl doesn't exist (first sync after upgrade) - TestMergeIssuesWithBaseState: Comprehensive 3-way merge cases - TestLabelUnionMerge: Verifies labels use union (no data loss) Key upgrade behavior validated: - base=nil (no sync_base.jsonl) safely handles all cases - Local-only issues kept (StrategyLocal) - Remote-only issues kept (StrategyRemote) - Overlapping issues merged (LWW scalars, union labels) * fix(sync): report line numbers for malformed JSON Problem: - JSON decoding errors when loading sync base state lacked line numbers - Difficult to identify location of syntax errors in large state files Solution: - Include line number reporting in JSON decoder errors during state loading - Add regression tests for malformed sync base file scenarios Impact: - Users receive actionable feedback for corrupted state files - Faster troubleshooting of manual configuration errors * fix(sync): warn on large clock skew during sync Problem: - Unsynchronized clocks between systems could lead to silent merge errors - No mechanism existed to alert users of significant timestamp drift Solution: - Implement clock skew detection during sync merge - Log a warning when large timestamp differences are found - Add comprehensive unit tests for skew reporting Impact: - Users are alerted to potential synchronization risks - Easier debugging of time-related merge issues * fix(sync): defer state update until remote push succeeds Problem: - Base state updated before confirming remote push completion - Failed pushes resulted in inconsistent local state tracking Solution: - Defer base state update until after the remote push succeeds Impact: - Ensures local state accurately reflects remote repository status - Prevents state desynchronization during network or push failures * fix(sync): prevent concurrent sync operations Problem: - Multiple sync processes could run simultaneously - Overlapping operations risk data corruption and race conditions Solution: - Implement file-based locking using gofrs/flock - Add integration tests to verify locking behavior Impact: - Guarantees execution of a single sync process at a time - Eliminates potential for data inconsistency during sync * docs: document sync architecture and merge model - Detail the 3-way merge model logic - Describe the core synchronization architecture principles * fix(lint): explicitly ignore lock.Unlock return value errcheck linter flagged bare defer lock.Unlock() calls. Wrap in anonymous function with explicit _ assignment to acknowledge intentional ignore of unlock errors during cleanup. * fix(lint): add sync_merge.go to G304 exclusions The loadBaseState and saveBaseState functions use file paths derived from trusted internal sources (beadsDir parameter from config). Add to existing G304 exclusion list for safe JSONL file operations. * feat(sync): integrate sync-branch into pull-first flow When sync.branch is configured, doPullFirstSync now: - Calls PullFromSyncBranch before merge - Calls CommitToSyncBranch after export This ensures sync-branch mode uses the correct branch for pull/push operations. * test(sync): add E2E tests for sync-branch and external BEADS_DIR Adds comprehensive end-to-end tests: - TestSyncBranchE2E: verifies pull→merge→commit flow with remote changes - TestExternalBeadsDirE2E: verifies sync with separate beads repository - TestExternalBeadsDirDetection: edge cases for repo detection - TestCommitToExternalBeadsRepo: commit handling * refactor(sync): remove unused rollbackJSONLFromGit Function was defined but never called. Pull-first flow saves base state after successful push, making this safety net unnecessary. * test(sync): add export-only mode E2E test Add TestExportOnlySync to cover --no-pull flag which was the only untested sync mode. This completes full mode coverage: - Normal (pull-first): sync_test.go, sync_merge_test.go - Sync-branch: sync_modes_test.go:TestSyncBranchE2E (PR#918) - External BEADS_DIR: sync_external_test.go (PR#918) - From-main: sync_branch_priority_test.go - Local-only: sync_local_only_test.go - Export-only: sync_modes_test.go:TestExportOnlySync (this commit) Refs: #911 * docs(sync): add sync modes reference section Document all 6 sync modes with triggers, flows, and use cases. Include mode selection decision tree and test coverage matrix. Co-authored-by: Claude <noreply@anthropic.com> * test(sync): upgrade sync-branch E2E tests to bare repo - Replace mocked repository with real bare repo setup - Implement multi-machine simulation in sync tests - Refactor test logic to handle distributed states Coverage: sync-branch end-to-end scenarios * test(sync): add daemon sync-branch E2E tests - Implement E2E tests for daemon sync-branch flow - Add test cases for force-overwrite scenarios Coverage: daemon sync-branch workflow in cmd/bd * docs(sync): document sync-branch paths and E2E architecture - Describe sync-branch CLI and Daemon execution flow - Document the end-to-end test architecture * build(nix): update vendorHash for gofrs/flock dependency New dependency added for file-based sync locking changes the Go module checksum. --------- Co-authored-by: Claude <noreply@anthropic.com>
562 lines
20 KiB
Go
562 lines
20 KiB
Go
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)) //nolint:gosec // G204: branch from git symbolic-ref
|
||
mergeCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", branch)) //nolint:gosec // G204: branch from git symbolic-ref
|
||
|
||
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
|
||
// This function is worktree-aware and handles bare repo worktree setups (GH#827).
|
||
// It also handles redirected beads directories (bd-arjb) by running git commands
|
||
// from the directory containing the actual .beads/.
|
||
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")
|
||
}
|
||
|
||
// Check if beads directory is redirected (bd-arjb)
|
||
// When redirected, beadsDir points outside the current repo, so we need to
|
||
// run git commands from the directory containing the actual .beads/
|
||
redirectInfo := beads.GetRedirectInfo()
|
||
if redirectInfo.IsRedirected {
|
||
// beadsDir is the target (e.g., /path/to/mayor/rig/.beads)
|
||
// We need to run git from the parent of .beads (e.g., /path/to/mayor/rig)
|
||
targetRepoDir := filepath.Dir(beadsDir)
|
||
statusCmd := exec.CommandContext(ctx, "git", "-C", targetRepoDir, "status", "--porcelain", beadsDir) //nolint:gosec // G204: beadsDir from beads.FindBeadsDir()
|
||
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 absolute path from current directory.
|
||
// This is more robust than using -C with a repo root, because:
|
||
// 1. In bare repo worktree setups, GetMainRepoRoot() returns the parent
|
||
// of the bare repo, which isn't a valid working tree (GH#827)
|
||
// 2. Git will find the repository from cwd, which is always valid
|
||
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir) //nolint:gosec // G204: beadsDir from beads.FindBeadsDir()
|
||
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) //nolint:gosec // G204: paths from internal git helpers
|
||
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...) //nolint:gosec // G204: args from buildGitCommitArgs
|
||
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...) //nolint:gosec // G204: paths from internal git helpers
|
||
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...) //nolint:gosec // G204: args from buildGitCommitArgs
|
||
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)
|
||
// If configuredRemote is non-empty, uses that instead of the branch's configured remote.
|
||
// This allows respecting the sync.remote bd config.
|
||
func gitPull(ctx context.Context, configuredRemote string) 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))
|
||
|
||
// Determine remote to use:
|
||
// 1. If configuredRemote (from sync.remote bd config) is set, use that
|
||
// 2. Otherwise, get from git branch tracking config
|
||
// 3. Fall back to "origin"
|
||
remote := configuredRemote
|
||
if remote == "" {
|
||
remoteCmd := exec.CommandContext(ctx, "git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) //nolint:gosec // G204: branch from git symbolic-ref
|
||
remoteOutput, err := remoteCmd.Output()
|
||
if err != nil {
|
||
// If no remote configured, default to "origin"
|
||
remote = "origin"
|
||
} else {
|
||
remote = strings.TrimSpace(string(remoteOutput))
|
||
}
|
||
}
|
||
|
||
// Pull with explicit remote and branch
|
||
cmd := exec.CommandContext(ctx, "git", "pull", remote, branch) //nolint:gosec // G204: remote/branch from git config, not user input
|
||
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)
|
||
// If configuredRemote is non-empty, pushes to that remote explicitly.
|
||
// This allows respecting the sync.remote bd config.
|
||
func gitPush(ctx context.Context, configuredRemote string) error {
|
||
// Check if any remote exists (support local-only repos)
|
||
if !hasGitRemote(ctx) {
|
||
return nil // Gracefully skip - local-only mode
|
||
}
|
||
|
||
// If configuredRemote is set, push explicitly to that remote with current branch
|
||
if configuredRemote != "" {
|
||
// Get current branch name
|
||
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))
|
||
|
||
cmd := exec.CommandContext(ctx, "git", "push", configuredRemote, branch) //nolint:gosec // G204: configuredRemote from bd config
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("git push failed: %w\n%s", err, output)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Default: use git's default push behavior
|
||
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
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
|
||
// 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) //nolint:gosec // G204: beadsDir from FindBeadsDir(), not user input
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("git checkout failed: %w\n%s", err, output)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// gitHasUncommittedBeadsChanges checks if .beads/issues.jsonl has uncommitted changes.
|
||
// This detects the failure mode where a previous sync exported but failed before commit.
|
||
// Returns true if the JSONL file has staged or unstaged changes (M or A status).
|
||
// GH#885: Pre-flight safety check to detect incomplete sync operations.
|
||
// Also handles redirected beads directories (bd-arjb).
|
||
func gitHasUncommittedBeadsChanges(ctx context.Context) (bool, error) {
|
||
beadsDir := beads.FindBeadsDir()
|
||
if beadsDir == "" {
|
||
return false, nil // No beads dir, nothing to check
|
||
}
|
||
|
||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||
|
||
// Check if beads directory is redirected (bd-arjb)
|
||
// When redirected, beadsDir points outside the current repo, so we need to
|
||
// run git commands from the directory containing the actual .beads/
|
||
redirectInfo := beads.GetRedirectInfo()
|
||
if redirectInfo.IsRedirected {
|
||
targetRepoDir := filepath.Dir(beadsDir)
|
||
cmd := exec.CommandContext(ctx, "git", "-C", targetRepoDir, "status", "--porcelain", jsonlPath) //nolint:gosec // G204: jsonlPath from internal beads.FindBeadsDir()
|
||
output, err := cmd.Output()
|
||
if err != nil {
|
||
return false, fmt.Errorf("git status failed: %w", err)
|
||
}
|
||
return parseGitStatusForBeadsChanges(string(output)), nil
|
||
}
|
||
|
||
// Check git status for the JSONL file specifically
|
||
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", jsonlPath) //nolint:gosec // G204: jsonlPath from internal beads.FindBeadsDir()
|
||
output, err := cmd.Output()
|
||
if err != nil {
|
||
return false, fmt.Errorf("git status failed: %w", err)
|
||
}
|
||
|
||
return parseGitStatusForBeadsChanges(string(output)), nil
|
||
}
|
||
|
||
// parseGitStatusForBeadsChanges parses git status --porcelain output and returns
|
||
// true if the status indicates uncommitted changes (modified or added).
|
||
// Format: XY filename where X=staged, Y=unstaged
|
||
// M = modified, A = added, ? = untracked, D = deleted
|
||
// Only M and A in either position indicate changes we care about.
|
||
func parseGitStatusForBeadsChanges(statusOutput string) bool {
|
||
statusLine := strings.TrimSpace(statusOutput)
|
||
if statusLine == "" {
|
||
return false // No changes
|
||
}
|
||
|
||
// Any status (M, A, MM, AM, etc.) indicates uncommitted changes
|
||
if len(statusLine) >= 2 {
|
||
x, y := statusLine[0], statusLine[1]
|
||
// Check for modifications (staged or unstaged)
|
||
if x == 'M' || x == 'A' || y == 'M' || y == 'A' {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// 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)) //nolint:gosec // G204: remote from git config
|
||
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 { //nolint:gosec // G204: remote from git config
|
||
return "main"
|
||
}
|
||
|
||
// Fallback: check if <remote>/master exists
|
||
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/master", remote)).Run() == nil { //nolint:gosec // G204: remote from git config
|
||
return "master"
|
||
}
|
||
|
||
// Default to main
|
||
return "main"
|
||
}
|