feat: detect uncommitted JSONL changes before sync (GH#885, bd-vd8e)

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 <noreply@anthropic.com>
This commit is contained in:
beads/crew/fang
2026-01-04 15:10:35 -08:00
committed by Steve Yegge
parent 7b90678afe
commit 44a5c3a0ec
2 changed files with 54 additions and 0 deletions

View File

@@ -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") 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 // GH#638: Check sync.branch BEFORE upstream check
// When sync.branch is configured, we should use worktree-based sync even if // 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) // the current branch has no upstream (e.g., detached HEAD in jj, git worktrees)

View File

@@ -447,6 +447,45 @@ func restoreBeadsDirFromBranch(ctx context.Context) error {
return nil 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 // 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 // Checks remote HEAD first, then falls back to checking if main/master exist
func getDefaultBranch(ctx context.Context) string { func getDefaultBranch(ctx context.Context) string {