Fix bd-l0r: Skip export after ZFC import to preserve JSONL source of truth

## Problem
When bd sync detected stale DB (>50% divergence), it would import JSONL to fix the DB,
but then immediately export the DB back to JSONL. This caused the stale DB to overwrite
the JSONL after a git pull, undoing cleanup work.

Example scenario:
1. Clone has 688 stale issues in DB (628 closed)
2. git pull brings JSONL with 62 issues (cleanup applied)
3. bd sync detects 1009.7% divergence and imports JSONL (DB → 62 issues) ✓
4. bd sync exports DB to JSONL (JSONL still 62 issues) ✓
5. But this marks JSONL as "changed" and commits/pushes it ✗

## Solution
After ZFC (JSONL First Consistency) import, set skipExport flag to prevent the export step.
JSONL is the source of truth after import - DB should sync to match, not export back.

## Changes
- cmd/bd/sync.go: Add skipExport flag, set it after ZFC import
- cmd/bd/sync.go: Wrap export logic in `if !skipExport` block
- CHANGELOG.md: Update ZFC entry with accurate description
- cmd/bd/sync_test.go: Add TestZFCSkipsExportAfterImport

Fixes #bd-l0r

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-24 01:45:46 -08:00
parent d1eb9f4e06
commit 30dfb247dc
4 changed files with 245 additions and 98 deletions

File diff suppressed because one or more lines are too long

View File

@@ -83,10 +83,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Now always uses content hash for reliable comparison
- Prevents bd sync from overwriting pulled JSONL and resurrecting deleted issues
- **ZFC (JSONL First Consistency)**: Enforce source of truth semantics (bd-l0r, 1ba068f, 2e4171a, 949ab42)
- bd sync now unconditionally imports JSONL first (source of truth)
- Simpler JSONL → DB → JSONL flow ensures consistency
- Prevents exporting when DB has significantly more issues than JSONL
- **ZFC (JSONL First Consistency)**: Fix stale DB overwriting JSONL on sync (bd-l0r, 1ba068f, 2e4171a, 949ab42)
- bd sync now detects stale DB (>50% divergence from JSONL) and imports first
- After ZFC import, skips export to prevent overwriting JSONL source of truth
- Fixes bug where DB with 688 issues would overwrite JSONL with 62 issues after pull
- JSONL is source of truth after git pull - DB syncs to match, not vice versa
- Preserves local uncommitted changes while catching stale DB scenarios
- **Merge Conflict Semantics**: Improved resolution policies (bd-pq5k, d4f9a05)

View File

@@ -124,11 +124,13 @@ Use --merge to merge the sync branch back to main branch.`,
}
// Step 1: Export pending changes (but check for stale DB first)
skipExport := false // Track if we should skip export due to ZFC import
if dryRun {
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
} else {
// ZFC safety check (bd-l0r): if DB significantly diverges from JSONL,
// force import first to sync with JSONL source of truth
// After import, skip export to prevent overwriting JSONL (JSONL is source of truth)
if err := ensureStoreActive(); err == nil && store != nil {
dbCount, err := countDBIssuesFast(ctx, store)
if err == nil {
@@ -142,32 +144,37 @@ Use --merge to merge the sync branch back to main branch.`,
fmt.Fprintf(os.Stderr, "Error importing (ZFC): %v\n", err)
os.Exit(1)
}
// Skip export after ZFC import - JSONL is source of truth
skipExport = true
fmt.Println("→ Skipping export (JSONL is source of truth after ZFC import)")
}
}
}
}
// Pre-export integrity checks
if err := ensureStoreActive(); err == nil && store != nil {
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Pre-export validation failed: %v\n", err)
os.Exit(1)
if !skipExport {
// Pre-export integrity checks
if err := ensureStoreActive(); err == nil && store != nil {
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Pre-export validation failed: %v\n", err)
os.Exit(1)
}
if err := checkDuplicateIDs(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Database corruption detected: %v\n", err)
os.Exit(1)
}
if orphaned, err := checkOrphanedDeps(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Warning: orphaned dependency check failed: %v\n", err)
} else if len(orphaned) > 0 {
fmt.Fprintf(os.Stderr, "Warning: found %d orphaned dependencies: %v\n", len(orphaned), orphaned)
}
}
if err := checkDuplicateIDs(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Database corruption detected: %v\n", err)
os.Exit(1)
}
if orphaned, err := checkOrphanedDeps(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Warning: orphaned dependency check failed: %v\n", err)
} else if len(orphaned) > 0 {
fmt.Fprintf(os.Stderr, "Warning: found %d orphaned dependencies: %v\n", len(orphaned), orphaned)
}
}
fmt.Println("→ Exporting pending changes to JSONL...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
os.Exit(1)
fmt.Println("→ Exporting pending changes to JSONL...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
os.Exit(1)
}
}
// Capture left snapshot (pre-pull state) for 3-way merge

View File

