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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user