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:
@@ -39,7 +39,7 @@ type Options struct {
|
||||
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
|
||||
OrphanHandling OrphanHandling // How to handle missing parent issues (default: allow)
|
||||
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
|
||||
ProtectLocalExportIDs map[string]bool // IDs from left snapshot to protect from deletion
|
||||
ProtectLocalExportIDs map[string]time.Time // IDs from left snapshot with timestamps for timestamp-aware protection (GH#865)
|
||||
}
|
||||
|
||||
// Result contains statistics about the import operation
|
||||
@@ -565,6 +565,12 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
if existing, found := dbByExternalRef[*incoming.ExternalRef]; found {
|
||||
// Found match by external_ref - update the existing issue
|
||||
if !opts.SkipUpdate {
|
||||
// GH#865: Check timestamp-aware protection first
|
||||
// If local snapshot has a newer version, protect it from being overwritten
|
||||
if shouldProtectFromUpdate(existing.ID, incoming.UpdatedAt, opts.ProtectLocalExportIDs) {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
// Check timestamps - only update if incoming is newer
|
||||
if !incoming.UpdatedAt.After(existing.UpdatedAt) {
|
||||
// Local version is newer or same - skip update
|
||||
@@ -663,6 +669,12 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
// The update should have been detected earlier by detectUpdates
|
||||
// If we reach here, it means collision wasn't resolved - treat as update
|
||||
if !opts.SkipUpdate {
|
||||
// GH#865: Check timestamp-aware protection first
|
||||
// If local snapshot has a newer version, protect it from being overwritten
|
||||
if shouldProtectFromUpdate(incoming.ID, incoming.UpdatedAt, opts.ProtectLocalExportIDs) {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
// Check timestamps - only update if incoming is newer
|
||||
if !incoming.UpdatedAt.After(existingWithID.UpdatedAt) {
|
||||
// Local version is newer or same - skip update
|
||||
@@ -919,6 +931,23 @@ func importComments(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issu
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldProtectFromUpdate checks if an update should be skipped due to timestamp-aware protection (GH#865).
|
||||
// Returns true if the update should be skipped (local is newer), false if the update should proceed.
|
||||
// If the issue is not in the protection map, returns false (allow update).
|
||||
func shouldProtectFromUpdate(issueID string, incomingTime time.Time, protectMap map[string]time.Time) bool {
|
||||
if protectMap == nil {
|
||||
return false
|
||||
}
|
||||
localTime, exists := protectMap[issueID]
|
||||
if !exists {
|
||||
// Issue not in protection map - allow update
|
||||
return false
|
||||
}
|
||||
// Only protect if local snapshot is newer than or equal to incoming
|
||||
// If incoming is newer, allow the update
|
||||
return !incomingTime.After(localTime)
|
||||
}
|
||||
|
||||
func GetPrefixList(prefixes map[string]int) []string {
|
||||
var result []string
|
||||
keys := make([]string, 0, len(prefixes))
|
||||
|
||||
Reference in New Issue
Block a user