Merge bd-cddj-nux: GH#519
This commit is contained in:
1800
.beads/issues.jsonl
1800
.beads/issues.jsonl
File diff suppressed because one or more lines are too long
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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)
|
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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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")
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user