fix: defer SQLite metadata updates until after git commit (GH#885)

This fixes the atomicity gap where exportToJSONL would update SQLite
metadata (clear dirty flags, update content hash, last_import_time)
BEFORE the git commit. If git commit failed, SQLite would incorrectly
indicate the sync succeeded.

Changes:
- Add ExportResult struct to capture export metadata for deferred finalization
- Add exportToJSONLDeferred() that exports without updating metadata
- Add finalizeExport() to update metadata after git commit succeeds
- Update daemon_sync.go sync flows to defer metadata updates

Now the sync flow is truly atomic: metadata only updates after git commit.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
beads/crew/dave
2026-01-04 15:15:52 -08:00
committed by Steve Yegge
parent 68f5bb24f8
commit ede940b5d2
2 changed files with 166 additions and 82 deletions

View File

@@ -437,25 +437,29 @@ func performExport(ctx context.Context, store storage.Storage, autoCommit, autoP
}
log.log("Exported to JSONL")
// Update export metadata for multi-repo support with stable keys
multiRepoPaths := getMultiRepoJSONLPaths()
if multiRepoPaths != nil {
// Multi-repo mode: update metadata for each JSONL with stable repo key
for _, path := range multiRepoPaths {
repoKey := getRepoKeyForPath(path)
updateExportMetadata(exportCtx, store, path, log, repoKey)
// GH#885: Defer metadata updates until AFTER git commit succeeds.
// This is a helper to finalize the export after git operations.
finalizeExportMetadata := func() {
// Update export metadata for multi-repo support with stable keys
multiRepoPaths := getMultiRepoJSONLPaths()
if multiRepoPaths != nil {
// Multi-repo mode: update metadata for each JSONL with stable repo key
for _, path := range multiRepoPaths {
repoKey := getRepoKeyForPath(path)
updateExportMetadata(exportCtx, store, path, log, repoKey)
}
} else {
// Single-repo mode: update metadata for main JSONL
updateExportMetadata(exportCtx, store, jsonlPath, log, "")
}
} else {
// Single-repo mode: update metadata for main JSONL
updateExportMetadata(exportCtx, store, jsonlPath, log, "")
}
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
// with "JSONL is newer than database" after daemon auto-export
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
// with "JSONL is newer than database" after daemon auto-export
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
}
// Auto-commit if enabled (skip in git-free mode)
@@ -497,6 +501,12 @@ func performExport(ctx context.Context, store storage.Storage, autoCommit, autoP
}
}
}
// GH#885: NOW finalize metadata after git commit succeeded
finalizeExportMetadata()
} else if skipGit {
// Git-free mode: finalize immediately since there's no git to wait for
finalizeExportMetadata()
}
if skipGit {
@@ -720,28 +730,34 @@ func performSync(ctx context.Context, store storage.Storage, autoCommit, autoPus
}
log.log("Exported to JSONL")
// Update export metadata for multi-repo support with stable keys
if multiRepoPaths != nil {
// Multi-repo mode: update metadata for each JSONL with stable repo key
for _, path := range multiRepoPaths {
repoKey := getRepoKeyForPath(path)
updateExportMetadata(syncCtx, store, path, log, repoKey)
}
} else {
// Single-repo mode: update metadata for main JSONL
updateExportMetadata(syncCtx, store, jsonlPath, log, "")
}
// Update database mtime to be >= JSONL mtime
// This prevents validatePreExport from incorrectly blocking on next export
// GH#885: Defer metadata updates until AFTER git commit succeeds.
// Define helper to finalize after git operations.
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
finalizeExportMetadata := func() {
// Update export metadata for multi-repo support with stable keys
if multiRepoPaths != nil {
// Multi-repo mode: update metadata for each JSONL with stable repo key
for _, path := range multiRepoPaths {
repoKey := getRepoKeyForPath(path)
updateExportMetadata(syncCtx, store, path, log, repoKey)
}
} else {
// Single-repo mode: update metadata for main JSONL
updateExportMetadata(syncCtx, store, jsonlPath, log, "")
}
// Update database mtime to be >= JSONL mtime
// This prevents validatePreExport from incorrectly blocking on next export
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
log.log("Warning: failed to update database mtime: %v", err)
}
}
// Skip git operations, snapshot capture, deletion tracking, and import in local-only mode
// Local-only sync is export-only since there's no remote to sync with
if skipGit {
// Git-free mode: finalize immediately since there's no git to wait for
finalizeExportMetadata()
log.log("Local %s complete", mode)
return
}
@@ -793,6 +809,9 @@ func performSync(ctx context.Context, store storage.Storage, autoCommit, autoPus
log.log("Committed changes")
}
}
// GH#885: NOW finalize metadata after git commit succeeded
finalizeExportMetadata()
}
// Pull (try sync branch first)