Merge bd-cddj-nux: GH#519
This commit is contained in:
@@ -36,6 +36,7 @@ Common operations:
|
||||
bd daemon --start Start the daemon (background)
|
||||
bd daemon --start --foreground Start in foreground (for systemd/supervisord)
|
||||
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 --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) {
|
||||
start, _ := cmd.Flags().GetBool("start")
|
||||
stop, _ := cmd.Flags().GetBool("stop")
|
||||
stopAll, _ := cmd.Flags().GetBool("stop-all")
|
||||
status, _ := cmd.Flags().GetBool("status")
|
||||
health, _ := cmd.Flags().GetBool("health")
|
||||
metrics, _ := cmd.Flags().GetBool("metrics")
|
||||
@@ -54,7 +56,7 @@ Run 'bd daemon' with no flags to see available options.`,
|
||||
foreground, _ := cmd.Flags().GetBool("foreground")
|
||||
|
||||
// If no operation flags provided, show help
|
||||
if !start && !stop && !status && !health && !metrics {
|
||||
if !start && !stop && !stopAll && !status && !health && !metrics {
|
||||
_ = cmd.Help()
|
||||
return
|
||||
}
|
||||
@@ -119,6 +121,11 @@ Run 'bd daemon' with no flags to see available options.`,
|
||||
return
|
||||
}
|
||||
|
||||
if stopAll {
|
||||
stopAllDaemons()
|
||||
return
|
||||
}
|
||||
|
||||
// If we get here and --start wasn't provided, something is wrong
|
||||
// (should have been caught by help check above)
|
||||
if !start {
|
||||
@@ -222,6 +229,7 @@ func init() {
|
||||
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("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("health", false, "Check daemon health and metrics")
|
||||
daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/daemon"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
)
|
||||
|
||||
@@ -304,6 +305,59 @@ func stopDaemon(pidFile string) {
|
||||
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)
|
||||
func startDaemon(interval time.Duration, autoCommit, autoPush, localMode, foreground bool, logFile, pidFile string) {
|
||||
logPath, err := getLogFilePath(logFile)
|
||||
|
||||
159
cmd/bd/doctor.go
159
cmd/bd/doctor.go
@@ -659,6 +659,58 @@ func checkHooksQuick() string {
|
||||
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).
|
||||
// Returns empty string if OK, otherwise returns issue description.
|
||||
func checkSyncBranchHookQuick(path string) string {
|
||||
@@ -668,56 +720,19 @@ func checkSyncBranchHookQuick(path string) string {
|
||||
return "" // sync-branch not configured, nothing to check
|
||||
}
|
||||
|
||||
// Get git directory
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
hookPath, err := getPrePushHookPath(path)
|
||||
if err != nil {
|
||||
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
|
||||
if err != nil {
|
||||
return "" // No pre-push hook, covered by other checks
|
||||
}
|
||||
|
||||
// Check if bd hook and extract version
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
hookVersion := extractBdHookVersion(string(content))
|
||||
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)
|
||||
@@ -2072,6 +2087,7 @@ func checkGitHooks() doctorCheck {
|
||||
// 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
|
||||
// (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 {
|
||||
// Check if sync-branch is configured
|
||||
syncBranch := syncbranch.GetFromYAML()
|
||||
@@ -2083,11 +2099,8 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// sync-branch is configured - check pre-push hook version
|
||||
// Get actual git directory (handles worktrees where .git is a file)
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
cmd.Dir = path
|
||||
output, err := cmd.Output()
|
||||
// Get pre-push hook path using common helper
|
||||
hookPath, err := getPrePushHookPath(path)
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
@@ -2095,27 +2108,6 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
|
||||
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
|
||||
if err != nil {
|
||||
@@ -2127,10 +2119,13 @@ func checkSyncBranchHookCompatibility(path string) doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a bd hook and extract version
|
||||
hookStr := string(hookContent)
|
||||
if !strings.Contains(hookStr, "bd-hooks-version:") {
|
||||
// Not a bd hook - can't determine compatibility
|
||||
// Extract version using common helper
|
||||
hookVersion := extractBdHookVersion(string(hookContent))
|
||||
|
||||
// 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{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
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 == "" {
|
||||
return doctorCheck{
|
||||
Name: "Sync Branch Hook Compatibility",
|
||||
@@ -2566,16 +2549,14 @@ func checkSyncBranchConfig(path string) doctorCheck {
|
||||
currentBranch = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// CRITICAL: Check if we're on the sync branch - this is a misconfiguration
|
||||
// that will cause bd sync to fail trying to create a worktree for a branch
|
||||
// that's already checked out
|
||||
// GH#519: Check if we're on the sync branch - this is supported but worth noting
|
||||
// bd sync will commit directly instead of using worktree when on sync branch
|
||||
if syncBranch != "" && currentBranch == syncBranch {
|
||||
return doctorCheck{
|
||||
Name: "Sync Branch Config",
|
||||
Status: statusError,
|
||||
Message: fmt.Sprintf("On sync branch '%s'", 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),
|
||||
Fix: "Switch to your main working branch: git checkout main",
|
||||
Status: statusOK,
|
||||
Message: fmt.Sprintf("On sync branch '%s' (direct mode)", syncBranch),
|
||||
Detail: fmt.Sprintf("Currently on sync branch '%s'. bd sync will commit directly instead of using worktree.", syncBranch),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
552
cmd/bd/sync.go
552
cmd/bd/sync.go
@@ -58,9 +58,7 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
fromMain, _ := cmd.Flags().GetBool("from-main")
|
||||
noGitHistory, _ := cmd.Flags().GetBool("no-git-history")
|
||||
squash, _ := cmd.Flags().GetBool("squash")
|
||||
// Recovery options (bd-vckm)
|
||||
resetRemote, _ := cmd.Flags().GetBool("reset-remote")
|
||||
forcePush, _ := cmd.Flags().GetBool("force-push")
|
||||
checkIntegrity, _ := cmd.Flags().GetBool("check")
|
||||
|
||||
// bd-sync-corruption fix: Force direct mode for sync operations.
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if err := mergeSyncBranch(ctx, dryRun); err != nil {
|
||||
@@ -111,15 +118,6 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
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 importOnly {
|
||||
if dryRun {
|
||||
@@ -389,20 +387,22 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
var syncBranchName string
|
||||
var repoRoot string
|
||||
var useSyncBranch bool
|
||||
var onSyncBranch bool // GH#519: track if we're on the sync branch
|
||||
if err := ensureStoreActive(); err == nil && store != nil {
|
||||
syncBranchName, _ = syncbranch.Get(ctx, store)
|
||||
if syncBranchName != "" && syncbranch.HasGitRemote(ctx) {
|
||||
// GH#519: Check if sync.branch equals current branch
|
||||
// If so, we can't use a worktree (git doesn't allow same branch in multiple worktrees)
|
||||
// Fall back to direct commits on the current branch
|
||||
if syncbranch.IsSyncBranchSameAsCurrent(ctx, syncBranchName) {
|
||||
// sync.branch == current branch - use regular commits, not worktree
|
||||
useSyncBranch = false
|
||||
repoRoot, err = syncbranch.GetRepoRoot(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: sync.branch configured but failed to get repo root: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Falling back to current branch commits\n")
|
||||
} else {
|
||||
repoRoot, err = syncbranch.GetRepoRoot(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: sync.branch configured but failed to get repo root: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Falling back to current branch commits\n")
|
||||
// GH#519: Check if current branch is the sync branch
|
||||
// If so, commit directly instead of using worktree (which would fail)
|
||||
currentBranch, _ := getCurrentBranch(ctx)
|
||||
if currentBranch == syncBranchName {
|
||||
onSyncBranch = true
|
||||
// Don't use worktree - commit directly to current branch
|
||||
useSyncBranch = false
|
||||
} else {
|
||||
useSyncBranch = true
|
||||
}
|
||||
@@ -424,6 +424,9 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
if dryRun {
|
||||
if useSyncBranch {
|
||||
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 {
|
||||
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 {
|
||||
// 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 {
|
||||
fmt.Fprintf(os.Stderr, "Error committing: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -460,6 +468,9 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
if dryRun {
|
||||
if useSyncBranch {
|
||||
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 {
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
// 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("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")
|
||||
// Recovery options for diverged sync branches (bd-vckm)
|
||||
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)")
|
||||
syncCmd.Flags().Bool("check", false, "Pre-sync integrity check: detect forced pushes, prefix mismatches, and orphaned issues")
|
||||
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
|
||||
// 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()
|
||||
if err != nil {
|
||||
// Fallback to current directory if GetMainRepoRoot fails
|
||||
@@ -1913,129 +1927,6 @@ func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool {
|
||||
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.
|
||||
// This is used to detect when BEADS_DIR points to a separate repository.
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user