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

@@ -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 {