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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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),
}
}

View File

@@ -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()
}