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>
481 lines
14 KiB
Go
481 lines
14 KiB
Go
package importer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestImportTimestampPrecedence verifies that imports respect updated_at timestamps (bd-e55c)
|
|
// When importing an issue with the same ID but different content, the newer version should win.
|
|
func TestImportTimestampPrecedence(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
|
|
// Initialize storage
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set up database with prefix
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
// Create an issue locally at time T1
|
|
now := time.Now()
|
|
closedAt := now
|
|
localIssue := &types.Issue{
|
|
ID: "bd-test123",
|
|
Title: "Test Issue",
|
|
Description: "Local version",
|
|
Status: types.StatusClosed,
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: now.Add(-2 * time.Hour),
|
|
UpdatedAt: now, // Newer timestamp
|
|
ClosedAt: &closedAt,
|
|
}
|
|
localIssue.ContentHash = localIssue.ComputeContentHash()
|
|
|
|
if err := store.CreateIssue(ctx, localIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create local issue: %v", err)
|
|
}
|
|
|
|
// Simulate importing an older version from remote (e.g., from git pull)
|
|
// This represents the scenario in bd-e55c where remote has status=open from yesterday
|
|
olderRemoteIssue := &types.Issue{
|
|
ID: "bd-test123", // Same ID
|
|
Title: "Test Issue",
|
|
Description: "Remote version",
|
|
Status: types.StatusOpen, // Different status
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: now.Add(-2 * time.Hour),
|
|
UpdatedAt: now.Add(-1 * time.Hour), // Older timestamp
|
|
}
|
|
olderRemoteIssue.ContentHash = olderRemoteIssue.ComputeContentHash()
|
|
|
|
// Import the older remote version
|
|
result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{olderRemoteIssue}, Options{
|
|
SkipPrefixValidation: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
// Verify that the import did NOT update the local version
|
|
// The local version is newer, so it should be preserved
|
|
if result.Updated > 0 {
|
|
t.Errorf("Expected 0 updates, got %d - older remote should not overwrite newer local", result.Updated)
|
|
}
|
|
if result.Unchanged == 0 {
|
|
t.Errorf("Expected unchanged count > 0, got %d", result.Unchanged)
|
|
}
|
|
|
|
// Verify the database still has the local (newer) version
|
|
dbIssue, err := store.GetIssue(ctx, "bd-test123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
|
|
if dbIssue.Status != types.StatusClosed {
|
|
t.Errorf("Expected status=closed (local version), got status=%s", dbIssue.Status)
|
|
}
|
|
if dbIssue.Description != "Local version" {
|
|
t.Errorf("Expected description='Local version', got '%s'", dbIssue.Description)
|
|
}
|
|
|
|
// Now test the reverse: importing a NEWER version should update
|
|
newerRemoteIssue := &types.Issue{
|
|
ID: "bd-test123",
|
|
Title: "Test Issue",
|
|
Description: "Even newer remote version",
|
|
Status: types.StatusOpen,
|
|
Priority: 2, // Changed priority too
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: now.Add(-2 * time.Hour),
|
|
UpdatedAt: now.Add(1 * time.Hour), // Newer than current DB
|
|
}
|
|
newerRemoteIssue.ContentHash = newerRemoteIssue.ComputeContentHash()
|
|
|
|
result2, err := ImportIssues(ctx, dbPath, store, []*types.Issue{newerRemoteIssue}, Options{
|
|
SkipPrefixValidation: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Import of newer version failed: %v", err)
|
|
}
|
|
|
|
if result2.Updated == 0 {
|
|
t.Errorf("Expected 1 update, got 0 - newer remote should overwrite older local")
|
|
}
|
|
|
|
// Verify the database now has the newer remote version
|
|
dbIssue2, err := store.GetIssue(ctx, "bd-test123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue after second import: %v", err)
|
|
}
|
|
|
|
if dbIssue2.Priority != 2 {
|
|
t.Errorf("Expected priority=2 (newer remote), got %d", dbIssue2.Priority)
|
|
}
|
|
if dbIssue2.Description != "Even newer remote version" {
|
|
t.Errorf("Expected description='Even newer remote version', got '%s'", dbIssue2.Description)
|
|
}
|
|
}
|
|
|
|
// TestImportSameTimestamp tests behavior when timestamps are equal
|
|
func TestImportSameTimestamp(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 local issue
|
|
localIssue := &types.Issue{
|
|
ID: "bd-test456",
|
|
Title: "Test Issue",
|
|
Description: "Local version",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
localIssue.ContentHash = localIssue.ComputeContentHash()
|
|
|
|
if err := store.CreateIssue(ctx, localIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create local issue: %v", err)
|
|
}
|
|
|
|
// Import with SAME timestamp but different content
|
|
remoteIssue := &types.Issue{
|
|
ID: "bd-test456",
|
|
Title: "Test Issue",
|
|
Description: "Remote version",
|
|
Status: types.StatusInProgress,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: now,
|
|
UpdatedAt: now, // Same timestamp
|
|
}
|
|
remoteIssue.ContentHash = remoteIssue.ComputeContentHash()
|
|
|
|
result, err := ImportIssues(ctx, dbPath, store, []*types.Issue{remoteIssue}, Options{
|
|
SkipPrefixValidation: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
// With equal timestamps, we should NOT update (local wins)
|
|
if result.Updated > 0 {
|
|
t.Errorf("Expected 0 updates with equal timestamps, got %d", result.Updated)
|
|
}
|
|
|
|
// Verify local version is preserved
|
|
dbIssue, err := store.GetIssue(ctx, "bd-test456")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
|
|
if dbIssue.Description != "Local version" {
|
|
t.Errorf("Expected local version to be preserved, got '%s'", dbIssue.Description)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
os.Exit(code)
|
|
}
|