From 44a5c3a0ecefefb29864f68caf356d42295e8b34 Mon Sep 17 00:00:00 2001 From: beads/crew/fang Date: Sun, 4 Jan 2026 15:10:35 -0800 Subject: [PATCH] feat: detect uncommitted JSONL changes before sync (GH#885, bd-vd8e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pre-flight safety check to detect when a previous sync exported but failed before commit, leaving JSONL in an inconsistent state. - Add gitHasUncommittedBeadsChanges() helper in sync_git.go - Call in sync pre-flight checks after merge/rebase check - If uncommitted changes detected, force re-export to reconcile state This catches the failure mode early before it compounds across worktrees. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/sync.go | 15 +++++++++++++++ cmd/bd/sync_git.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index f01ef372..bcbc8557 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -163,6 +163,21 @@ Use --merge to merge the sync branch back to main branch.`, FatalErrorWithHint("unmerged paths or merge in progress", "resolve conflicts, run 'bd import' if needed, then 'bd sync' again") } + // GH#885: Preflight check for uncommitted JSONL changes + // This detects when a previous sync exported but failed before commit, + // leaving the JSONL in an inconsistent state across worktrees. + if hasUncommitted, err := gitHasUncommittedBeadsChanges(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to check for uncommitted changes: %v\n", err) + } else if hasUncommitted { + fmt.Println("→ Detected uncommitted JSONL changes (possible incomplete sync)") + fmt.Println("→ Re-exporting from database to reconcile state...") + // Force a fresh export to ensure JSONL matches current DB state + if err := exportToJSONL(ctx, jsonlPath); err != nil { + FatalError("re-exporting to reconcile state: %v", err) + } + fmt.Println("✓ State reconciled") + } + // GH#638: Check sync.branch BEFORE upstream check // When sync.branch is configured, we should use worktree-based sync even if // the current branch has no upstream (e.g., detached HEAD in jj, git worktrees) diff --git a/cmd/bd/sync_git.go b/cmd/bd/sync_git.go index 26a481da..97b6c467 100644 --- a/cmd/bd/sync_git.go +++ b/cmd/bd/sync_git.go @@ -447,6 +447,45 @@ func restoreBeadsDirFromBranch(ctx context.Context) error { return nil } +// gitHasUncommittedBeadsChanges checks if .beads/issues.jsonl has uncommitted changes. +// This detects the failure mode where a previous sync exported but failed before commit. +// Returns true if the JSONL file has staged or unstaged changes (M or A status). +// GH#885: Pre-flight safety check to detect incomplete sync operations. +func gitHasUncommittedBeadsChanges(ctx context.Context) (bool, error) { + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + return false, nil // No beads dir, nothing to check + } + + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + // Check git status for the JSONL file specifically + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", jsonlPath) //nolint:gosec // G204: jsonlPath from internal beads.FindBeadsDir() + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("git status failed: %w", err) + } + + // Parse status output - look for modified/added files + // Format: XY filename where X=staged, Y=unstaged + // M = modified, A = added, ? = untracked + statusLine := strings.TrimSpace(string(output)) + if statusLine == "" { + return false, nil // No changes + } + + // Any status (M, A, MM, AM, etc.) indicates uncommitted changes + if len(statusLine) >= 2 { + x, y := statusLine[0], statusLine[1] + // Check for modifications (staged or unstaged) + if x == 'M' || x == 'A' || y == 'M' || y == 'A' { + return true, nil + } + } + + return false, nil +} + // getDefaultBranch returns the default branch name (main or master) for origin remote // Checks remote HEAD first, then falls back to checking if main/master exist func getDefaultBranch(ctx context.Context) string {