diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 9c5934a2..58a50dc3 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -304,6 +304,73 @@ Use --merge to merge the sync branch back to main branch.`, } } + // Check if BEADS_DIR points to an external repository (dand-oss fix) + // If so, use direct git operations instead of worktree-based sync + beadsDir := filepath.Dir(jsonlPath) + isExternal := isExternalBeadsDir(ctx, beadsDir) + + if isExternal { + // External BEADS_DIR: commit/pull directly to the beads repo + fmt.Println("→ External BEADS_DIR detected, using direct commit...") + + // Check for changes in the external beads repo + externalRepoRoot, err := getRepoRootFromPath(ctx, beadsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Check if there are changes to commit + relBeadsDir, _ := filepath.Rel(externalRepoRoot, beadsDir) + statusCmd := exec.CommandContext(ctx, "git", "-C", externalRepoRoot, "status", "--porcelain", relBeadsDir) + statusOutput, _ := statusCmd.Output() + externalHasChanges := len(strings.TrimSpace(string(statusOutput))) > 0 + + if externalHasChanges { + if dryRun { + fmt.Printf("→ [DRY RUN] Would commit changes to external beads repo at %s\n", externalRepoRoot) + } else { + committed, err := commitToExternalBeadsRepo(ctx, beadsDir, message, !noPush) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if committed { + if !noPush { + fmt.Println("✓ Committed and pushed to external beads repo") + } else { + fmt.Println("✓ Committed to external beads repo") + } + } + } + } else { + fmt.Println("→ No changes to commit in external beads repo") + } + + if !noPull { + if dryRun { + fmt.Printf("→ [DRY RUN] Would pull from external beads repo at %s\n", externalRepoRoot) + } else { + fmt.Println("→ Pulling from external beads repo...") + if err := pullFromExternalBeadsRepo(ctx, beadsDir); err != nil { + fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err) + os.Exit(1) + } + fmt.Println("✓ Pulled from external beads repo") + + // Re-import after pull to update local database + fmt.Println("→ Importing JSONL...") + if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { + fmt.Fprintf(os.Stderr, "Error importing: %v\n", err) + os.Exit(1) + } + } + } + + fmt.Println("\n✓ Sync complete") + return + } + // Check if sync.branch is configured for worktree-based sync (bd-e3w) // This allows committing to a separate branch without changing the user's working directory var syncBranchName string @@ -1731,3 +1798,106 @@ func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool { } return noGitHistory } + +// 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) + 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") + if diffCmd.Run() == nil { + return false, nil // No changes to commit + } + + // Commit + if message == "" { + message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05")) + } + commitCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "commit", "-m", message) + 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") + if output, err := pushCmd.CombinedOutput(); err != nil { + return true, fmt.Errorf("git push failed: %w\n%s", err, output) + } + } + + 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") + 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") + if output, err := pullCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git pull failed: %w\n%s", err, output) + } + + return nil +}