fix(sync): make snapshot protection timestamp-aware (GH#865)

The --protect-left-snapshot mechanism was protecting ALL local issues
by ID alone, ignoring timestamps. This caused newer remote changes to
be incorrectly skipped during cross-worktree sync.

Changes:
- Add BuildIDToTimestampMap() to SnapshotManager for timestamp-aware
  snapshot reading
- Change ProtectLocalExportIDs from map[string]bool to map[string]time.Time
- Add shouldProtectFromUpdate() helper that compares timestamps
- Only protect if local snapshot is newer than incoming; allow update
  if incoming is newer

This fixes data loss scenarios where:
1. Main worktree closes issue at 11:31
2. Test worktree syncs and incorrectly skips the update
3. Test worktree then pushes stale open state, overwriting mains changes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
emma
2026-01-03 13:27:19 -08:00
committed by Steve Yegge
parent a5d9793ecd
commit 62e4eaf7c1
5 changed files with 348 additions and 8 deletions

View File

@@ -262,19 +262,19 @@ NOTE: Import requires direct database access and does not work with daemon mode.
OrphanHandling: orphanHandling,
}
// If --protect-left-snapshot is set, read the left snapshot and build ID set
// This protects locally exported issues from git-history-backfill
// If --protect-left-snapshot is set, read the left snapshot and build timestamp map
// GH#865: Use timestamp-aware protection - only protect if local is newer than incoming
if protectLeftSnapshot && input != "" {
beadsDir := filepath.Dir(input)
leftSnapshotPath := filepath.Join(beadsDir, "beads.left.jsonl")
if _, err := os.Stat(leftSnapshotPath); err == nil {
sm := NewSnapshotManager(input)
leftIDs, err := sm.BuildIDSet(leftSnapshotPath)
leftTimestamps, err := sm.BuildIDToTimestampMap(leftSnapshotPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to read left snapshot: %v\n", err)
} else if len(leftIDs) > 0 {
opts.ProtectLocalExportIDs = leftIDs
fmt.Fprintf(os.Stderr, "Protecting %d issue(s) from left snapshot\n", len(leftIDs))
} else if len(leftTimestamps) > 0 {
opts.ProtectLocalExportIDs = leftTimestamps
fmt.Fprintf(os.Stderr, "Protecting %d issue(s) from left snapshot (timestamp-aware)\n", len(leftTimestamps))
}
}
}

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"time"
"github.com/steveyegge/beads/internal/importer"
"github.com/steveyegge/beads/internal/storage"
@@ -165,7 +166,7 @@ type ImportOptions struct {
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
OrphanHandling string // Orphan handling mode: strict/resurrect/skip/allow (empty = use config)
ProtectLocalExportIDs map[string]bool // IDs from left snapshot to protect from git-history-backfill (bd-sync-deletion fix)
ProtectLocalExportIDs map[string]time.Time // IDs from left snapshot with timestamps for timestamp-aware protection (GH#865)
}
// ImportResult contains statistics about the import operation

View File

@@ -289,6 +289,48 @@ func (sm *SnapshotManager) BuildIDSet(path string) (map[string]bool, error) {
return sm.buildIDSet(path)
}
// BuildIDToTimestampMap reads a JSONL file and returns a map of issue ID to updated_at timestamp.
// This is used for timestamp-aware snapshot protection (GH#865): only protect local issues
// if they are newer than incoming remote versions.
func (sm *SnapshotManager) BuildIDToTimestampMap(path string) (map[string]time.Time, error) {
result := make(map[string]time.Time)
// #nosec G304 -- snapshot file path derived from internal state
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return result, nil // Empty map for missing files
}
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
// Parse ID and updated_at fields
var issue struct {
ID string `json:"id"`
UpdatedAt time.Time `json:"updated_at"`
}
if err := json.Unmarshal([]byte(line), &issue); err != nil {
return nil, fmt.Errorf("failed to parse issue from line: %w", err)
}
result[issue.ID] = issue.UpdatedAt
}
if err := scanner.Err(); err != nil {
return nil, err
}
return result, nil
}
// Private helper methods
func (sm *SnapshotManager) createMetadata() snapshotMetadata {