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

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

View File

@@ -205,6 +205,274 @@ func TestImportSameTimestamp(t *testing.T) {
}
}
// TestImportTimestampAwareProtection tests the GH#865 fix: timestamp-aware snapshot protection
// The ProtectLocalExportIDs map should only protect issues if the local snapshot version is newer.
func TestImportTimestampAwareProtection(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
now := time.Now()
// Create a local issue in the database
localIssue := &types.Issue{
ID: "bd-protect1",
Title: "Test Issue",
Description: "Local version",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: now.Add(-2 * time.Hour),
UpdatedAt: now.Add(-1 * time.Hour), // DB has old timestamp
}
localIssue.ContentHash = localIssue.ComputeContentHash()
if err := store.CreateIssue(ctx, localIssue, "test"); err != nil {
t.Fatalf("Failed to create local issue: %v", err)
}
t.Run("incoming newer than local snapshot - should update", func(t *testing.T) {
// Scenario: Remote closed the issue after we exported locally
// Local snapshot: issue open at T1 (10:00)
// Incoming: issue closed at T2 (11:30) - NEWER
// Expected: Update should proceed (remote is newer)
snapshotTime := now.Add(-30 * time.Minute) // Local snapshot at 10:00
incomingTime := now // Incoming at 11:30 (newer)
closedAt := incomingTime
incomingIssue := &types.Issue{
ID: "bd-protect1",
Title: "Test Issue",
Description: "Remote closed version",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: now.Add(-2 * time.Hour),
UpdatedAt: incomingTime,
ClosedAt: &closedAt,
}
incomingIssue.ContentHash = incomingIssue.ComputeContentHash()
// Protection map has the issue with the local snapshot timestamp
protectMap := map[string]time.Time{
"bd-protect1": snapshotTime,
}
result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{incomingIssue}, Options{
SkipPrefixValidation: true,
ProtectLocalExportIDs: protectMap,
})
if err != nil {
t.Fatalf("Import failed: %v", err)
}
// Incoming is newer than snapshot, so update should proceed
if result.Updated == 0 {
t.Errorf("Expected 1 update (incoming newer than snapshot), got 0")
}
if result.Skipped > 0 {
t.Errorf("Expected 0 skipped, got %d", result.Skipped)
}
// Verify the issue was updated to closed
dbIssue, err := store.GetIssue(ctx, "bd-protect1")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if dbIssue.Status != types.StatusClosed {
t.Errorf("Expected status=closed (remote version), got %s", dbIssue.Status)
}
})
t.Run("incoming older than local snapshot - should protect", func(t *testing.T) {
// Reset the issue for next test
resetIssue := &types.Issue{
ID: "bd-protect2",
Title: "Another Issue",
Description: "Local modified version",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: now.Add(-2 * time.Hour),
UpdatedAt: now, // Current timestamp
}
resetIssue.ContentHash = resetIssue.ComputeContentHash()
if err := store.CreateIssue(ctx, resetIssue, "test"); err != nil {
t.Fatalf("Failed to create reset issue: %v", err)
}
// Scenario: We modified locally, remote has older version
// Local snapshot: issue in_progress at T2 (11:30)
// Incoming: issue open at T1 (10:00) - OLDER
// Expected: Skip update (protect local changes)
snapshotTime := now // Local snapshot at 11:30
incomingTime := now.Add(-30 * time.Minute) // Incoming at 10:00 (older)
incomingIssue := &types.Issue{
ID: "bd-protect2",
Title: "Another Issue",
Description: "Old remote version",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: now.Add(-2 * time.Hour),
UpdatedAt: incomingTime,
}
incomingIssue.ContentHash = incomingIssue.ComputeContentHash()
// Protection map has the issue with the local snapshot timestamp
protectMap := map[string]time.Time{
"bd-protect2": snapshotTime,
}
result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{incomingIssue}, Options{
SkipPrefixValidation: true,
ProtectLocalExportIDs: protectMap,
})
if err != nil {
t.Fatalf("Import failed: %v", err)
}
// Incoming is older than snapshot, so update should be skipped (protected)
if result.Skipped == 0 {
t.Errorf("Expected 1 skipped (local snapshot newer), got 0")
}
if result.Updated > 0 {
t.Errorf("Expected 0 updates, got %d", result.Updated)
}
// Verify the issue was NOT updated
dbIssue, err := store.GetIssue(ctx, "bd-protect2")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if dbIssue.Status != types.StatusInProgress {
t.Errorf("Expected status=in_progress (protected local), got %s", dbIssue.Status)
}
})
t.Run("issue not in protection map - normal behavior", func(t *testing.T) {
// Create an issue not in the protection map
unprotectedIssue := &types.Issue{
ID: "bd-unprotected",
Title: "Unprotected Issue",
Description: "Original",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: now.Add(-2 * time.Hour),
UpdatedAt: now.Add(-1 * time.Hour),
}
unprotectedIssue.ContentHash = unprotectedIssue.ComputeContentHash()
if err := store.CreateIssue(ctx, unprotectedIssue, "test"); err != nil {
t.Fatalf("Failed to create unprotected issue: %v", err)
}
// Incoming version is newer
closedAt := now
incomingIssue := &types.Issue{
ID: "bd-unprotected",
Title: "Unprotected Issue",
Description: "Updated",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: now.Add(-2 * time.Hour),
UpdatedAt: now, // Newer than DB
ClosedAt: &closedAt,
}
incomingIssue.ContentHash = incomingIssue.ComputeContentHash()
// Protection map does NOT contain this issue
protectMap := map[string]time.Time{
"bd-other": now, // Different issue
}
result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{incomingIssue}, Options{
SkipPrefixValidation: true,
ProtectLocalExportIDs: protectMap,
})
if err != nil {
t.Fatalf("Import failed: %v", err)
}
// Issue not in protection map, incoming is newer - should update
if result.Updated == 0 {
t.Errorf("Expected 1 update (not in protection map), got 0")
}
dbIssue, err := store.GetIssue(ctx, "bd-unprotected")
if err != nil {
t.Fatalf("Failed to get issue: %v", err)
}
if dbIssue.Status != types.StatusClosed {
t.Errorf("Expected status=closed (updated), got %s", dbIssue.Status)
}
})
}
// TestShouldProtectFromUpdate tests the helper function directly
func TestShouldProtectFromUpdate(t *testing.T) {
now := time.Now()
t.Run("nil map - no protection", func(t *testing.T) {
if shouldProtectFromUpdate("bd-123", now, nil) {
t.Error("Expected no protection with nil map")
}
})
t.Run("issue not in map - no protection", func(t *testing.T) {
protectMap := map[string]time.Time{
"bd-other": now,
}
if shouldProtectFromUpdate("bd-123", now, protectMap) {
t.Error("Expected no protection when issue not in map")
}
})
t.Run("incoming newer than local - no protection", func(t *testing.T) {
localTime := now.Add(-1 * time.Hour)
protectMap := map[string]time.Time{
"bd-123": localTime,
}
if shouldProtectFromUpdate("bd-123", now, protectMap) {
t.Error("Expected no protection when incoming is newer")
}
})
t.Run("incoming same as local - protect", func(t *testing.T) {
protectMap := map[string]time.Time{
"bd-123": now,
}
if !shouldProtectFromUpdate("bd-123", now, protectMap) {
t.Error("Expected protection when timestamps are equal")
}
})
t.Run("incoming older than local - protect", func(t *testing.T) {
localTime := now.Add(1 * time.Hour) // Local is newer
protectMap := map[string]time.Time{
"bd-123": localTime,
}
if !shouldProtectFromUpdate("bd-123", now, protectMap) {
t.Error("Expected protection when local is newer")
}
})
}
func TestMain(m *testing.M) {
// Ensure test DB files are cleaned up
code := m.Run()