Implements configurable per-field merge strategies (hq-ew1mbr.11):
- Add FieldStrategy type with strategies: newest, max, union, manual
- Add conflict.fields config section for per-field overrides
- compaction_level defaults to "max" (highest value wins)
- estimated_minutes defaults to "manual" (flags for user resolution)
- labels defaults to "union" (set merge)
Manual conflicts are displayed during sync with resolution options:
bd sync --ours / --theirs, or bd resolve <id> <field> <value>
Config example:
conflict:
strategy: newest
fields:
compaction_level: max
estimated_minutes: manual
labels: union
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1608 lines
50 KiB
Go
1608 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// setupTestStore creates a test storage with issue_prefix configured
|
|
func setupTestStore(t *testing.T, dbPath string) *sqlite.SQLiteStorage {
|
|
t.Helper()
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
store.Close()
|
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
// TestDBNeedsExport_InSync verifies dbNeedsExport returns false when DB and JSONL are in sync
|
|
func TestDBNeedsExport_InSync(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "beads.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
store := setupTestStore(t, dbPath)
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create an issue in DB
|
|
issue := &types.Issue{
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
}
|
|
err := store.CreateIssue(ctx, issue, "test-user")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
// Export to JSONL
|
|
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
|
|
// Wait a moment to ensure DB mtime isn't newer
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Touch JSONL to make it newer than DB
|
|
now := time.Now()
|
|
if err := os.Chtimes(jsonlPath, now, now); err != nil {
|
|
t.Fatalf("Failed to touch JSONL: %v", err)
|
|
}
|
|
|
|
// DB and JSONL should be in sync
|
|
needsExport, err := dbNeedsExport(ctx, store, jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("dbNeedsExport failed: %v", err)
|
|
}
|
|
|
|
if needsExport {
|
|
t.Errorf("Expected needsExport=false (DB and JSONL in sync), got true")
|
|
}
|
|
}
|
|
|
|
// TestDBNeedsExport_DBNewer verifies dbNeedsExport returns true when DB is modified
|
|
func TestDBNeedsExport_DBNewer(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "beads.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
store := setupTestStore(t, dbPath)
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create and export issue
|
|
issue1 := &types.Issue{
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
}
|
|
err := store.CreateIssue(ctx, issue1, "test-user")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
|
|
// Wait and modify DB
|
|
time.Sleep(10 * time.Millisecond)
|
|
issue2 := &types.Issue{
|
|
Title: "Another Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
err = store.CreateIssue(ctx, issue2, "test-user")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create second issue: %v", err)
|
|
}
|
|
|
|
// DB is newer, should need export
|
|
needsExport, err := dbNeedsExport(ctx, store, jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("dbNeedsExport failed: %v", err)
|
|
}
|
|
|
|
if !needsExport {
|
|
t.Errorf("Expected needsExport=true (DB modified), got false")
|
|
}
|
|
}
|
|
|
|
// TestDBNeedsExport_CountMismatch verifies dbNeedsExport returns true when counts differ
|
|
func TestDBNeedsExport_CountMismatch(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "beads.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
store := setupTestStore(t, dbPath)
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create and export issue
|
|
issue1 := &types.Issue{
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
}
|
|
err := store.CreateIssue(ctx, issue1, "test-user")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
|
|
// Add another issue to DB but don't export
|
|
issue2 := &types.Issue{
|
|
Title: "Another Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
err = store.CreateIssue(ctx, issue2, "test-user")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create second issue: %v", err)
|
|
}
|
|
|
|
// Make JSONL appear newer (but counts differ)
|
|
time.Sleep(10 * time.Millisecond)
|
|
now := time.Now().Add(1 * time.Hour) // Way in the future
|
|
if err := os.Chtimes(jsonlPath, now, now); err != nil {
|
|
t.Fatalf("Failed to touch JSONL: %v", err)
|
|
}
|
|
|
|
// Counts mismatch, should need export
|
|
needsExport, err := dbNeedsExport(ctx, store, jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("dbNeedsExport failed: %v", err)
|
|
}
|
|
|
|
if !needsExport {
|
|
t.Errorf("Expected needsExport=true (count mismatch), got false")
|
|
}
|
|
}
|
|
|
|
// TestDBNeedsExport_NoJSONL verifies dbNeedsExport returns true when JSONL doesn't exist
|
|
func TestDBNeedsExport_NoJSONL(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, "beads.db")
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
|
|
store := setupTestStore(t, dbPath)
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create issue but don't export
|
|
issue := &types.Issue{
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeBug,
|
|
}
|
|
err := store.CreateIssue(ctx, issue, "test-user")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
// JSONL doesn't exist, should need export
|
|
needsExport, err := dbNeedsExport(ctx, store, jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("dbNeedsExport failed: %v", err)
|
|
}
|
|
|
|
if !needsExport {
|
|
t.Fatalf("Expected needsExport=true (JSONL missing), got false")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 3-Way Merge Tests (Phase 2)
|
|
// =============================================================================
|
|
|
|
// makeTestIssue creates a test issue with specified fields
|
|
func makeTestIssue(id, title string, status types.Status, priority int, updatedAt time.Time) *types.Issue {
|
|
return &types.Issue{
|
|
ID: id,
|
|
Title: title,
|
|
Status: status,
|
|
Priority: priority,
|
|
IssueType: types.TypeTask,
|
|
UpdatedAt: updatedAt,
|
|
CreatedAt: updatedAt.Add(-time.Hour), // Created 1 hour before update
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_NoBase_LocalOnly tests first sync with only local issue
|
|
func TestMergeIssue_NoBase_LocalOnly(t *testing.T) {
|
|
local := makeTestIssue("bd-1234", "Local Issue", types.StatusOpen, 1, time.Now())
|
|
|
|
merged, strategy, _ := MergeIssue(nil, local, nil)
|
|
|
|
if strategy != StrategyLocal {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.ID != "bd-1234" {
|
|
t.Errorf("Expected ID=bd-1234, got %s", merged.ID)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_NoBase_RemoteOnly tests first sync with only remote issue
|
|
func TestMergeIssue_NoBase_RemoteOnly(t *testing.T) {
|
|
remote := makeTestIssue("bd-5678", "Remote Issue", types.StatusOpen, 2, time.Now())
|
|
|
|
merged, strategy, _ := MergeIssue(nil, nil, remote)
|
|
|
|
if strategy != StrategyRemote {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.ID != "bd-5678" {
|
|
t.Errorf("Expected ID=bd-5678, got %s", merged.ID)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_NoBase_BothExist_LocalNewer tests first sync where both have same issue, local is newer
|
|
func TestMergeIssue_NoBase_BothExist_LocalNewer(t *testing.T) {
|
|
now := time.Now()
|
|
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now.Add(time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now)
|
|
|
|
merged, strategy, _ := MergeIssue(nil, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.Title != "Local Title" {
|
|
t.Errorf("Expected local title (newer), got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_NoBase_BothExist_RemoteNewer tests first sync where both have same issue, remote is newer
|
|
func TestMergeIssue_NoBase_BothExist_RemoteNewer(t *testing.T) {
|
|
now := time.Now()
|
|
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now)
|
|
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(nil, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.Title != "Remote Title" {
|
|
t.Errorf("Expected remote title (newer), got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_NoBase_BothExist_SameTime tests first sync where both have same timestamp (remote wins)
|
|
func TestMergeIssue_NoBase_BothExist_SameTime(t *testing.T) {
|
|
now := time.Now()
|
|
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now)
|
|
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now)
|
|
|
|
merged, strategy, _ := MergeIssue(nil, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
// Remote wins on tie (per design.md Decision 3)
|
|
if merged.Title != "Remote Title" {
|
|
t.Errorf("Expected remote title (tie goes to remote), got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_NoChanges tests 3-way merge with no changes anywhere
|
|
func TestMergeIssue_NoChanges(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
|
remote := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategySame {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_OnlyLocalChanged tests 3-way merge where only local changed
|
|
func TestMergeIssue_OnlyLocalChanged(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyLocal {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.Title != "Updated Title" {
|
|
t.Errorf("Expected updated title, got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_OnlyRemoteChanged tests 3-way merge where only remote changed
|
|
func TestMergeIssue_OnlyRemoteChanged(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
|
remote := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyRemote {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.Title != "Updated Title" {
|
|
t.Errorf("Expected updated title, got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_BothMadeSameChange tests 3-way merge where both made identical change
|
|
func TestMergeIssue_BothMadeSameChange(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategySame {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
if merged.Title != "Same Update" {
|
|
t.Errorf("Expected 'Same Update', got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_TrueConflict_LocalNewer tests true conflict where local is newer
|
|
func TestMergeIssue_TrueConflict_LocalNewer(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(2*time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
// Local is newer, should win
|
|
if merged.Title != "Local Update" {
|
|
t.Errorf("Expected local title (newer), got %s", merged.Title)
|
|
}
|
|
if merged.Status != types.StatusInProgress {
|
|
t.Errorf("Expected local status, got %s", merged.Status)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_TrueConflict_RemoteNewer tests true conflict where remote is newer
|
|
func TestMergeIssue_TrueConflict_RemoteNewer(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(2*time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
// Remote is newer, should win
|
|
if merged.Title != "Remote Update" {
|
|
t.Errorf("Expected remote title (newer), got %s", merged.Title)
|
|
}
|
|
if merged.Status != types.StatusClosed {
|
|
t.Errorf("Expected remote status, got %s", merged.Status)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_LocalDeleted_RemoteUnchanged tests local deletion when remote unchanged
|
|
func TestMergeIssue_LocalDeleted_RemoteUnchanged(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
|
remote := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
|
|
|
merged, strategy, _ := MergeIssue(base, nil, remote)
|
|
|
|
if strategy != StrategyLocal {
|
|
t.Errorf("Expected strategy=%s (honor local deletion), got %s", StrategyLocal, strategy)
|
|
}
|
|
if merged != nil {
|
|
t.Errorf("Expected nil (deleted), got issue %s", merged.ID)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_LocalDeleted_RemoteChanged tests local deletion but remote changed
|
|
func TestMergeIssue_LocalDeleted_RemoteChanged(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
remote := makeTestIssue("bd-1234", "Remote Updated", types.StatusClosed, 2, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, nil, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s (conflict: deleted vs updated), got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue (remote changed), got nil")
|
|
}
|
|
if merged.Title != "Remote Updated" {
|
|
t.Errorf("Expected remote title (changed wins over delete), got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_RemoteDeleted_LocalUnchanged tests remote deletion when local unchanged
|
|
func TestMergeIssue_RemoteDeleted_LocalUnchanged(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, nil)
|
|
|
|
if strategy != StrategyRemote {
|
|
t.Errorf("Expected strategy=%s (honor remote deletion), got %s", StrategyRemote, strategy)
|
|
}
|
|
if merged != nil {
|
|
t.Errorf("Expected nil (deleted), got issue %s", merged.ID)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_RemoteDeleted_LocalChanged tests remote deletion but local changed
|
|
func TestMergeIssue_RemoteDeleted_LocalChanged(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Updated", types.StatusClosed, 2, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, nil)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s (conflict: updated vs deleted), got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue (local changed), got nil")
|
|
}
|
|
if merged.Title != "Local Updated" {
|
|
t.Errorf("Expected local title (changed wins over delete), got %s", merged.Title)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssues_Empty tests merging empty sets
|
|
func TestMergeIssues_Empty(t *testing.T) {
|
|
result := MergeIssues(nil, nil, nil)
|
|
if len(result.Merged) != 0 {
|
|
t.Errorf("Expected 0 merged issues, got %d", len(result.Merged))
|
|
}
|
|
if result.Conflicts != 0 {
|
|
t.Errorf("Expected 0 conflicts, got %d", result.Conflicts)
|
|
}
|
|
}
|
|
|
|
// TestMergeIssues_MultipleIssues tests merging multiple issues with different scenarios
|
|
func TestMergeIssues_MultipleIssues(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
// Base state
|
|
base := []*types.Issue{
|
|
makeTestIssue("bd-0001", "Unchanged", types.StatusOpen, 1, now),
|
|
makeTestIssue("bd-0002", "Will change locally", types.StatusOpen, 1, now),
|
|
makeTestIssue("bd-0003", "Will change remotely", types.StatusOpen, 1, now),
|
|
makeTestIssue("bd-0004", "To delete locally", types.StatusOpen, 1, now),
|
|
}
|
|
|
|
// Local state
|
|
local := []*types.Issue{
|
|
makeTestIssue("bd-0001", "Unchanged", types.StatusOpen, 1, now),
|
|
makeTestIssue("bd-0002", "Changed locally", types.StatusInProgress, 1, now.Add(time.Hour)),
|
|
makeTestIssue("bd-0003", "Will change remotely", types.StatusOpen, 1, now),
|
|
// bd-0004 deleted locally
|
|
makeTestIssue("bd-0005", "New local issue", types.StatusOpen, 1, now), // New issue
|
|
}
|
|
|
|
// Remote state
|
|
remote := []*types.Issue{
|
|
makeTestIssue("bd-0001", "Unchanged", types.StatusOpen, 1, now),
|
|
makeTestIssue("bd-0002", "Will change locally", types.StatusOpen, 1, now),
|
|
makeTestIssue("bd-0003", "Changed remotely", types.StatusClosed, 2, now.Add(time.Hour)),
|
|
makeTestIssue("bd-0004", "To delete locally", types.StatusOpen, 1, now), // Unchanged from base
|
|
makeTestIssue("bd-0006", "New remote issue", types.StatusOpen, 1, now), // New issue
|
|
}
|
|
|
|
result := MergeIssues(base, local, remote)
|
|
|
|
// Should have 5 issues:
|
|
// - bd-0001: same
|
|
// - bd-0002: local changed
|
|
// - bd-0003: remote changed
|
|
// - bd-0004: deleted (not in merged)
|
|
// - bd-0005: new local
|
|
// - bd-0006: new remote
|
|
if len(result.Merged) != 5 {
|
|
t.Errorf("Expected 5 merged issues, got %d", len(result.Merged))
|
|
}
|
|
|
|
// Verify strategies
|
|
expectedStrategies := map[string]string{
|
|
"bd-0001": StrategySame,
|
|
"bd-0002": StrategyLocal,
|
|
"bd-0003": StrategyRemote,
|
|
"bd-0004": StrategyLocal, // Deleted locally
|
|
"bd-0005": StrategyLocal,
|
|
"bd-0006": StrategyRemote,
|
|
}
|
|
|
|
for id, expected := range expectedStrategies {
|
|
if got := result.Strategy[id]; got != expected {
|
|
t.Errorf("Issue %s: expected strategy=%s, got %s", id, expected, got)
|
|
}
|
|
}
|
|
|
|
// Verify bd-0004 is not in merged (deleted)
|
|
for _, issue := range result.Merged {
|
|
if issue.ID == "bd-0004" {
|
|
t.Errorf("bd-0004 should be deleted, but found in merged")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBaseState_LoadSave tests loading and saving base state
|
|
func TestBaseState_LoadSave(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
now := time.Now().Truncate(time.Second) // Truncate for JSON round-trip
|
|
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "bd-0001",
|
|
Title: "Test Issue 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
UpdatedAt: now,
|
|
CreatedAt: now.Add(-time.Hour),
|
|
},
|
|
{
|
|
ID: "bd-0002",
|
|
Title: "Test Issue 2",
|
|
Status: types.StatusClosed,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
UpdatedAt: now,
|
|
CreatedAt: now.Add(-time.Hour),
|
|
},
|
|
}
|
|
|
|
// Save base state
|
|
if err := saveBaseState(tmpDir, issues); err != nil {
|
|
t.Fatalf("saveBaseState failed: %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
baseStatePath := filepath.Join(tmpDir, syncBaseFileName)
|
|
if _, err := os.Stat(baseStatePath); os.IsNotExist(err) {
|
|
t.Fatalf("Base state file not created")
|
|
}
|
|
|
|
// Load base state
|
|
loaded, err := loadBaseState(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("loadBaseState failed: %v", err)
|
|
}
|
|
|
|
if len(loaded) != 2 {
|
|
t.Fatalf("Expected 2 issues, got %d", len(loaded))
|
|
}
|
|
|
|
// Verify issue content
|
|
if loaded[0].ID != "bd-0001" || loaded[0].Title != "Test Issue 1" {
|
|
t.Errorf("First issue mismatch: got ID=%s, Title=%s", loaded[0].ID, loaded[0].Title)
|
|
}
|
|
if loaded[1].ID != "bd-0002" || loaded[1].Title != "Test Issue 2" {
|
|
t.Errorf("Second issue mismatch: got ID=%s, Title=%s", loaded[1].ID, loaded[1].Title)
|
|
}
|
|
}
|
|
|
|
// TestBaseState_LoadMissing tests loading when no base state exists
|
|
func TestBaseState_LoadMissing(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
loaded, err := loadBaseState(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("loadBaseState failed: %v", err)
|
|
}
|
|
|
|
if loaded != nil {
|
|
t.Errorf("Expected nil for missing base state, got %d issues", len(loaded))
|
|
}
|
|
}
|
|
|
|
// TestBaseState_LoadMalformed tests loading sync_base.jsonl with malformed lines
|
|
func TestBaseState_LoadMalformed(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
baseStatePath := filepath.Join(tmpDir, syncBaseFileName)
|
|
|
|
// Create file with mix of valid and malformed lines
|
|
content := `{"id":"bd-0001","title":"Valid Issue","status":"open","priority":1,"issue_type":"task"}
|
|
not valid json at all
|
|
{"id":"bd-0002","title":"Another Valid","status":"closed","priority":2,"issue_type":"bug"}
|
|
{truncated json
|
|
{"id":"bd-0003","title":"Third Valid","status":"open","priority":3,"issue_type":"task"}
|
|
`
|
|
if err := os.WriteFile(baseStatePath, []byte(content), 0644); err != nil {
|
|
t.Fatalf("Failed to write test file: %v", err)
|
|
}
|
|
|
|
// Capture stderr to verify warning is produced
|
|
oldStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
loaded, err := loadBaseState(tmpDir)
|
|
|
|
// Restore stderr and read captured output
|
|
w.Close()
|
|
os.Stderr = oldStderr
|
|
var stderrBuf bytes.Buffer
|
|
stderrBuf.ReadFrom(r)
|
|
stderrOutput := stderrBuf.String()
|
|
|
|
if err != nil {
|
|
t.Fatalf("loadBaseState failed: %v", err)
|
|
}
|
|
|
|
// Should have loaded 3 valid issues, skipping 2 malformed lines
|
|
if len(loaded) != 3 {
|
|
t.Errorf("Expected 3 valid issues, got %d", len(loaded))
|
|
}
|
|
|
|
// Verify correct issues loaded
|
|
expectedIDs := []string{"bd-0001", "bd-0002", "bd-0003"}
|
|
for i, expected := range expectedIDs {
|
|
if i >= len(loaded) {
|
|
t.Errorf("Missing issue at index %d", i)
|
|
continue
|
|
}
|
|
if loaded[i].ID != expected {
|
|
t.Errorf("Issue %d: expected ID=%s, got %s", i, expected, loaded[i].ID)
|
|
}
|
|
}
|
|
|
|
// Verify warnings were produced for malformed lines (lines 2 and 4)
|
|
if !strings.Contains(stderrOutput, "line 2") {
|
|
t.Errorf("Expected warning for line 2, got: %s", stderrOutput)
|
|
}
|
|
if !strings.Contains(stderrOutput, "line 4") {
|
|
t.Errorf("Expected warning for line 4, got: %s", stderrOutput)
|
|
}
|
|
}
|
|
|
|
// TestIssueEqual tests the issueEqual helper function
|
|
func TestIssueEqual(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
a, b *types.Issue
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "both nil",
|
|
a: nil,
|
|
b: nil,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "a nil",
|
|
a: nil,
|
|
b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "b nil",
|
|
a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
b: nil,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "identical",
|
|
a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "different ID",
|
|
a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
b: makeTestIssue("bd-5678", "Test", types.StatusOpen, 1, now),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "different title",
|
|
a: makeTestIssue("bd-1234", "Test A", types.StatusOpen, 1, now),
|
|
b: makeTestIssue("bd-1234", "Test B", types.StatusOpen, 1, now),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "different status",
|
|
a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
b: makeTestIssue("bd-1234", "Test", types.StatusClosed, 1, now),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "different priority",
|
|
a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 2, now),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "different updated_at",
|
|
a: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now),
|
|
b: makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(time.Hour)),
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := issueEqual(tc.a, tc.b)
|
|
if result != tc.expected {
|
|
t.Errorf("issueEqual returned %v, expected %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Field-Level Merge Tests (Phase 3)
|
|
// =============================================================================
|
|
|
|
// makeTestIssueWithLabels creates a test issue with labels
|
|
func makeTestIssueWithLabels(id, title string, status types.Status, priority int, updatedAt time.Time, labels []string) *types.Issue {
|
|
issue := makeTestIssue(id, title, status, priority, updatedAt)
|
|
issue.Labels = labels
|
|
return issue
|
|
}
|
|
|
|
// TestFieldMerge_LWW_LocalNewer tests field-level merge where local is newer
|
|
func TestFieldMerge_LWW_LocalNewer(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(2*time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
// Local is newer, should have local's scalar values
|
|
if merged.Title != "Local Update" {
|
|
t.Errorf("Expected title='Local Update' (local is newer), got %s", merged.Title)
|
|
}
|
|
if merged.Status != types.StatusInProgress {
|
|
t.Errorf("Expected status=in_progress (local is newer), got %s", merged.Status)
|
|
}
|
|
if merged.Priority != 2 {
|
|
t.Errorf("Expected priority=2 (local is newer), got %d", merged.Priority)
|
|
}
|
|
}
|
|
|
|
// TestFieldMerge_LWW_RemoteNewer tests field-level merge where remote is newer
|
|
func TestFieldMerge_LWW_RemoteNewer(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(2*time.Hour))
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
// Remote is newer, should have remote's scalar values
|
|
if merged.Title != "Remote Update" {
|
|
t.Errorf("Expected title='Remote Update' (remote is newer), got %s", merged.Title)
|
|
}
|
|
if merged.Status != types.StatusClosed {
|
|
t.Errorf("Expected status=closed (remote is newer), got %s", merged.Status)
|
|
}
|
|
if merged.Priority != 3 {
|
|
t.Errorf("Expected priority=3 (remote is newer), got %d", merged.Priority)
|
|
}
|
|
}
|
|
|
|
// TestFieldMerge_LWW_SameTimestamp tests field-level merge where timestamps are equal (remote wins)
|
|
func TestFieldMerge_LWW_SameTimestamp(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now.Add(-time.Hour))
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now)
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now)
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
// Same timestamp: remote wins (per design.md Decision 3)
|
|
if merged.Title != "Remote Update" {
|
|
t.Errorf("Expected title='Remote Update' (remote wins on tie), got %s", merged.Title)
|
|
}
|
|
if merged.Status != types.StatusClosed {
|
|
t.Errorf("Expected status=closed (remote wins on tie), got %s", merged.Status)
|
|
}
|
|
}
|
|
|
|
// TestLabelUnion_BothAdd tests label union when both local and remote add different labels
|
|
func TestLabelUnion_BothAdd(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssueWithLabels("bd-1234", "Test", types.StatusOpen, 1, now, []string{"original"})
|
|
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"})
|
|
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original", "remote-added"})
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Labels should be union of both
|
|
expectedLabels := []string{"local-added", "original", "remote-added"}
|
|
if len(merged.Labels) != len(expectedLabels) {
|
|
t.Errorf("Expected %d labels, got %d: %v", len(expectedLabels), len(merged.Labels), merged.Labels)
|
|
}
|
|
for _, expected := range expectedLabels {
|
|
found := false
|
|
for _, actual := range merged.Labels {
|
|
if actual == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Expected label %q in merged labels %v", expected, merged.Labels)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLabelUnion_LocalOnly tests label union when only local adds labels
|
|
func TestLabelUnion_LocalOnly(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssueWithLabels("bd-1234", "Test", types.StatusOpen, 1, now, []string{"original"})
|
|
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"})
|
|
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"})
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Labels should include local-added even though remote is newer for scalars
|
|
expectedLabels := []string{"local-added", "original"}
|
|
if len(merged.Labels) != len(expectedLabels) {
|
|
t.Errorf("Expected %d labels, got %d: %v", len(expectedLabels), len(merged.Labels), merged.Labels)
|
|
}
|
|
}
|
|
|
|
// TestLabelUnion_RemoteOnly tests label union when only remote adds labels
|
|
func TestLabelUnion_RemoteOnly(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssueWithLabels("bd-1234", "Test", types.StatusOpen, 1, now, []string{"original"})
|
|
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"})
|
|
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "remote-added"})
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Labels should include remote-added even though local is newer for scalars
|
|
expectedLabels := []string{"original", "remote-added"}
|
|
if len(merged.Labels) != len(expectedLabels) {
|
|
t.Errorf("Expected %d labels, got %d: %v", len(expectedLabels), len(merged.Labels), merged.Labels)
|
|
}
|
|
}
|
|
|
|
// TestDependencyUnion tests dependency union when both add different dependencies
|
|
func TestDependencyUnion(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
localDep := &types.Dependency{
|
|
IssueID: "bd-1234",
|
|
DependsOnID: "bd-aaaa",
|
|
Type: types.DepBlocks,
|
|
CreatedAt: now,
|
|
}
|
|
remoteDep := &types.Dependency{
|
|
IssueID: "bd-1234",
|
|
DependsOnID: "bd-bbbb",
|
|
Type: types.DepBlocks,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Dependencies = []*types.Dependency{localDep}
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Dependencies = []*types.Dependency{remoteDep}
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Dependencies should be union of both
|
|
if len(merged.Dependencies) != 2 {
|
|
t.Errorf("Expected 2 dependencies, got %d", len(merged.Dependencies))
|
|
}
|
|
|
|
// Check both dependencies are present
|
|
foundAAA := false
|
|
foundBBB := false
|
|
for _, dep := range merged.Dependencies {
|
|
if dep.DependsOnID == "bd-aaaa" {
|
|
foundAAA = true
|
|
}
|
|
if dep.DependsOnID == "bd-bbbb" {
|
|
foundBBB = true
|
|
}
|
|
}
|
|
if !foundAAA {
|
|
t.Error("Expected dependency to bd-aaaa in merged")
|
|
}
|
|
if !foundBBB {
|
|
t.Error("Expected dependency to bd-bbbb in merged")
|
|
}
|
|
}
|
|
|
|
// TestCommentAppend tests comment append-merge with deduplication
|
|
func TestCommentAppend(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
// Common comment (should be deduplicated)
|
|
commonComment := &types.Comment{
|
|
ID: 1,
|
|
IssueID: "bd-1234",
|
|
Author: "user1",
|
|
Text: "Common comment",
|
|
CreatedAt: now.Add(-time.Hour),
|
|
}
|
|
localComment := &types.Comment{
|
|
ID: 2,
|
|
IssueID: "bd-1234",
|
|
Author: "user2",
|
|
Text: "Local comment",
|
|
CreatedAt: now,
|
|
}
|
|
remoteComment := &types.Comment{
|
|
ID: 3,
|
|
IssueID: "bd-1234",
|
|
Author: "user3",
|
|
Text: "Remote comment",
|
|
CreatedAt: now.Add(30 * time.Minute),
|
|
}
|
|
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(-2*time.Hour))
|
|
base.Comments = []*types.Comment{commonComment}
|
|
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Comments = []*types.Comment{commonComment, localComment}
|
|
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Comments = []*types.Comment{commonComment, remoteComment}
|
|
|
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
|
|
|
if strategy != StrategyMerged {
|
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
|
}
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Comments should be union (3 total: common, local, remote)
|
|
if len(merged.Comments) != 3 {
|
|
t.Errorf("Expected 3 comments, got %d", len(merged.Comments))
|
|
}
|
|
|
|
// Check comments are sorted chronologically
|
|
for i := 0; i < len(merged.Comments)-1; i++ {
|
|
if merged.Comments[i].CreatedAt.After(merged.Comments[i+1].CreatedAt) {
|
|
t.Errorf("Comments not sorted chronologically: %v after %v",
|
|
merged.Comments[i].CreatedAt, merged.Comments[i+1].CreatedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFieldMerge_EdgeCases tests edge cases in field-level merge
|
|
func TestFieldMerge_EdgeCases(t *testing.T) {
|
|
t.Run("nil_labels", func(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Labels = nil
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Labels = []string{"remote-label"}
|
|
|
|
merged, _, _ := MergeIssue(base, local, remote)
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Should have remote label (union of nil and ["remote-label"])
|
|
if len(merged.Labels) != 1 || merged.Labels[0] != "remote-label" {
|
|
t.Errorf("Expected ['remote-label'], got %v", merged.Labels)
|
|
}
|
|
})
|
|
|
|
t.Run("empty_labels", func(t *testing.T) {
|
|
now := time.Now()
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Labels = []string{}
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Labels = []string{"remote-label"}
|
|
|
|
merged, _, _ := MergeIssue(base, local, remote)
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Should have remote label (union of [] and ["remote-label"])
|
|
if len(merged.Labels) != 1 || merged.Labels[0] != "remote-label" {
|
|
t.Errorf("Expected ['remote-label'], got %v", merged.Labels)
|
|
}
|
|
})
|
|
|
|
t.Run("nil_dependencies", func(t *testing.T) {
|
|
now := time.Now()
|
|
dep := &types.Dependency{
|
|
IssueID: "bd-1234",
|
|
DependsOnID: "bd-dep",
|
|
Type: types.DepBlocks,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Dependencies = []*types.Dependency{dep}
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Dependencies = nil
|
|
|
|
merged, _, _ := MergeIssue(base, local, remote)
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Should have the dependency from local
|
|
if len(merged.Dependencies) != 1 {
|
|
t.Errorf("Expected 1 dependency, got %d", len(merged.Dependencies))
|
|
}
|
|
})
|
|
|
|
t.Run("nil_comments", func(t *testing.T) {
|
|
now := time.Now()
|
|
comment := &types.Comment{
|
|
ID: 1,
|
|
IssueID: "bd-1234",
|
|
Author: "user",
|
|
Text: "Test comment",
|
|
CreatedAt: now,
|
|
}
|
|
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(-time.Hour))
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Comments = nil
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Comments = []*types.Comment{comment}
|
|
|
|
merged, _, _ := MergeIssue(base, local, remote)
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Should have the comment from remote
|
|
if len(merged.Comments) != 1 {
|
|
t.Errorf("Expected 1 comment, got %d", len(merged.Comments))
|
|
}
|
|
})
|
|
|
|
t.Run("duplicate_dependencies_newer_wins", func(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
// Same dependency in both, but with different metadata/timestamps
|
|
localDep := &types.Dependency{
|
|
IssueID: "bd-1234",
|
|
DependsOnID: "bd-dep",
|
|
Type: types.DepBlocks,
|
|
CreatedAt: now,
|
|
CreatedBy: "local-user",
|
|
}
|
|
remoteDep := &types.Dependency{
|
|
IssueID: "bd-1234",
|
|
DependsOnID: "bd-dep",
|
|
Type: types.DepBlocks,
|
|
CreatedAt: now.Add(time.Hour), // Newer
|
|
CreatedBy: "remote-user",
|
|
}
|
|
|
|
base := makeTestIssue("bd-1234", "Test", types.StatusOpen, 1, now.Add(-time.Hour))
|
|
local := makeTestIssue("bd-1234", "Test Local", types.StatusInProgress, 1, now.Add(time.Hour))
|
|
local.Dependencies = []*types.Dependency{localDep}
|
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
|
remote.Dependencies = []*types.Dependency{remoteDep}
|
|
|
|
merged, _, _ := MergeIssue(base, local, remote)
|
|
if merged == nil {
|
|
t.Fatal("Expected merged issue, got nil")
|
|
}
|
|
|
|
// Should have only 1 dependency (deduplicated), the newer one
|
|
if len(merged.Dependencies) != 1 {
|
|
t.Errorf("Expected 1 dependency, got %d", len(merged.Dependencies))
|
|
}
|
|
if merged.Dependencies[0].CreatedBy != "remote-user" {
|
|
t.Errorf("Expected newer dependency (remote-user), got %s", merged.Dependencies[0].CreatedBy)
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// Clock Skew Detection Tests (Phase 2 - PR918)
|
|
// =============================================================================
|
|
|
|
// TestMergeClockSkewWarning tests that large timestamp differences produce a warning
|
|
func TestMergeClockSkewWarning(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
t.Run("no_warning_under_24h", func(t *testing.T) {
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(23*time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 1, now)
|
|
|
|
// Capture stderr
|
|
oldStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
_, _, _ = MergeIssue(base, local, remote)
|
|
|
|
w.Close()
|
|
os.Stderr = oldStderr
|
|
var stderrBuf bytes.Buffer
|
|
stderrBuf.ReadFrom(r)
|
|
stderrOutput := stderrBuf.String()
|
|
|
|
// Should NOT produce warning for <24h difference
|
|
if strings.Contains(stderrOutput, "clock skew") {
|
|
t.Errorf("Expected no warning for 23h difference, got: %s", stderrOutput)
|
|
}
|
|
})
|
|
|
|
t.Run("warning_over_24h_local_newer", func(t *testing.T) {
|
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(48*time.Hour))
|
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 1, now)
|
|
|
|
// Capture stderr
|
|
oldStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
_, _, _ = MergeIssue(base, local, remote)
|
|
|
|
w.Close()
|
|
os.Stderr = oldStderr
|
|
var stderrBuf bytes.Buffer
|
|
stderrBuf.ReadFrom(r)
|
|
stderrOutput := stderrBuf.String()
|
|
|
|
// Should produce warning for 48h difference
|
|
if !strings.Contains(stderrOutput, "clock skew") {
|
|
t.Errorf("Expected clock skew warning for 48h difference, got: %s", stderrOutput)
|
|
}
|
|
if !strings.Contains(stderrOutput, "bd-1234") {
|
|
t.Errorf("Warning should contain issue ID, got: %s", stderrOutput)
|
|
}
|
|
})
|
|
|
|
t.Run("warning_over_24h_remote_newer", func(t *testing.T) {
|
|
base := makeTestIssue("bd-5678", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-5678", "Local Update", types.StatusInProgress, 1, now)
|
|
remote := makeTestIssue("bd-5678", "Remote Update", types.StatusClosed, 1, now.Add(72*time.Hour))
|
|
|
|
// Capture stderr
|
|
oldStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
_, _, _ = MergeIssue(base, local, remote)
|
|
|
|
w.Close()
|
|
os.Stderr = oldStderr
|
|
var stderrBuf bytes.Buffer
|
|
stderrBuf.ReadFrom(r)
|
|
stderrOutput := stderrBuf.String()
|
|
|
|
// Should produce warning for 72h difference
|
|
if !strings.Contains(stderrOutput, "clock skew") {
|
|
t.Errorf("Expected clock skew warning for 72h difference, got: %s", stderrOutput)
|
|
}
|
|
if !strings.Contains(stderrOutput, "bd-5678") {
|
|
t.Errorf("Warning should contain issue ID, got: %s", stderrOutput)
|
|
}
|
|
})
|
|
|
|
t.Run("warning_exactly_24h", func(t *testing.T) {
|
|
base := makeTestIssue("bd-exact", "Original", types.StatusOpen, 1, now)
|
|
local := makeTestIssue("bd-exact", "Local Update", types.StatusInProgress, 1, now.Add(24*time.Hour))
|
|
remote := makeTestIssue("bd-exact", "Remote Update", types.StatusClosed, 1, now)
|
|
|
|
// Capture stderr
|
|
oldStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
_, _, _ = MergeIssue(base, local, remote)
|
|
|
|
w.Close()
|
|
os.Stderr = oldStderr
|
|
var stderrBuf bytes.Buffer
|
|
stderrBuf.ReadFrom(r)
|
|
stderrOutput := stderrBuf.String()
|
|
|
|
// Exactly 24h should NOT trigger warning (> not >=)
|
|
if strings.Contains(stderrOutput, "clock skew") {
|
|
t.Errorf("Expected no warning for exactly 24h difference, got: %s", stderrOutput)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergeLabels tests the mergeLabels helper function directly
|
|
func TestMergeLabels(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
local []string
|
|
remote []string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "both_empty",
|
|
local: nil,
|
|
remote: nil,
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "local_only",
|
|
local: []string{"a", "b"},
|
|
remote: nil,
|
|
expected: []string{"a", "b"},
|
|
},
|
|
{
|
|
name: "remote_only",
|
|
local: nil,
|
|
remote: []string{"x", "y"},
|
|
expected: []string{"x", "y"},
|
|
},
|
|
{
|
|
name: "no_overlap",
|
|
local: []string{"a", "b"},
|
|
remote: []string{"x", "y"},
|
|
expected: []string{"a", "b", "x", "y"},
|
|
},
|
|
{
|
|
name: "full_overlap",
|
|
local: []string{"a", "b"},
|
|
remote: []string{"a", "b"},
|
|
expected: []string{"a", "b"},
|
|
},
|
|
{
|
|
name: "partial_overlap",
|
|
local: []string{"a", "b", "c"},
|
|
remote: []string{"b", "c", "d"},
|
|
expected: []string{"a", "b", "c", "d"},
|
|
},
|
|
{
|
|
name: "duplicates_in_input",
|
|
local: []string{"a", "a", "b"},
|
|
remote: []string{"b", "b", "c"},
|
|
expected: []string{"a", "b", "c"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := mergeLabels(tc.local, tc.remote)
|
|
|
|
// Check length
|
|
if len(result) != len(tc.expected) {
|
|
t.Errorf("Expected %d labels, got %d: %v", len(tc.expected), len(result), result)
|
|
return
|
|
}
|
|
|
|
// Check contents (result is sorted, so direct comparison works)
|
|
for i, expected := range tc.expected {
|
|
if i >= len(result) || result[i] != expected {
|
|
t.Errorf("Expected %v, got %v", tc.expected, result)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMergeFieldLevel_CompactionLevel tests compaction_level uses max strategy by default
|
|
func TestMergeFieldLevel_CompactionLevel(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
t.Run("max_strategy_takes_higher_value", func(t *testing.T) {
|
|
// Local has higher compaction_level
|
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
|
local.CompactionLevel = 5
|
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
|
remote.CompactionLevel = 3
|
|
|
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
|
|
|
if merged.CompactionLevel != 5 {
|
|
t.Errorf("Expected compaction_level=5 (max), got %d", merged.CompactionLevel)
|
|
}
|
|
if len(manualConflicts) != 0 {
|
|
t.Errorf("Expected no manual conflicts, got %d", len(manualConflicts))
|
|
}
|
|
})
|
|
|
|
t.Run("max_strategy_takes_higher_from_remote", func(t *testing.T) {
|
|
// Remote has higher compaction_level
|
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
|
local.CompactionLevel = 2
|
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
|
remote.CompactionLevel = 7
|
|
|
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
|
|
|
if merged.CompactionLevel != 7 {
|
|
t.Errorf("Expected compaction_level=7 (max), got %d", merged.CompactionLevel)
|
|
}
|
|
if len(manualConflicts) != 0 {
|
|
t.Errorf("Expected no manual conflicts, got %d", len(manualConflicts))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergeFieldLevel_EstimatedMinutes tests estimated_minutes uses manual strategy by default
|
|
func TestMergeFieldLevel_EstimatedMinutes(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
t.Run("manual_strategy_flags_conflict_when_different", func(t *testing.T) {
|
|
// Default is manual strategy - should flag conflict when values differ
|
|
localMins := 120
|
|
remoteMins := 60
|
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
|
local.EstimatedMinutes = &localMins
|
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
|
remote.EstimatedMinutes = &remoteMins
|
|
|
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
|
|
|
// Should keep local value as tentative
|
|
if merged.EstimatedMinutes == nil || *merged.EstimatedMinutes != 120 {
|
|
t.Errorf("Expected estimated_minutes=120 (local as tentative), got %v", merged.EstimatedMinutes)
|
|
}
|
|
// Should flag for manual resolution
|
|
if len(manualConflicts) != 1 {
|
|
t.Errorf("Expected 1 manual conflict, got %d", len(manualConflicts))
|
|
} else {
|
|
mc := manualConflicts[0]
|
|
if mc.Field != "estimated_minutes" {
|
|
t.Errorf("Expected field=estimated_minutes, got %s", mc.Field)
|
|
}
|
|
if mc.LocalValue != 120 {
|
|
t.Errorf("Expected LocalValue=120, got %v", mc.LocalValue)
|
|
}
|
|
if mc.RemoteValue != 60 {
|
|
t.Errorf("Expected RemoteValue=60, got %v", mc.RemoteValue)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("manual_strategy_no_conflict_when_same", func(t *testing.T) {
|
|
// No conflict when values are the same
|
|
mins := 120
|
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
|
local.EstimatedMinutes = &mins
|
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
|
remoteMins := 120
|
|
remote.EstimatedMinutes = &remoteMins
|
|
|
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
|
|
|
if merged.EstimatedMinutes == nil || *merged.EstimatedMinutes != 120 {
|
|
t.Errorf("Expected estimated_minutes=120, got %v", merged.EstimatedMinutes)
|
|
}
|
|
if len(manualConflicts) != 0 {
|
|
t.Errorf("Expected no manual conflicts when values match, got %d", len(manualConflicts))
|
|
}
|
|
})
|
|
|
|
t.Run("manual_strategy_nil_vs_value_flags_conflict", func(t *testing.T) {
|
|
// Conflict when one is nil and other has value
|
|
remoteMins := 60
|
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
|
local.EstimatedMinutes = nil
|
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
|
remote.EstimatedMinutes = &remoteMins
|
|
|
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
|
|
|
// Should keep local value (nil) as tentative
|
|
if merged.EstimatedMinutes != nil {
|
|
t.Errorf("Expected estimated_minutes=nil (local as tentative), got %v", *merged.EstimatedMinutes)
|
|
}
|
|
// Should flag for manual resolution
|
|
if len(manualConflicts) != 1 {
|
|
t.Errorf("Expected 1 manual conflict, got %d", len(manualConflicts))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMaxInt tests the maxInt helper function
|
|
func TestMaxInt(t *testing.T) {
|
|
tests := []struct {
|
|
a, b, expected int
|
|
}{
|
|
{0, 0, 0},
|
|
{1, 0, 1},
|
|
{0, 1, 1},
|
|
{5, 3, 5},
|
|
{3, 5, 5},
|
|
{-1, -2, -1},
|
|
{-2, -1, -1},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
result := maxInt(tc.a, tc.b)
|
|
if result != tc.expected {
|
|
t.Errorf("maxInt(%d, %d) = %d, expected %d", tc.a, tc.b, result, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMaxIntPtr tests the maxIntPtr helper function
|
|
func TestMaxIntPtr(t *testing.T) {
|
|
five := 5
|
|
three := 3
|
|
|
|
t.Run("both_nil_returns_nil", func(t *testing.T) {
|
|
result := maxIntPtr(nil, nil)
|
|
if result != nil {
|
|
t.Errorf("Expected nil, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("left_nil_returns_right", func(t *testing.T) {
|
|
result := maxIntPtr(nil, &three)
|
|
if result == nil || *result != 3 {
|
|
t.Errorf("Expected 3, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("right_nil_returns_left", func(t *testing.T) {
|
|
result := maxIntPtr(&five, nil)
|
|
if result == nil || *result != 5 {
|
|
t.Errorf("Expected 5, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("returns_larger_value", func(t *testing.T) {
|
|
result := maxIntPtr(&three, &five)
|
|
if result == nil || *result != 5 {
|
|
t.Errorf("Expected 5, got %v", result)
|
|
}
|
|
|
|
result = maxIntPtr(&five, &three)
|
|
if result == nil || *result != 5 {
|
|
t.Errorf("Expected 5, got %v", result)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergeResult_ManualConflicts tests that ManualConflicts is properly initialized
|
|
func TestMergeResult_ManualConflicts(t *testing.T) {
|
|
now := time.Now()
|
|
base := []*types.Issue{
|
|
makeTestIssue("bd-1234", "Base", types.StatusOpen, 1, now),
|
|
}
|
|
local := []*types.Issue{
|
|
makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour)),
|
|
}
|
|
remote := []*types.Issue{
|
|
makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now.Add(2*time.Hour)),
|
|
}
|
|
|
|
result := MergeIssues(base, local, remote)
|
|
|
|
if result.ManualConflicts == nil {
|
|
t.Error("ManualConflicts should be initialized, got nil")
|
|
}
|
|
}
|