fix(sync): atomic export and force-push detection (bd-3bhl, bd-4hh5)

bd-3bhl: Add sync rollback on git commit failure
- Use exportToJSONLDeferred() instead of exportToJSONL() for atomic sync
- Call finalizeExport() only after git commit succeeds
- Rollback JSONL from git HEAD on commit failure
- Add rollbackJSONLFromGit() helper function
- Coverage: regular commit, sync branch, external beads repo paths

bd-4hh5: Fix false-positive force-push detection
- Use explicit refspec in CheckForcePush fetch
- +refs/heads/beads-sync:refs/remotes/origin/beads-sync
- Ensures tracking ref is always created/updated
- Fixes stale ref comparison causing false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
emma
2026-01-04 23:13:56 -08:00
committed by Steve Yegge
parent d7221f6858
commit 9b84ef73dd
3 changed files with 88 additions and 5 deletions

View File

@@ -492,6 +492,27 @@ func parseGitStatusForBeadsChanges(statusOutput string) bool {
return false
}
// rollbackJSONLFromGit restores the JSONL file from git HEAD after a failed commit.
// This is part of the sync atomicity fix (GH#885/bd-3bhl): when git commit fails
// after export, we restore the JSONL to its previous state so the working
// directory stays consistent with the last successful sync.
func rollbackJSONLFromGit(ctx context.Context, jsonlPath string) error {
// Check if the file is tracked by git
cmd := exec.CommandContext(ctx, "git", "ls-files", "--error-unmatch", jsonlPath)
if err := cmd.Run(); err != nil {
// File not tracked - nothing to restore
return nil
}
// Restore from HEAD
restoreCmd := exec.CommandContext(ctx, "git", "checkout", "HEAD", "--", jsonlPath) //nolint:gosec // G204: jsonlPath from internal beads.FindBeadsDir()
output, err := restoreCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git checkout failed: %w\n%s", err, output)
}
return 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 {