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
|
- Now always uses content hash for reliable comparison
|
||||||
- Prevents bd sync from overwriting pulled JSONL and resurrecting deleted issues
|
- 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)
|
- **ZFC (JSONL First Consistency)**: Fix stale DB overwriting JSONL on sync (bd-l0r, 1ba068f, 2e4171a, 949ab42)
|
||||||
- bd sync now unconditionally imports JSONL first (source of truth)
|
- bd sync now detects stale DB (>50% divergence from JSONL) and imports first
|
||||||
- Simpler JSONL → DB → JSONL flow ensures consistency
|
- After ZFC import, skips export to prevent overwriting JSONL source of truth
|
||||||
- Prevents exporting when DB has significantly more issues than JSONL
|
- 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
|
- Preserves local uncommitted changes while catching stale DB scenarios
|
||||||
|
|
||||||
- **Merge Conflict Semantics**: Improved resolution policies (bd-pq5k, d4f9a05)
|
- **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)
|
// 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 {
|
if dryRun {
|
||||||
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
|
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
|
||||||
} else {
|
} else {
|
||||||
// ZFC safety check (bd-l0r): if DB significantly diverges from JSONL,
|
// ZFC safety check (bd-l0r): if DB significantly diverges from JSONL,
|
||||||
// force import first to sync with JSONL source of truth
|
// 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 {
|
if err := ensureStoreActive(); err == nil && store != nil {
|
||||||
dbCount, err := countDBIssuesFast(ctx, store)
|
dbCount, err := countDBIssuesFast(ctx, store)
|
||||||
if err == nil {
|
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)
|
fmt.Fprintf(os.Stderr, "Error importing (ZFC): %v\n", err)
|
||||||
os.Exit(1)
|
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 !skipExport {
|
||||||
if err := ensureStoreActive(); err == nil && store != nil {
|
// Pre-export integrity checks
|
||||||
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
|
if err := ensureStoreActive(); err == nil && store != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Pre-export validation failed: %v\n", err)
|
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
|
||||||
os.Exit(1)
|
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...")
|
fmt.Println("→ Exporting pending changes to JSONL...")
|
||||||
if err := exportToJSONL(ctx, jsonlPath); err != nil {
|
if err := exportToJSONL(ctx, jsonlPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture left snapshot (pre-pull state) for 3-way merge
|
// Capture left snapshot (pre-pull state) for 3-way merge
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/syncbranch"
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsGitRepo_InGitRepo(t *testing.T) {
|
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)")
|
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