gitCommit() was adding the JSONL file but then committing ALL staged changes (no pathspec). If other files were staged (e.g., deletions from git add -A), they would be swept into the bd sync commit. Fixed by adding pathspec to both gitCommit() and commitToExternalBeadsRepo() so they only commit what they explicitly staged. This was the root cause of PR #722 files being deleted - they were staged for deletion in the working tree and got committed by bd sync. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
288 lines
9.7 KiB
Go
288 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
)
|
|
|
|
// getCurrentBranch returns the name of the current git branch
|
|
// Uses symbolic-ref instead of rev-parse to work in fresh repos without commits (bd-flil)
|
|
func getCurrentBranch(ctx context.Context) (string, error) {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
func showSyncStatus(ctx context.Context) error {
|
|
if !isGitRepo() {
|
|
return fmt.Errorf("not in a git repository")
|
|
}
|
|
|
|
currentBranch, err := getCurrentBranch(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
syncBranch, err := getSyncBranch(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if sync branch exists
|
|
checkCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) //nolint:gosec // syncBranch from config
|
|
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 := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch) //nolint:gosec // branch names from git
|
|
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 = exec.CommandContext(ctx, "git", "log", "--oneline", syncBranch+".."+currentBranch) //nolint:gosec // branch names from git
|
|
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 := exec.CommandContext(ctx, "git", "diff", currentBranch+"..."+syncBranch, "--", ".beads/issues.jsonl") //nolint:gosec // branch names from git
|
|
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
|
|
func mergeSyncBranch(ctx context.Context, dryRun bool) error {
|
|
if !isGitRepo() {
|
|
return fmt.Errorf("not in a git repository")
|
|
}
|
|
|
|
currentBranch, err := getCurrentBranch(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
syncBranch, err := getSyncBranch(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if sync branch exists
|
|
checkCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) //nolint:gosec // syncBranch from config
|
|
if err := checkCmd.Run(); err != nil {
|
|
return fmt.Errorf("sync branch '%s' does not exist", syncBranch)
|
|
}
|
|
|
|
// Check if there are uncommitted changes
|
|
statusCmd := exec.CommandContext(ctx, "git", "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")
|
|
}
|
|
|
|
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 := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch) //nolint:gosec // branch names from git
|
|
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 := exec.CommandContext(ctx, "git", "merge", syncBranch, "-m", fmt.Sprintf("Merge sync branch '%s'", syncBranch)) //nolint:gosec // syncBranch from config
|
|
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)
|
|
func isExternalBeadsDir(ctx context.Context, beadsDir string) bool {
|
|
// Get repo root of cwd
|
|
cwdRepoRoot, err := syncbranch.GetRepoRoot(ctx)
|
|
if err != nil {
|
|
return false // Can't determine, assume local
|
|
}
|
|
|
|
// Get repo root of beads dir
|
|
beadsRepoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
|
if err != nil {
|
|
return false // Can't determine, assume local
|
|
}
|
|
|
|
return cwdRepoRoot != beadsRepoRoot
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", 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
|
|
}
|