@@ -2,14 +2,17 @@ package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
)
func TestIsGitRepo_InGitRepo(t *testing.T) {
@@ -431,3 +434,139 @@ func TestHasJSONLConflict_MultipleConflicts(t *testing.T) {
t.Error("expected false when multiple files have conflicts (should not auto-resolve)")
}
}
// TestZFCSkipsExportAfterImport tests the bd-l0r fix: after importing JSONL due to
// stale DB detection, sync should skip export to avoid overwriting the JSONL source of truth.
func TestZFCSkipsExportAfterImport(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// Setup beads directory with JSONL
beadsDir := filepath.Join(tmpDir, ".beads")
os.MkdirAll(beadsDir, 0755)
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
// Create JSONL with 10 issues (simulating pulled state after cleanup)
var jsonlLines []string
for i := 1; i <= 10; i++ {
line := fmt.Sprintf(`{"id":"bd-%d","title":"JSONL Issue %d","status":"open","issue_type":"task","priority":2,"created_at":"2025-11-24T00:00:00Z","updated_at":"2025-11-24T00:00:00Z"}`, i, i)
jsonlLines = append(jsonlLines, line)
}
os.WriteFile(jsonlPath, []byte(strings.Join(jsonlLines, "\n")+"\n"), 0644)
// Create SQLite store with 100 stale issues (10x the JSONL count = 900% divergence)
dbPath := filepath.Join(beadsDir, "beads.db")
testStore, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
defer testStore.Close()
// Set issue_prefix to prevent "database not initialized" errors
if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Populate DB with 100 issues (stale, 90 closed)
for i := 1; i <= 100; i++ {
status := types.StatusOpen
var closedAt *time.Time
if i > 10 { // First 10 open, rest closed
status = types.StatusClosed
now := time.Now()
closedAt = &now
}
issue := &types.Issue{
Title: fmt.Sprintf("Old Issue %d", i),
Status: status,
ClosedAt: closedAt,
IssueType: types.TypeTask,
Priority: 2,
}
if err := testStore.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("failed to create issue %d: %v", i, err)
}
}
// Verify divergence: (100 - 10) / 10 = 900% > 50% threshold
dbCount, _ := countDBIssuesFast(ctx, testStore)
jsonlCount, _ := countIssuesInJSONL(jsonlPath)
divergence := float64(dbCount-jsonlCount) / float64(jsonlCount)
if dbCount != 100 {
t.Fatalf("DB setup failed: expected 100 issues, got %d", dbCount)
}
if jsonlCount != 10 {
t.Fatalf("JSONL setup failed: expected 10 issues, got %d", jsonlCount)
}
if divergence <= 0.5 {
t.Fatalf("Divergence too low: %.2f%% (expected >50%%)", divergence*100)
}
// Set global store for the test
oldStore := store
storeMutex.Lock()
oldStoreActive := storeActive
store = testStore
storeActive = true
storeMutex.Unlock()
defer func() {
storeMutex.Lock()
store = oldStore
storeActive = oldStoreActive
storeMutex.Unlock()
}()
// Save JSONL content hash before running sync logic
beforeHash, _ := computeJSONLHash(jsonlPath)
// Simulate the ZFC check and export step from sync.go lines 126-186
// This is the code path that should detect divergence and skip export
skipExport := false
// ZFC safety check
if err := ensureStoreActive(); err == nil && store != nil {
dbCount, err := countDBIssuesFast(ctx, store)
if err == nil {
jsonlCount, err := countIssuesInJSONL(jsonlPath)
if err == nil && jsonlCount > 0 && dbCount > jsonlCount {
divergence := float64(dbCount-jsonlCount) / float64(jsonlCount)
if divergence > 0.5 {
// Import JSONL (this should sync DB to match JSONL's 62 issues)
if err := importFromJSONL(ctx, jsonlPath, false); err != nil {
t.Fatalf("ZFC import failed: %v", err)
}
skipExport = true
}
}
}
}
// Verify skipExport was set
if !skipExport {
t.Error("Expected skipExport=true after ZFC import, but got false")
}
// Verify DB was synced to JSONL (should have 10 issues now, not 100)
afterDBCount, _ := countDBIssuesFast(ctx, testStore)
if afterDBCount != 10 {
t.Errorf("After ZFC import, DB should have 10 issues (matching JSONL), got %d", afterDBCount)
}
// Verify JSONL was NOT modified (no export happened)
afterHash, _ := computeJSONLHash(jsonlPath)
if beforeHash != afterHash {
t.Error("JSONL content changed after ZFC import (export should have been skipped)")
}
// Verify issue count in JSONL is still 10
finalJSONLCount, _ := countIssuesInJSONL(jsonlPath)
if finalJSONLCount != 10 {
t.Errorf("JSONL should still have 10 issues, got %d", finalJSONLCount)
}
t.Logf("✓ ZFC fix verified: DB synced from 100 to 10 issues, JSONL unchanged")
}