Merge branch 'main' of https://github.com/steveyegge/beads
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user