fix(bd-53c): Add reverse ZFC check to prevent stale DB from corrupting JSONL
Root cause: bd sync exports DB to JSONL BEFORE pulling from remote. If the local DB is stale (fewer issues than JSONL), the stale data gets exported and committed, potentially corrupting the remote when pushed. The existing ZFC (Zero-Fill Check) only detected when DB had MORE issues than JSONL, missing the dangerous reverse case. Fix: Added "reverse ZFC" check in sync.go that detects when JSONL has significantly more issues than DB (>20% divergence or empty DB). When detected, it imports JSONL first to sync the database before any export occurs. This prevents stale/fresh clones from exporting their incomplete database state over a well-populated JSONL file. Version bump: 0.26.0 -> 0.26.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -171,25 +171,55 @@ Use --merge to merge the sync branch back to main branch.`,
|
||||
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)
|
||||
// ZFC safety check (bd-l0r, bd-53c): 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).
|
||||
//
|
||||
// bd-53c fix: Added REVERSE ZFC check - if JSONL has MORE issues than DB,
|
||||
// this indicates the DB is stale and exporting would cause data loss.
|
||||
// This catches the case where a fresh/stale clone tries to export an
|
||||
// empty or outdated database over a JSONL with many issues.
|
||||
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 { // >50% more issues in DB than JSONL
|
||||
fmt.Printf("→ DB has %d issues but JSONL has %d (stale DB detected)\n", dbCount, jsonlCount)
|
||||
fmt.Println("→ Importing JSONL first (ZFC)...")
|
||||
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error importing (ZFC): %v\n", err)
|
||||
os.Exit(1)
|
||||
if err == nil && jsonlCount > 0 {
|
||||
// Case 1: DB has significantly more issues than JSONL
|
||||
// (original ZFC check - DB is ahead of JSONL)
|
||||
if dbCount > jsonlCount {
|
||||
divergence := float64(dbCount-jsonlCount) / float64(jsonlCount)
|
||||
if divergence > 0.5 { // >50% more issues in DB than JSONL
|
||||
fmt.Printf("→ DB has %d issues but JSONL has %d (stale JSONL detected)\n", dbCount, jsonlCount)
|
||||
fmt.Println("→ Importing JSONL first (ZFC)...")
|
||||
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2 (bd-53c): JSONL has significantly more issues than DB
|
||||
// This is the DANGEROUS case - exporting would lose issues!
|
||||
// A stale/empty DB exporting over a populated JSONL causes data loss.
|
||||
if jsonlCount > dbCount && !skipExport {
|
||||
divergence := float64(jsonlCount-dbCount) / float64(jsonlCount)
|
||||
// Use stricter threshold for this dangerous case:
|
||||
// - Any loss > 20% is suspicious
|
||||
// - Complete loss (DB empty) is always blocked
|
||||
if dbCount == 0 || divergence > 0.2 {
|
||||
fmt.Printf("→ JSONL has %d issues but DB has only %d (stale DB detected - bd-53c)\n", jsonlCount, dbCount)
|
||||
fmt.Println("→ Importing JSONL first to prevent data loss...")
|
||||
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error importing (reverse ZFC): %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Skip export after import - JSONL is source of truth
|
||||
skipExport = true
|
||||
fmt.Println("→ Skipping export (JSONL is source of truth after reverse ZFC import)")
|
||||
}
|
||||
// Skip export after ZFC import - JSONL is source of truth
|
||||
skipExport = true
|
||||
fmt.Println("→ Skipping export (JSONL is source of truth after ZFC import)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -886,6 +916,9 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
|
||||
}
|
||||
|
||||
// Safety check: prevent exporting empty database over non-empty JSONL
|
||||
// Note: The main bd-53c protection is the reverse ZFC check earlier in sync.go
|
||||
// which runs BEFORE export. Here we only block the most catastrophic case (empty DB)
|
||||
// to allow legitimate deletions.
|
||||
if len(issues) == 0 {
|
||||
existingCount, countErr := countIssuesInJSONL(jsonlPath)
|
||||
if countErr != nil {
|
||||
@@ -898,16 +931,6 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Warning: check if export would lose >50% of issues
|
||||
existingCount, err := countIssuesInJSONL(jsonlPath)
|
||||
if err == nil && existingCount > 0 {
|
||||
lossPercent := float64(existingCount-len(issues)) / float64(existingCount) * 100
|
||||
if lossPercent > 50 {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: Export would lose %.1f%% of issues (existing: %d, database: %d)\n",
|
||||
lossPercent, existingCount, len(issues))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ID for consistent output
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
|
||||
Reference in New Issue
Block a user