Merge bd-cddj-nux: GH#519

This commit is contained in:
Steve Yegge
2025-12-16 01:17:25 -08:00
5 changed files with 1434 additions and 1141 deletions

File diff suppressed because one or more lines are too long

View File

@@ -36,6 +36,7 @@ Common operations:
bd daemon --start Start the daemon (background) bd daemon --start Start the daemon (background)
bd daemon --start --foreground Start in foreground (for systemd/supervisord) bd daemon --start --foreground Start in foreground (for systemd/supervisord)
bd daemon --stop Stop a running daemon bd daemon --stop Stop a running daemon
bd daemon --stop-all Stop ALL running bd daemons
bd daemon --status Check if daemon is running bd daemon --status Check if daemon is running
bd daemon --health Check daemon health and metrics bd daemon --health Check daemon health and metrics
@@ -43,6 +44,7 @@ Run 'bd daemon' with no flags to see available options.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
start, _ := cmd.Flags().GetBool("start") start, _ := cmd.Flags().GetBool("start")
stop, _ := cmd.Flags().GetBool("stop") stop, _ := cmd.Flags().GetBool("stop")
stopAll, _ := cmd.Flags().GetBool("stop-all")
status, _ := cmd.Flags().GetBool("status") status, _ := cmd.Flags().GetBool("status")
health, _ := cmd.Flags().GetBool("health") health, _ := cmd.Flags().GetBool("health")
metrics, _ := cmd.Flags().GetBool("metrics") metrics, _ := cmd.Flags().GetBool("metrics")
@@ -54,7 +56,7 @@ Run 'bd daemon' with no flags to see available options.`,
foreground, _ := cmd.Flags().GetBool("foreground") foreground, _ := cmd.Flags().GetBool("foreground")
// If no operation flags provided, show help // If no operation flags provided, show help
if !start && !stop && !status && !health && !metrics { if !start && !stop && !stopAll && !status && !health && !metrics {
_ = cmd.Help() _ = cmd.Help()
return return
} }
@@ -119,6 +121,11 @@ Run 'bd daemon' with no flags to see available options.`,
return return
} }
if stopAll {
stopAllDaemons()
return
}
// If we get here and --start wasn't provided, something is wrong // If we get here and --start wasn't provided, something is wrong
// (should have been caught by help check above) // (should have been caught by help check above)
if !start { if !start {
@@ -222,6 +229,7 @@ func init() {
daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits") daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits")
daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)") daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)")
daemonCmd.Flags().Bool("stop", false, "Stop running daemon") daemonCmd.Flags().Bool("stop", false, "Stop running daemon")
daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons")
daemonCmd.Flags().Bool("status", false, "Show daemon status") daemonCmd.Flags().Bool("status", false, "Show daemon status")
daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics") daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics")
daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics") daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics")

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
) )
@@ -304,6 +305,59 @@ func stopDaemon(pidFile string) {
fmt.Println("Daemon killed") fmt.Println("Daemon killed")
} }
// stopAllDaemons stops all running bd daemons (bd-47tn)
func stopAllDaemons() {
// Discover all running daemons using the registry
daemons, err := daemon.DiscoverDaemons(nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1)
}
// Filter to only alive daemons
var alive []daemon.DaemonInfo
for _, d := range daemons {
if d.Alive {
alive = append(alive, d)
}
}
if len(alive) == 0 {
if jsonOutput {
fmt.Println(`{"stopped": 0, "message": "No running daemons found"}`)
} else {
fmt.Println("No running daemons found")
}
return
}
if !jsonOutput {
fmt.Printf("Found %d running daemon(s), stopping...\n", len(alive))
}
// Stop all daemons (with force=true for stubborn processes)
results := daemon.KillAllDaemons(alive, true)
if jsonOutput {
output, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(output))
} else {
if results.Stopped > 0 {
fmt.Printf("✓ Stopped %d daemon(s)\n", results.Stopped)
}
if results.Failed > 0 {
fmt.Printf("✗ Failed to stop %d daemon(s):\n", results.Failed)
for _, f := range results.Failures {
fmt.Printf(" - PID %d (%s): %s\n", f.PID, f.Workspace, f.Error)
}
}
}
if results.Failed > 0 {
os.Exit(1)
}
}
// startDaemon starts the daemon (in foreground if requested, otherwise background) // startDaemon starts the daemon (in foreground if requested, otherwise background)
func startDaemon(interval time.Duration, autoCommit, autoPush, localMode, foreground bool, logFile, pidFile string) { func startDaemon(interval time.Duration, autoCommit, autoPush, localMode, foreground bool, logFile, pidFile string) {
logPath, err := getLogFilePath(logFile) logPath, err := getLogFilePath(logFile)

View File

@@ -659,6 +659,58 @@ func checkHooksQuick() string {
return fmt.Sprintf("Git hooks outdated: %s (%s → %s)", strings.Join(outdatedHooks, ", "), oldestVersion, Version) return fmt.Sprintf("Git hooks outdated: %s (%s → %s)", strings.Join(outdatedHooks, ", "), oldestVersion, Version)
} }
// getPrePushHookPath resolves the pre-push hook path for a git repository.
// Handles both standard .git/hooks and shared hooks via core.hooksPath.
// Returns (hookPath, error) where error is set if not a git repo.
// (bd-e0o7: extracted common helper)
func getPrePushHookPath(path string) (string, error) {
// Get git directory (handles worktrees where .git is a file)
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("not a git repository")
}
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// Check for shared hooks via core.hooksPath first
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
sharedHooksDir := strings.TrimSpace(string(hooksPathOutput))
if !filepath.IsAbs(sharedHooksDir) {
sharedHooksDir = filepath.Join(path, sharedHooksDir)
}
return filepath.Join(sharedHooksDir, "pre-push"), nil
}
// Use standard .git/hooks location
return filepath.Join(gitDir, "hooks", "pre-push"), nil
}
// extractBdHookVersion extracts the version from a bd hook's content.
// Returns empty string if not a bd hook or version cannot be determined.
// (bd-e0o7: extracted common helper)
func extractBdHookVersion(content string) string {
if !strings.Contains(content, "bd-hooks-version:") {
return "" // Not a bd hook
}
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, "bd-hooks-version:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
break
}
}
return ""
}
// checkSyncBranchHookQuick does a fast check for sync-branch hook compatibility (issue #532). // checkSyncBranchHookQuick does a fast check for sync-branch hook compatibility (issue #532).
// Returns empty string if OK, otherwise returns issue description. // Returns empty string if OK, otherwise returns issue description.
func checkSyncBranchHookQuick(path string) string { func checkSyncBranchHookQuick(path string) string {
@@ -668,56 +720,19 @@ func checkSyncBranchHookQuick(path string) string {
return "" // sync-branch not configured, nothing to check return "" // sync-branch not configured, nothing to check
} }
// Get git directory hookPath, err := getPrePushHookPath(path)
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
output, err := cmd.Output()
if err != nil { if err != nil {
return "" // Not a git repo, skip return "" // Not a git repo, skip
} }
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// Find pre-push hook (check shared hooks first)
var hookPath string
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
sharedHooksDir := strings.TrimSpace(string(hooksPathOutput))
if !filepath.IsAbs(sharedHooksDir) {
sharedHooksDir = filepath.Join(path, sharedHooksDir)
}
hookPath = filepath.Join(sharedHooksDir, "pre-push")
} else {
hookPath = filepath.Join(gitDir, "hooks", "pre-push")
}
content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
if err != nil { if err != nil {
return "" // No pre-push hook, covered by other checks return "" // No pre-push hook, covered by other checks
} }
// Check if bd hook and extract version hookVersion := extractBdHookVersion(string(content))
hookStr := string(content)
if !strings.Contains(hookStr, "bd-hooks-version:") {
return "" // Not a bd hook, can't check
}
var hookVersion string
for _, line := range strings.Split(hookStr, "\n") {
if strings.Contains(line, "bd-hooks-version:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
hookVersion = strings.TrimSpace(parts[1])
}
break
}
}
if hookVersion == "" { if hookVersion == "" {
return "" // Can't determine version return "" // Not a bd hook or can't determine version
} }
// Check if version < minSyncBranchHookVersion (when sync-branch bypass was added) // Check if version < minSyncBranchHookVersion (when sync-branch bypass was added)
@@ -2072,6 +2087,7 @@ func checkGitHooks() doctorCheck {
// checkSyncBranchHookCompatibility checks if pre-push hook is compatible with sync-branch mode. // checkSyncBranchHookCompatibility checks if pre-push hook is compatible with sync-branch mode.
// When sync-branch is configured, the pre-push hook must have the sync-branch bypass logic // When sync-branch is configured, the pre-push hook must have the sync-branch bypass logic
// (added in version 0.29.0). Without it, users experience circular "bd sync" failures (issue #532). // (added in version 0.29.0). Without it, users experience circular "bd sync" failures (issue #532).
// (bd-e0o7: refactored to use extracted helpers)
func checkSyncBranchHookCompatibility(path string) doctorCheck { func checkSyncBranchHookCompatibility(path string) doctorCheck {
// Check if sync-branch is configured // Check if sync-branch is configured
syncBranch := syncbranch.GetFromYAML() syncBranch := syncbranch.GetFromYAML()
@@ -2083,11 +2099,8 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
} }
} }
// sync-branch is configured - check pre-push hook version // Get pre-push hook path using common helper
// Get actual git directory (handles worktrees where .git is a file) hookPath, err := getPrePushHookPath(path)
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
output, err := cmd.Output()
if err != nil { if err != nil {
return doctorCheck{ return doctorCheck{
Name: "Sync Branch Hook Compatibility", Name: "Sync Branch Hook Compatibility",
@@ -2095,27 +2108,6 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
Message: "N/A (not a git repository)", Message: "N/A (not a git repository)",
} }
} }
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// Check for pre-push hook in standard location or shared hooks location
var hookPath string
// First check if core.hooksPath is configured (shared hooks)
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
sharedHooksDir := strings.TrimSpace(string(hooksPathOutput))
if !filepath.IsAbs(sharedHooksDir) {
sharedHooksDir = filepath.Join(path, sharedHooksDir)
}
hookPath = filepath.Join(sharedHooksDir, "pre-push")
} else {
// Use standard .git/hooks location
hookPath = filepath.Join(gitDir, "hooks", "pre-push")
}
hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
if err != nil { if err != nil {
@@ -2127,10 +2119,13 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
} }
} }
// Check if this is a bd hook and extract version // Extract version using common helper
hookStr := string(hookContent) hookVersion := extractBdHookVersion(string(hookContent))
if !strings.Contains(hookStr, "bd-hooks-version:") {
// Not a bd hook - can't determine compatibility // Not a bd hook - this is intentionally a Warning (vs OK in quick check)
// because the full doctor check wants to alert users to potential issues
// with custom hooks that may not be sync-branch compatible
if !strings.Contains(string(hookContent), "bd-hooks-version:") {
return doctorCheck{ return doctorCheck{
Name: "Sync Branch Hook Compatibility", Name: "Sync Branch Hook Compatibility",
Status: statusWarning, Status: statusWarning,
@@ -2139,18 +2134,6 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
} }
} }
// Extract version from hook
var hookVersion string
for _, line := range strings.Split(hookStr, "\n") {
if strings.Contains(line, "bd-hooks-version:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
hookVersion = strings.TrimSpace(parts[1])
}
break
}
}
if hookVersion == "" { if hookVersion == "" {
return doctorCheck{ return doctorCheck{
Name: "Sync Branch Hook Compatibility", Name: "Sync Branch Hook Compatibility",
@@ -2566,16 +2549,14 @@ func checkSyncBranchConfig(path string) doctorCheck {
currentBranch = strings.TrimSpace(string(output)) currentBranch = strings.TrimSpace(string(output))
} }
// CRITICAL: Check if we're on the sync branch - this is a misconfiguration // GH#519: Check if we're on the sync branch - this is supported but worth noting
// that will cause bd sync to fail trying to create a worktree for a branch // bd sync will commit directly instead of using worktree when on sync branch
// that's already checked out
if syncBranch != "" && currentBranch == syncBranch { if syncBranch != "" && currentBranch == syncBranch {
return doctorCheck{ return doctorCheck{
Name: "Sync Branch Config", Name: "Sync Branch Config",
Status: statusError, Status: statusOK,
Message: fmt.Sprintf("On sync branch '%s'", syncBranch), Message: fmt.Sprintf("On sync branch '%s' (direct mode)", syncBranch),
Detail: fmt.Sprintf("Currently on branch '%s' which is configured as the sync branch. bd sync cannot create a worktree for a branch that's already checked out.", syncBranch), Detail: fmt.Sprintf("Currently on sync branch '%s'. bd sync will commit directly instead of using worktree.", syncBranch),
Fix: "Switch to your main working branch: git checkout main",
} }
} }

View File

@@ -58,9 +58,7 @@ Use --merge to merge the sync branch back to main branch.`,
fromMain, _ := cmd.Flags().GetBool("from-main") fromMain, _ := cmd.Flags().GetBool("from-main")
noGitHistory, _ := cmd.Flags().GetBool("no-git-history") noGitHistory, _ := cmd.Flags().GetBool("no-git-history")
squash, _ := cmd.Flags().GetBool("squash") squash, _ := cmd.Flags().GetBool("squash")
// Recovery options (bd-vckm) checkIntegrity, _ := cmd.Flags().GetBool("check")
resetRemote, _ := cmd.Flags().GetBool("reset-remote")
forcePush, _ := cmd.Flags().GetBool("force-push")
// bd-sync-corruption fix: Force direct mode for sync operations. // bd-sync-corruption fix: Force direct mode for sync operations.
// This prevents stale daemon SQLite connections from corrupting exports. // This prevents stale daemon SQLite connections from corrupting exports.
@@ -93,6 +91,15 @@ Use --merge to merge the sync branch back to main branch.`,
return return
} }
// If check mode, run pre-sync integrity checks (bd-hlsw.1)
if checkIntegrity {
if err := showSyncIntegrityCheck(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// If merge mode, merge sync branch to main // If merge mode, merge sync branch to main
if merge { if merge {
if err := mergeSyncBranch(ctx, dryRun); err != nil { if err := mergeSyncBranch(ctx, dryRun); err != nil {
@@ -111,15 +118,6 @@ Use --merge to merge the sync branch back to main branch.`,
return return
} }
// Handle recovery options (bd-vckm)
if resetRemote || forcePush {
if err := handleSyncRecovery(ctx, jsonlPath, resetRemote, forcePush, renameOnImport, noGitHistory, dryRun); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// If import-only mode, just import and exit // If import-only mode, just import and exit
if importOnly { if importOnly {
if dryRun { if dryRun {
@@ -389,20 +387,22 @@ Use --merge to merge the sync branch back to main branch.`,
var syncBranchName string var syncBranchName string
var repoRoot string var repoRoot string
var useSyncBranch bool var useSyncBranch bool
var onSyncBranch bool // GH#519: track if we're on the sync branch
if err := ensureStoreActive(); err == nil && store != nil { if err := ensureStoreActive(); err == nil && store != nil {
syncBranchName, _ = syncbranch.Get(ctx, store) syncBranchName, _ = syncbranch.Get(ctx, store)
if syncBranchName != "" && syncbranch.HasGitRemote(ctx) { if syncBranchName != "" && syncbranch.HasGitRemote(ctx) {
// GH#519: Check if sync.branch equals current branch repoRoot, err = syncbranch.GetRepoRoot(ctx)
// If so, we can't use a worktree (git doesn't allow same branch in multiple worktrees) if err != nil {
// Fall back to direct commits on the current branch fmt.Fprintf(os.Stderr, "Warning: sync.branch configured but failed to get repo root: %v\n", err)
if syncbranch.IsSyncBranchSameAsCurrent(ctx, syncBranchName) { fmt.Fprintf(os.Stderr, "Falling back to current branch commits\n")
// sync.branch == current branch - use regular commits, not worktree
useSyncBranch = false
} else { } else {
repoRoot, err = syncbranch.GetRepoRoot(ctx) // GH#519: Check if current branch is the sync branch
if err != nil { // If so, commit directly instead of using worktree (which would fail)
fmt.Fprintf(os.Stderr, "Warning: sync.branch configured but failed to get repo root: %v\n", err) currentBranch, _ := getCurrentBranch(ctx)
fmt.Fprintf(os.Stderr, "Falling back to current branch commits\n") if currentBranch == syncBranchName {
onSyncBranch = true
// Don't use worktree - commit directly to current branch
useSyncBranch = false
} else { } else {
useSyncBranch = true useSyncBranch = true
} }
@@ -424,6 +424,9 @@ Use --merge to merge the sync branch back to main branch.`,
if dryRun { if dryRun {
if useSyncBranch { if useSyncBranch {
fmt.Printf("→ [DRY RUN] Would commit changes to sync branch '%s' via worktree\n", syncBranchName) fmt.Printf("→ [DRY RUN] Would commit changes to sync branch '%s' via worktree\n", syncBranchName)
} else if onSyncBranch {
// GH#519: on sync branch, commit directly
fmt.Printf("→ [DRY RUN] Would commit changes directly to sync branch '%s'\n", syncBranchName)
} else { } else {
fmt.Println("→ [DRY RUN] Would commit changes to git") fmt.Println("→ [DRY RUN] Would commit changes to git")
} }
@@ -444,7 +447,12 @@ Use --merge to merge the sync branch back to main branch.`,
} }
} else { } else {
// Regular commit to current branch // Regular commit to current branch
fmt.Println("→ Committing changes to git...") // GH#519: if on sync branch, show appropriate message
if onSyncBranch {
fmt.Printf("→ Committing changes directly to sync branch '%s'...\n", syncBranchName)
} else {
fmt.Println("→ Committing changes to git...")
}
if err := gitCommitBeadsDir(ctx, message); err != nil { if err := gitCommitBeadsDir(ctx, message); err != nil {
fmt.Fprintf(os.Stderr, "Error committing: %v\n", err) fmt.Fprintf(os.Stderr, "Error committing: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -460,6 +468,9 @@ Use --merge to merge the sync branch back to main branch.`,
if dryRun { if dryRun {
if useSyncBranch { if useSyncBranch {
fmt.Printf("→ [DRY RUN] Would pull from sync branch '%s' via worktree\n", syncBranchName) fmt.Printf("→ [DRY RUN] Would pull from sync branch '%s' via worktree\n", syncBranchName)
} else if onSyncBranch {
// GH#519: on sync branch, regular git pull
fmt.Printf("→ [DRY RUN] Would pull directly on sync branch '%s'\n", syncBranchName)
} else { } else {
fmt.Println("→ [DRY RUN] Would pull from remote") fmt.Println("→ [DRY RUN] Would pull from remote")
} }
@@ -526,7 +537,12 @@ Use --merge to merge the sync branch back to main branch.`,
// Check merge driver configuration before pulling // Check merge driver configuration before pulling
checkMergeDriverConfig() checkMergeDriverConfig()
fmt.Println("→ Pulling from remote...") // GH#519: show appropriate message when on sync branch
if onSyncBranch {
fmt.Printf("→ Pulling from remote on sync branch '%s'...\n", syncBranchName)
} else {
fmt.Println("→ Pulling from remote...")
}
err := gitPull(ctx) err := gitPull(ctx)
if err != nil { if err != nil {
// Check if it's a rebase conflict on beads.jsonl that we can auto-resolve // Check if it's a rebase conflict on beads.jsonl that we can auto-resolve
@@ -788,9 +804,7 @@ func init() {
syncCmd.Flags().Bool("from-main", false, "One-way sync from main branch (for ephemeral branches without upstream)") syncCmd.Flags().Bool("from-main", false, "One-way sync from main branch (for ephemeral branches without upstream)")
syncCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (use during JSONL filename migrations)") syncCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (use during JSONL filename migrations)")
syncCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output sync statistics in JSON format") syncCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output sync statistics in JSON format")
// Recovery options for diverged sync branches (bd-vckm) syncCmd.Flags().Bool("check", false, "Pre-sync integrity check: detect forced pushes, prefix mismatches, and orphaned issues")
syncCmd.Flags().Bool("reset-remote", false, "Reset local sync branch to remote state (discards local sync changes)")
syncCmd.Flags().Bool("force-push", false, "Force push local sync branch to remote (overwrites remote)")
rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(syncCmd)
} }
@@ -859,7 +873,7 @@ func gitHasChanges(ctx context.Context, filePath string) (bool, error) {
// getRepoRootForWorktree returns the main repository root for running git commands // getRepoRootForWorktree returns the main repository root for running git commands
// This is always the main repository root, never the worktree root // This is always the main repository root, never the worktree root
func getRepoRootForWorktree(_ context.Context) string { func getRepoRootForWorktree(ctx context.Context) string {
repoRoot, err := git.GetMainRepoRoot() repoRoot, err := git.GetMainRepoRoot()
if err != nil { if err != nil {
// Fallback to current directory if GetMainRepoRoot fails // Fallback to current directory if GetMainRepoRoot fails
@@ -1913,129 +1927,6 @@ func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool {
return noGitHistory return noGitHistory
} }
// handleSyncRecovery handles --reset-remote and --force-push recovery options (bd-vckm)
// These are used when the sync branch has diverged significantly from remote.
func handleSyncRecovery(ctx context.Context, jsonlPath string, resetRemote, forcePush, renameOnImport, noGitHistory, dryRun bool) error {
// Check if sync.branch is configured
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("failed to initialize store: %w", err)
}
syncBranchName, err := syncbranch.Get(ctx, store)
if err != nil {
return fmt.Errorf("failed to get sync branch config: %w", err)
}
if syncBranchName == "" {
return fmt.Errorf("sync.branch not configured - recovery options only apply to sync branch mode\nRun 'bd config set sync.branch <branch-name>' to configure")
}
repoRoot, err := syncbranch.GetRepoRoot(ctx)
if err != nil {
return fmt.Errorf("failed to get repo root: %w", err)
}
// Check current divergence
divergence, err := syncbranch.CheckDivergence(ctx, repoRoot, syncBranchName)
if err != nil {
return fmt.Errorf("failed to check divergence: %w", err)
}
fmt.Printf("Sync branch '%s' status:\n", syncBranchName)
fmt.Printf(" Local ahead: %d commits\n", divergence.LocalAhead)
fmt.Printf(" Remote ahead: %d commits\n", divergence.RemoteAhead)
if divergence.IsDiverged {
fmt.Println(" Status: DIVERGED")
} else if divergence.LocalAhead > 0 {
fmt.Println(" Status: Local ahead of remote")
} else if divergence.RemoteAhead > 0 {
fmt.Println(" Status: Remote ahead of local")
} else {
fmt.Println(" Status: In sync")
}
fmt.Println()
if resetRemote {
if dryRun {
fmt.Println("[DRY RUN] Would reset local sync branch to remote state")
fmt.Printf(" This would discard %d local commit(s)\n", divergence.LocalAhead)
return nil
}
if divergence.LocalAhead == 0 {
fmt.Println("Nothing to reset - local is not ahead of remote")
return nil
}
fmt.Printf("Resetting sync branch '%s' to remote state...\n", syncBranchName)
fmt.Printf(" This will discard %d local commit(s)\n", divergence.LocalAhead)
if err := syncbranch.ResetToRemote(ctx, repoRoot, syncBranchName, jsonlPath); err != nil {
return fmt.Errorf("reset to remote failed: %w", err)
}
fmt.Println("✓ Reset complete - local sync branch now matches remote")
// Import the JSONL to update the database
fmt.Println("→ Importing JSONL to update database...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
return fmt.Errorf("import after reset failed: %w", err)
}
fmt.Println("✓ Import complete")
return nil
}
if forcePush {
if dryRun {
fmt.Println("[DRY RUN] Would force push local sync branch to remote")
fmt.Printf(" This would overwrite %d remote commit(s)\n", divergence.RemoteAhead)
return nil
}
if divergence.RemoteAhead == 0 && divergence.LocalAhead == 0 {
fmt.Println("Nothing to force push - already in sync")
return nil
}
fmt.Printf("Force pushing sync branch '%s' to remote...\n", syncBranchName)
if divergence.RemoteAhead > 0 {
fmt.Printf(" This will overwrite %d remote commit(s)\n", divergence.RemoteAhead)
}
if err := forcePushSyncBranch(ctx, repoRoot, syncBranchName); err != nil {
return fmt.Errorf("force push failed: %w", err)
}
fmt.Println("✓ Force push complete - remote now matches local")
return nil
}
return nil
}
// forcePushSyncBranch force pushes the local sync branch to remote (bd-vckm)
// This is used when you want to overwrite the remote state with local state.
func forcePushSyncBranch(ctx context.Context, repoRoot, syncBranch string) error {
// Worktree path is under .git/beads-worktrees/<branch>
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
// Get remote name
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", syncBranch))
remoteOutput, err := remoteCmd.Output()
remote := "origin"
if err == nil {
remote = strings.TrimSpace(string(remoteOutput))
}
// Force push
pushCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--force", remote, syncBranch)
if output, err := pushCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git push --force failed: %w\n%s", err, output)
}
return nil
}
// isExternalBeadsDir checks if the beads directory is in a different git repo than cwd. // 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. // This is used to detect when BEADS_DIR points to a separate repository.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533) // Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
@@ -2138,3 +2029,362 @@ func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error {
return nil return nil
} }
// SyncIntegrityResult contains the results of a pre-sync integrity check.
// bd-hlsw.1: Pre-sync integrity check
type SyncIntegrityResult struct {
ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"`
PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"`
OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"`
HasProblems bool `json:"has_problems"`
}
// ForcedPushCheck detects if sync branch has diverged from remote.
type ForcedPushCheck struct {
Detected bool `json:"detected"`
LocalRef string `json:"local_ref,omitempty"`
RemoteRef string `json:"remote_ref,omitempty"`
Message string `json:"message"`
}
// PrefixMismatch detects issues with wrong prefix in JSONL.
type PrefixMismatch struct {
ConfiguredPrefix string `json:"configured_prefix"`
MismatchedIDs []string `json:"mismatched_ids,omitempty"`
Count int `json:"count"`
}
// OrphanedChildren detects issues with parent that doesn't exist.
type OrphanedChildren struct {
OrphanedIDs []string `json:"orphaned_ids,omitempty"`
Count int `json:"count"`
}
// showSyncIntegrityCheck performs pre-sync integrity checks without modifying state.
// bd-hlsw.1: Detects forced pushes, prefix mismatches, and orphaned children.
func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) error {
fmt.Println("Sync Integrity Check")
fmt.Println("====================\n")
result := &SyncIntegrityResult{}
// Check 1: Detect forced pushes on sync branch
forcedPush := checkForcedPush(ctx)
result.ForcedPush = forcedPush
if forcedPush.Detected {
result.HasProblems = true
}
printForcedPushResult(forcedPush)
// Check 2: Detect prefix mismatches in JSONL
prefixMismatch, err := checkPrefixMismatch(ctx, jsonlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: prefix check failed: %v\n", err)
} else {
result.PrefixMismatch = prefixMismatch
if prefixMismatch != nil && prefixMismatch.Count > 0 {
result.HasProblems = true
}
printPrefixMismatchResult(prefixMismatch)
}
// Check 3: Detect orphaned children (parent issues that don't exist)
orphaned, err := checkOrphanedChildrenInJSONL(jsonlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: orphaned check failed: %v\n", err)
} else {
result.OrphanedChildren = orphaned
if orphaned != nil && orphaned.Count > 0 {
result.HasProblems = true
}
printOrphanedChildrenResult(orphaned)
}
// Summary
fmt.Println("\nSummary")
fmt.Println("-------")
if result.HasProblems {
fmt.Println("Problems detected! Review above and consider:")
if result.ForcedPush != nil && result.ForcedPush.Detected {
fmt.Println(" - Force push: Reset local sync branch or use 'bd sync --from-main'")
}
if result.PrefixMismatch != nil && result.PrefixMismatch.Count > 0 {
fmt.Println(" - Prefix mismatch: Use 'bd import --rename-on-import' to fix")
}
if result.OrphanedChildren != nil && result.OrphanedChildren.Count > 0 {
fmt.Println(" - Orphaned children: Remove parent references or create missing parents")
}
os.Exit(1)
} else {
fmt.Println("No problems detected. Safe to sync.")
}
if jsonOutput {
data, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(data))
}
return nil
}
// checkForcedPush detects if the sync branch has diverged from remote.
// This can happen when someone force-pushes to the sync branch.
func checkForcedPush(ctx context.Context) *ForcedPushCheck {
result := &ForcedPushCheck{
Detected: false,
Message: "No sync branch configured or no remote",
}
// Get sync branch name
if err := ensureStoreActive(); err != nil {
return result
}
syncBranch, _ := syncbranch.Get(ctx, store)
if syncBranch == "" {
return result
}
// Check if sync branch exists locally
checkLocalCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
if checkLocalCmd.Run() != nil {
result.Message = fmt.Sprintf("Sync branch '%s' does not exist locally", syncBranch)
return result
}
// Get local ref
localRefCmd := exec.CommandContext(ctx, "git", "rev-parse", syncBranch)
localRefOutput, err := localRefCmd.Output()
if err != nil {
result.Message = "Failed to get local sync branch ref"
return result
}
localRef := strings.TrimSpace(string(localRefOutput))
result.LocalRef = localRef
// Check if remote tracking branch exists
remote := "origin"
if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" {
remote = configuredRemote
}
// Get remote ref
remoteRefCmd := exec.CommandContext(ctx, "git", "rev-parse", remote+"/"+syncBranch)
remoteRefOutput, err := remoteRefCmd.Output()
if err != nil {
result.Message = fmt.Sprintf("Remote tracking branch '%s/%s' does not exist", remote, syncBranch)
return result
}
remoteRef := strings.TrimSpace(string(remoteRefOutput))
result.RemoteRef = remoteRef
// If refs match, no divergence
if localRef == remoteRef {
result.Message = "Sync branch is in sync with remote"
return result
}
// Check if local is ahead of remote (normal case)
aheadCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", remoteRef, localRef)
if aheadCmd.Run() == nil {
result.Message = "Local sync branch is ahead of remote (normal)"
return result
}
// Check if remote is ahead of local (behind, needs pull)
behindCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", localRef, remoteRef)
if behindCmd.Run() == nil {
result.Message = "Local sync branch is behind remote (needs pull)"
return result
}
// If neither is ancestor, branches have diverged - likely a force push
result.Detected = true
result.Message = fmt.Sprintf("Sync branch has DIVERGED from remote! Local: %s, Remote: %s. This may indicate a force push on the remote.", localRef[:8], remoteRef[:8])
return result
}
func printForcedPushResult(fp *ForcedPushCheck) {
fmt.Println("1. Force Push Detection")
if fp.Detected {
fmt.Printf(" [PROBLEM] %s\n", fp.Message)
} else {
fmt.Printf(" [OK] %s\n", fp.Message)
}
fmt.Println()
}
// checkPrefixMismatch detects issues in JSONL that don't match the configured prefix.
func checkPrefixMismatch(ctx context.Context, jsonlPath string) (*PrefixMismatch, error) {
result := &PrefixMismatch{
MismatchedIDs: []string{},
}
// Get configured prefix
if err := ensureStoreActive(); err != nil {
return nil, err
}
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil || prefix == "" {
prefix = "bd" // Default
}
result.ConfiguredPrefix = prefix
// Read JSONL and check each issue's prefix
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
if err != nil {
if os.IsNotExist(err) {
return result, nil // No JSONL, no mismatches
}
return nil, fmt.Errorf("failed to open JSONL: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var issue struct {
ID string `json:"id"`
}
if err := json.Unmarshal(line, &issue); err != nil {
continue // Skip malformed lines
}
// Check if ID starts with configured prefix
if !strings.HasPrefix(issue.ID, prefix+"-") {
result.MismatchedIDs = append(result.MismatchedIDs, issue.ID)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read JSONL: %w", err)
}
result.Count = len(result.MismatchedIDs)
return result, nil
}
func printPrefixMismatchResult(pm *PrefixMismatch) {
fmt.Println("2. Prefix Mismatch Check")
if pm == nil {
fmt.Println(" [SKIP] Could not check prefix")
fmt.Println()
return
}
fmt.Printf(" Configured prefix: %s\n", pm.ConfiguredPrefix)
if pm.Count > 0 {
fmt.Printf(" [PROBLEM] Found %d issue(s) with wrong prefix:\n", pm.Count)
// Show first 10
limit := pm.Count
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
fmt.Printf(" - %s\n", pm.MismatchedIDs[i])
}
if pm.Count > 10 {
fmt.Printf(" ... and %d more\n", pm.Count-10)
}
} else {
fmt.Println(" [OK] All issues have correct prefix")
}
fmt.Println()
}
// checkOrphanedChildrenInJSONL detects issues with parent references to non-existent issues.
func checkOrphanedChildrenInJSONL(jsonlPath string) (*OrphanedChildren, error) {
result := &OrphanedChildren{
OrphanedIDs: []string{},
}
// Read JSONL and build maps of IDs and parent references
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
if err != nil {
if os.IsNotExist(err) {
return result, nil
}
return nil, fmt.Errorf("failed to open JSONL: %w", err)
}
defer f.Close()
existingIDs := make(map[string]bool)
parentRefs := make(map[string]string) // child ID -> parent ID
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var issue struct {
ID string `json:"id"`
Parent string `json:"parent,omitempty"`
Status string `json:"status"`
}
if err := json.Unmarshal(line, &issue); err != nil {
continue
}
// Skip tombstones
if issue.Status == string(types.StatusTombstone) {
continue
}
existingIDs[issue.ID] = true
if issue.Parent != "" {
parentRefs[issue.ID] = issue.Parent
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read JSONL: %w", err)
}
// Find orphaned children (parent doesn't exist)
for childID, parentID := range parentRefs {
if !existingIDs[parentID] {
result.OrphanedIDs = append(result.OrphanedIDs, fmt.Sprintf("%s (parent: %s)", childID, parentID))
}
}
result.Count = len(result.OrphanedIDs)
return result, nil
}
func printOrphanedChildrenResult(oc *OrphanedChildren) {
fmt.Println("3. Orphaned Children Check")
if oc == nil {
fmt.Println(" [SKIP] Could not check orphaned children")
fmt.Println()
return
}
if oc.Count > 0 {
fmt.Printf(" [PROBLEM] Found %d issue(s) with missing parent:\n", oc.Count)
limit := oc.Count
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
fmt.Printf(" - %s\n", oc.OrphanedIDs[i])
}
if oc.Count > 10 {
fmt.Printf(" ... and %d more\n", oc.Count-10)
}
} else {
fmt.Println(" [OK] No orphaned children found")
}
fmt.Println()
}