* feat(sync): implement pull-first synchronization strategy - Add --pull-first flag and logic to sync command - Introduce 3-way merge stub for issue synchronization - Add concurrent edit tests for the pull-first flow Ensures local changes are reconciled with remote updates before pushing to prevent data loss. * feat(sync): implement 3-way merge and state tracking - Implement 3-way merge algorithm for issue synchronization - Add base state storage to track changes between syncs - Add comprehensive tests for merge logic and persistence Ensures data consistency and prevents data loss during concurrent issue updates. * feat(sync): implement field-level conflict merging - Implement field-level merge logic for issue conflicts - Add unit tests for field-level merge strategies Reduces manual intervention by automatically resolving overlapping updates at the field level. * refactor(sync): simplify sync flow by removing ZFC checks The previous sync implementation relied on Zero-False-Convergence (ZFC) staleness checks which are redundant following the transition to structural 3-way merging. This legacy logic added complexity and maintenance overhead without providing additional safety. This commit introduces a streamlined sync pipeline: - Remove ZFC staleness validation from primary sync flow - Update safety documentation to reflect current merge strategy - Eliminate deprecated unit tests associated with ZFC logic These changes reduce codebase complexity while maintaining data integrity through the robust structural 3-way merge implementation. * feat(sync): default to pull-first sync workflow - Set pull-first as the primary synchronization workflow - Refactor core sync logic for better maintainability - Update concurrent edit tests to validate 3-way merge logic Reduces merge conflicts by ensuring local state is current before pushing changes. * refactor(sync): clean up lint issues in merge code - Remove unused error return from MergeIssues (never returned error) - Use _ prefix for unused _base parameter in mergeFieldLevel - Update callers to not expect error from MergeIssues - Keep nolint:gosec for trusted internal file path * test(sync): add mode compatibility and upgrade safety tests Add tests addressing Steve's PR #918 review concerns: - TestSyncBranchModeWithPullFirst: Verifies sync-branch config storage and git branch creation work with pull-first - TestExternalBeadsDirWithPullFirst: Verifies external BEADS_DIR detection and pullFromExternalBeadsRepo - TestUpgradeFromOldSync: Validates upgrade safety when sync_base.jsonl doesn't exist (first sync after upgrade) - TestMergeIssuesWithBaseState: Comprehensive 3-way merge cases - TestLabelUnionMerge: Verifies labels use union (no data loss) Key upgrade behavior validated: - base=nil (no sync_base.jsonl) safely handles all cases - Local-only issues kept (StrategyLocal) - Remote-only issues kept (StrategyRemote) - Overlapping issues merged (LWW scalars, union labels) * fix(sync): report line numbers for malformed JSON Problem: - JSON decoding errors when loading sync base state lacked line numbers - Difficult to identify location of syntax errors in large state files Solution: - Include line number reporting in JSON decoder errors during state loading - Add regression tests for malformed sync base file scenarios Impact: - Users receive actionable feedback for corrupted state files - Faster troubleshooting of manual configuration errors * fix(sync): warn on large clock skew during sync Problem: - Unsynchronized clocks between systems could lead to silent merge errors - No mechanism existed to alert users of significant timestamp drift Solution: - Implement clock skew detection during sync merge - Log a warning when large timestamp differences are found - Add comprehensive unit tests for skew reporting Impact: - Users are alerted to potential synchronization risks - Easier debugging of time-related merge issues * fix(sync): defer state update until remote push succeeds Problem: - Base state updated before confirming remote push completion - Failed pushes resulted in inconsistent local state tracking Solution: - Defer base state update until after the remote push succeeds Impact: - Ensures local state accurately reflects remote repository status - Prevents state desynchronization during network or push failures * fix(sync): prevent concurrent sync operations Problem: - Multiple sync processes could run simultaneously - Overlapping operations risk data corruption and race conditions Solution: - Implement file-based locking using gofrs/flock - Add integration tests to verify locking behavior Impact: - Guarantees execution of a single sync process at a time - Eliminates potential for data inconsistency during sync * docs: document sync architecture and merge model - Detail the 3-way merge model logic - Describe the core synchronization architecture principles * fix(lint): explicitly ignore lock.Unlock return value errcheck linter flagged bare defer lock.Unlock() calls. Wrap in anonymous function with explicit _ assignment to acknowledge intentional ignore of unlock errors during cleanup. * fix(lint): add sync_merge.go to G304 exclusions The loadBaseState and saveBaseState functions use file paths derived from trusted internal sources (beadsDir parameter from config). Add to existing G304 exclusion list for safe JSONL file operations. * feat(sync): integrate sync-branch into pull-first flow When sync.branch is configured, doPullFirstSync now: - Calls PullFromSyncBranch before merge - Calls CommitToSyncBranch after export This ensures sync-branch mode uses the correct branch for pull/push operations. * test(sync): add E2E tests for sync-branch and external BEADS_DIR Adds comprehensive end-to-end tests: - TestSyncBranchE2E: verifies pull→merge→commit flow with remote changes - TestExternalBeadsDirE2E: verifies sync with separate beads repository - TestExternalBeadsDirDetection: edge cases for repo detection - TestCommitToExternalBeadsRepo: commit handling * refactor(sync): remove unused rollbackJSONLFromGit Function was defined but never called. Pull-first flow saves base state after successful push, making this safety net unnecessary. * test(sync): add export-only mode E2E test Add TestExportOnlySync to cover --no-pull flag which was the only untested sync mode. This completes full mode coverage: - Normal (pull-first): sync_test.go, sync_merge_test.go - Sync-branch: sync_modes_test.go:TestSyncBranchE2E (PR#918) - External BEADS_DIR: sync_external_test.go (PR#918) - From-main: sync_branch_priority_test.go - Local-only: sync_local_only_test.go - Export-only: sync_modes_test.go:TestExportOnlySync (this commit) Refs: #911 * docs(sync): add sync modes reference section Document all 6 sync modes with triggers, flows, and use cases. Include mode selection decision tree and test coverage matrix. Co-authored-by: Claude <noreply@anthropic.com> * test(sync): upgrade sync-branch E2E tests to bare repo - Replace mocked repository with real bare repo setup - Implement multi-machine simulation in sync tests - Refactor test logic to handle distributed states Coverage: sync-branch end-to-end scenarios * test(sync): add daemon sync-branch E2E tests - Implement E2E tests for daemon sync-branch flow - Add test cases for force-overwrite scenarios Coverage: daemon sync-branch workflow in cmd/bd * docs(sync): document sync-branch paths and E2E architecture - Describe sync-branch CLI and Daemon execution flow - Document the end-to-end test architecture * build(nix): update vendorHash for gofrs/flock dependency New dependency added for file-based sync locking changes the Go module checksum. --------- Co-authored-by: Claude <noreply@anthropic.com>
941 lines
28 KiB
Go
941 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofrs/flock"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestIsGitRepo_InGitRepo(t *testing.T) {
|
|
// This test assumes we're running in the beads git repo
|
|
if !isGitRepo() {
|
|
t.Skip("not in a git repository")
|
|
}
|
|
}
|
|
|
|
func TestIsGitRepo_NotInGitRepo(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
if isGitRepo() {
|
|
t.Error("expected false when not in git repo")
|
|
}
|
|
}
|
|
|
|
func TestGitHasUpstream_NoUpstream(t *testing.T) {
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Should not have upstream
|
|
if gitHasUpstream() {
|
|
t.Error("expected false when no upstream configured")
|
|
}
|
|
}
|
|
|
|
func TestGitHasChanges_NoFile(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Check - should have no changes (test.txt was committed by setupGitRepo)
|
|
hasChanges, err := gitHasChanges(ctx, "test.txt")
|
|
if err != nil {
|
|
t.Fatalf("gitHasChanges() error = %v", err)
|
|
}
|
|
if hasChanges {
|
|
t.Error("expected no changes for committed file")
|
|
}
|
|
}
|
|
|
|
func TestGitHasChanges_ModifiedFile(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Modify the file
|
|
testFile := filepath.Join(tmpDir, "test.txt")
|
|
os.WriteFile(testFile, []byte("modified"), 0644)
|
|
|
|
// Check - should have changes
|
|
hasChanges, err := gitHasChanges(ctx, "test.txt")
|
|
if err != nil {
|
|
t.Fatalf("gitHasChanges() error = %v", err)
|
|
}
|
|
if !hasChanges {
|
|
t.Error("expected changes for modified file")
|
|
}
|
|
}
|
|
|
|
func TestGitHasUnmergedPaths_CleanRepo(t *testing.T) {
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Should not have unmerged paths
|
|
hasUnmerged, err := gitHasUnmergedPaths()
|
|
if err != nil {
|
|
t.Fatalf("gitHasUnmergedPaths() error = %v", err)
|
|
}
|
|
if hasUnmerged {
|
|
t.Error("expected no unmerged paths in clean repo")
|
|
}
|
|
}
|
|
|
|
func TestGitCommit_Success(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Create a new file
|
|
testFile := "new.txt"
|
|
os.WriteFile(testFile, []byte("content"), 0644)
|
|
|
|
// Commit the file
|
|
err := gitCommit(ctx, testFile, "test commit")
|
|
if err != nil {
|
|
t.Fatalf("gitCommit() error = %v", err)
|
|
}
|
|
|
|
// Verify file is committed
|
|
hasChanges, err := gitHasChanges(ctx, testFile)
|
|
if err != nil {
|
|
t.Fatalf("gitHasChanges() error = %v", err)
|
|
}
|
|
if hasChanges {
|
|
t.Error("expected no changes after commit")
|
|
}
|
|
}
|
|
|
|
func TestGitCommit_AutoMessage(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Create a new file
|
|
testFile := "new.txt"
|
|
os.WriteFile(testFile, []byte("content"), 0644)
|
|
|
|
// Commit with auto-generated message (empty string)
|
|
err := gitCommit(ctx, testFile, "")
|
|
if err != nil {
|
|
t.Fatalf("gitCommit() error = %v", err)
|
|
}
|
|
|
|
// Verify it committed (message generation worked)
|
|
cmd := exec.Command("git", "log", "-1", "--pretty=%B")
|
|
output, _ := cmd.Output()
|
|
if len(output) == 0 {
|
|
t.Error("expected commit message to be generated")
|
|
}
|
|
}
|
|
|
|
func TestCountIssuesInJSONL_NonExistent(t *testing.T) {
|
|
t.Parallel()
|
|
count, err := countIssuesInJSONL("/nonexistent/path.jsonl")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent file")
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("count = %d, want 0 on error", count)
|
|
}
|
|
}
|
|
|
|
func TestCountIssuesInJSONL_EmptyFile(t *testing.T) {
|
|
t.Parallel()
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "empty.jsonl")
|
|
os.WriteFile(jsonlPath, []byte(""), 0644)
|
|
|
|
count, err := countIssuesInJSONL(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("count = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
func TestCountIssuesInJSONL_MultipleIssues(t *testing.T) {
|
|
t.Parallel()
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
|
content := `{"id":"bd-1"}
|
|
{"id":"bd-2"}
|
|
{"id":"bd-3"}
|
|
`
|
|
os.WriteFile(jsonlPath, []byte(content), 0644)
|
|
|
|
count, err := countIssuesInJSONL(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if count != 3 {
|
|
t.Errorf("count = %d, want 3", count)
|
|
}
|
|
}
|
|
|
|
func TestCountIssuesInJSONL_WithMalformedLines(t *testing.T) {
|
|
t.Parallel()
|
|
tmpDir := t.TempDir()
|
|
jsonlPath := filepath.Join(tmpDir, "mixed.jsonl")
|
|
content := `{"id":"bd-1"}
|
|
not valid json
|
|
{"id":"bd-2"}
|
|
{"id":"bd-3"}
|
|
`
|
|
os.WriteFile(jsonlPath, []byte(content), 0644)
|
|
|
|
count, err := countIssuesInJSONL(jsonlPath)
|
|
// countIssuesInJSONL returns error on malformed JSON
|
|
if err == nil {
|
|
t.Error("expected error for malformed JSON")
|
|
}
|
|
// Should have counted the first valid issue before hitting error
|
|
if count != 1 {
|
|
t.Errorf("count = %d, want 1 (before malformed line)", count)
|
|
}
|
|
}
|
|
|
|
func TestGetCurrentBranch(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Get current branch
|
|
branch, err := getCurrentBranch(ctx)
|
|
if err != nil {
|
|
t.Fatalf("getCurrentBranch() error = %v", err)
|
|
}
|
|
|
|
// Default branch is usually main or master
|
|
if branch != "main" && branch != "master" {
|
|
t.Logf("got branch %s (expected main or master, but this can vary)", branch)
|
|
}
|
|
}
|
|
|
|
func TestMergeSyncBranch_NoSyncBranchConfigured(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Try to merge without sync.branch configured (or database)
|
|
err := mergeSyncBranch(ctx, false)
|
|
if err == nil {
|
|
t.Error("expected error when sync.branch not configured")
|
|
}
|
|
// Error could be about missing database or missing sync.branch config
|
|
if err != nil && !strings.Contains(err.Error(), "sync.branch") && !strings.Contains(err.Error(), "database") {
|
|
t.Errorf("expected error about sync.branch or database, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMergeSyncBranch_OnSyncBranch(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Create sync branch
|
|
exec.Command("git", "checkout", "-b", "beads-metadata").Run()
|
|
|
|
// Initialize bd database and set sync.branch
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
os.MkdirAll(beadsDir, 0755)
|
|
|
|
// This test will fail with store access issues, so we just verify the branch check
|
|
// The actual merge functionality is tested in integration tests
|
|
currentBranch, _ := getCurrentBranch(ctx)
|
|
if currentBranch != "beads-metadata" {
|
|
t.Skipf("test setup failed, current branch is %s", currentBranch)
|
|
}
|
|
}
|
|
|
|
func TestMergeSyncBranch_DirtyWorkingTree(t *testing.T) {
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Create uncommitted changes
|
|
os.WriteFile("test.txt", []byte("modified"), 0644)
|
|
|
|
// This test verifies the dirty working tree check would work
|
|
// (We can't test the full merge without database setup)
|
|
statusCmd := exec.Command("git", "status", "--porcelain")
|
|
output, _ := statusCmd.Output()
|
|
if len(output) == 0 {
|
|
t.Error("expected dirty working tree for test setup")
|
|
}
|
|
}
|
|
|
|
func TestGetSyncBranch_EnvOverridesDB(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Save and restore global store state
|
|
oldStore := store
|
|
storeMutex.Lock()
|
|
oldStoreActive := storeActive
|
|
storeMutex.Unlock()
|
|
oldDBPath := dbPath
|
|
|
|
// Use an in-memory SQLite store for testing
|
|
testStore, err := sqlite.New(context.Background(), "file::memory:?mode=memory&cache=private")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test store: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
// Seed DB config and globals
|
|
if err := testStore.SetConfig(ctx, "sync.branch", "db-branch"); err != nil {
|
|
t.Fatalf("failed to set sync.branch in db: %v", err)
|
|
}
|
|
|
|
storeMutex.Lock()
|
|
store = testStore
|
|
storeActive = true
|
|
storeMutex.Unlock()
|
|
dbPath = "" // avoid FindDatabasePath in ensureStoreActive
|
|
|
|
// Set environment override
|
|
if err := os.Setenv(syncbranch.EnvVar, "env-branch"); err != nil {
|
|
t.Fatalf("failed to set %s: %v", syncbranch.EnvVar, err)
|
|
}
|
|
defer os.Unsetenv(syncbranch.EnvVar)
|
|
|
|
// Ensure we restore globals after the test
|
|
defer func() {
|
|
storeMutex.Lock()
|
|
store = oldStore
|
|
storeActive = oldStoreActive
|
|
storeMutex.Unlock()
|
|
dbPath = oldDBPath
|
|
}()
|
|
|
|
branch, err := getSyncBranch(ctx)
|
|
if err != nil {
|
|
t.Fatalf("getSyncBranch() error = %v", err)
|
|
}
|
|
if branch != "env-branch" {
|
|
t.Errorf("getSyncBranch() = %q, want %q (env override)", branch, "env-branch")
|
|
}
|
|
}
|
|
|
|
func TestIsInRebase_NotInRebase(t *testing.T) {
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Should not be in rebase
|
|
if isInRebase() {
|
|
t.Error("expected false when not in rebase")
|
|
}
|
|
}
|
|
|
|
func TestIsInRebase_InRebase(t *testing.T) {
|
|
tmpDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Simulate rebase by creating rebase-merge directory
|
|
os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-merge"), 0755)
|
|
|
|
// Should detect rebase
|
|
if !isInRebase() {
|
|
t.Error("expected true when .git/rebase-merge exists")
|
|
}
|
|
}
|
|
|
|
func TestIsInRebase_InRebaseApply(t *testing.T) {
|
|
tmpDir, cleanup := setupMinimalGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Simulate non-interactive rebase by creating rebase-apply directory
|
|
os.MkdirAll(filepath.Join(tmpDir, ".git", "rebase-apply"), 0755)
|
|
|
|
// Should detect rebase
|
|
if !isInRebase() {
|
|
t.Error("expected true when .git/rebase-apply exists")
|
|
}
|
|
}
|
|
|
|
func TestHasJSONLConflict_NoConflict(t *testing.T) {
|
|
_, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Should not have JSONL conflict
|
|
if hasJSONLConflict() {
|
|
t.Error("expected false when no conflicts")
|
|
}
|
|
}
|
|
|
|
func TestHasJSONLConflict_OnlyJSONLConflict(t *testing.T) {
|
|
tmpDir, cleanup := setupGitRepoWithBranch(t, "main")
|
|
defer cleanup()
|
|
|
|
// Create initial commit with beads.jsonl
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
os.MkdirAll(beadsDir, 0755)
|
|
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644)
|
|
exec.Command("git", "add", ".").Run()
|
|
exec.Command("git", "commit", "-m", "add beads.jsonl").Run()
|
|
|
|
// Create a second commit on main (modify same issue)
|
|
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644)
|
|
exec.Command("git", "add", ".").Run()
|
|
exec.Command("git", "commit", "-m", "main change").Run()
|
|
|
|
// Create a branch from the first commit
|
|
exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run()
|
|
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644)
|
|
exec.Command("git", "add", ".").Run()
|
|
exec.Command("git", "commit", "-m", "feature change").Run()
|
|
|
|
// Attempt rebase onto main (will conflict)
|
|
exec.Command("git", "rebase", "main").Run()
|
|
|
|
// Should detect JSONL conflict during rebase
|
|
if !hasJSONLConflict() {
|
|
t.Error("expected true when only beads.jsonl has conflict during rebase")
|
|
}
|
|
}
|
|
|
|
func TestHasJSONLConflict_MultipleConflicts(t *testing.T) {
|
|
tmpDir, cleanup := setupGitRepoWithBranch(t, "main")
|
|
defer cleanup()
|
|
|
|
// Create initial commit with beads.jsonl and another file
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
os.MkdirAll(beadsDir, 0755)
|
|
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"original"}`), 0644)
|
|
os.WriteFile("other.txt", []byte("line1\nline2\nline3"), 0644)
|
|
exec.Command("git", "add", ".").Run()
|
|
exec.Command("git", "commit", "-m", "add initial files").Run()
|
|
|
|
// Create a second commit on main (modify both files)
|
|
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"main-version"}`), 0644)
|
|
os.WriteFile("other.txt", []byte("line1\nmain-version\nline3"), 0644)
|
|
exec.Command("git", "add", ".").Run()
|
|
exec.Command("git", "commit", "-m", "main change").Run()
|
|
|
|
// Create a branch from the first commit
|
|
exec.Command("git", "checkout", "-b", "feature", "HEAD~1").Run()
|
|
os.WriteFile(filepath.Join(beadsDir, "beads.jsonl"), []byte(`{"id":"bd-1","title":"feature-version"}`), 0644)
|
|
os.WriteFile("other.txt", []byte("line1\nfeature-version\nline3"), 0644)
|
|
exec.Command("git", "add", ".").Run()
|
|
exec.Command("git", "commit", "-m", "feature change").Run()
|
|
|
|
// Attempt rebase (will conflict on both files)
|
|
exec.Command("git", "rebase", "main").Run()
|
|
|
|
// Should NOT auto-resolve when multiple files conflict
|
|
if hasJSONLConflict() {
|
|
t.Error("expected false when multiple files have conflicts (should not auto-resolve)")
|
|
}
|
|
}
|
|
|
|
// Note: TestZFCSkipsExportAfterImport was removed as ZFC checks are no longer part of the
|
|
// legacy sync flow. Use --pull-first for structural staleness handling via 3-way merge.
|
|
|
|
// TestHashBasedStalenessDetection_bd_f2f tests the bd-f2f fix:
|
|
// When JSONL content differs from stored hash (e.g., remote changed status),
|
|
// hasJSONLChanged should detect the mismatch even if counts are equal.
|
|
func TestHashBasedStalenessDetection_bd_f2f(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create test database
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create beads dir: %v", err)
|
|
}
|
|
|
|
testDBPath := filepath.Join(beadsDir, "beads.db")
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
// Create store
|
|
testStore, err := sqlite.New(ctx, testDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create store: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
// Initialize issue prefix (required for creating issues)
|
|
if err := testStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("failed to set issue prefix: %v", err)
|
|
}
|
|
|
|
// Create an issue in DB (simulating stale DB with old content)
|
|
issue := &types.Issue{
|
|
ID: "test-abc",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1, // DB has priority 1
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("failed to create issue: %v", err)
|
|
}
|
|
|
|
// Create JSONL with same issue but different priority (correct remote state)
|
|
// This simulates what happens after git pull brings in updated JSONL
|
|
// (e.g., remote changed priority from 1 to 0)
|
|
jsonlContent := `{"id":"test-abc","title":"Test Issue","status":"open","priority":0,"type":"task"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0600); err != nil {
|
|
t.Fatalf("failed to write JSONL: %v", err)
|
|
}
|
|
|
|
// Store an OLD hash (different from current JSONL)
|
|
// This simulates the case where JSONL was updated externally (by git pull)
|
|
// but DB still has old hash from before the pull
|
|
oldHash := "0000000000000000000000000000000000000000000000000000000000000000"
|
|
if err := testStore.SetMetadata(ctx, "jsonl_content_hash", oldHash); err != nil {
|
|
t.Fatalf("failed to set old hash: %v", err)
|
|
}
|
|
|
|
// Verify counts are equal (1 issue in both)
|
|
dbCount, err := countDBIssuesFast(ctx, testStore)
|
|
if err != nil {
|
|
t.Fatalf("failed to count DB issues: %v", err)
|
|
}
|
|
jsonlCount, err := countIssuesInJSONL(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to count JSONL issues: %v", err)
|
|
}
|
|
if dbCount != jsonlCount {
|
|
t.Fatalf("setup error: expected equal counts, got DB=%d, JSONL=%d", dbCount, jsonlCount)
|
|
}
|
|
|
|
// The key test: hasJSONLChanged should detect the hash mismatch
|
|
// even though counts are equal
|
|
repoKey := getRepoKeyForPath(jsonlPath)
|
|
changed := hasJSONLChanged(ctx, testStore, jsonlPath, repoKey)
|
|
|
|
if !changed {
|
|
t.Error("bd-f2f: hasJSONLChanged should return true when JSONL hash differs from stored hash")
|
|
t.Log("This is the bug scenario: counts match (1 == 1) but content differs (priority=1 vs priority=0)")
|
|
t.Log("Without the bd-f2f fix, the stale DB would export old content and corrupt the remote")
|
|
} else {
|
|
t.Log("✓ bd-f2f fix verified: hash mismatch detected even with equal counts")
|
|
}
|
|
|
|
// Verify that after updating hash, hasJSONLChanged returns false
|
|
currentHash, err := computeJSONLHash(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to compute current hash: %v", err)
|
|
}
|
|
if err := testStore.SetMetadata(ctx, "jsonl_content_hash", currentHash); err != nil {
|
|
t.Fatalf("failed to set current hash: %v", err)
|
|
}
|
|
|
|
changedAfterUpdate := hasJSONLChanged(ctx, testStore, jsonlPath, repoKey)
|
|
if changedAfterUpdate {
|
|
t.Error("hasJSONLChanged should return false after hash is updated to match JSONL")
|
|
}
|
|
}
|
|
|
|
// TestResolveNoGitHistoryForFromMain tests that --from-main forces noGitHistory=true
|
|
// to prevent creating incorrect deletion records for locally-created beads.
|
|
// See: https://github.com/steveyegge/beads/issues/417
|
|
func TestResolveNoGitHistoryForFromMain(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
fromMain bool
|
|
noGitHistory bool
|
|
want bool
|
|
}{
|
|
{
|
|
name: "fromMain=true forces noGitHistory=true regardless of flag",
|
|
fromMain: true,
|
|
noGitHistory: false,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "fromMain=true with noGitHistory=true stays true",
|
|
fromMain: true,
|
|
noGitHistory: true,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "fromMain=false preserves noGitHistory=false",
|
|
fromMain: false,
|
|
noGitHistory: false,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "fromMain=false preserves noGitHistory=true",
|
|
fromMain: false,
|
|
noGitHistory: true,
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt // capture range variable
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := resolveNoGitHistoryForFromMain(tt.fromMain, tt.noGitHistory)
|
|
if got != tt.want {
|
|
t.Errorf("resolveNoGitHistoryForFromMain(%v, %v) = %v, want %v",
|
|
tt.fromMain, tt.noGitHistory, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetGitCommonDir tests that getGitCommonDir correctly returns the shared
|
|
// git directory for both regular repos and worktrees.
|
|
func TestGetGitCommonDir(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Test 1: Regular repo
|
|
t.Run("regular repo", func(t *testing.T) {
|
|
repoDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
commonDir, err := getGitCommonDir(ctx, repoDir)
|
|
if err != nil {
|
|
t.Fatalf("getGitCommonDir failed: %v", err)
|
|
}
|
|
|
|
// For a regular repo, git-common-dir should point to .git
|
|
expectedGitDir := filepath.Join(repoDir, ".git")
|
|
// Resolve symlinks for comparison (macOS /var -> /private/var)
|
|
if resolved, err := filepath.EvalSymlinks(expectedGitDir); err == nil {
|
|
expectedGitDir = resolved
|
|
}
|
|
if commonDir != expectedGitDir {
|
|
t.Errorf("getGitCommonDir = %q, want %q", commonDir, expectedGitDir)
|
|
}
|
|
})
|
|
|
|
// Test 2: Worktree (non-bare) shares common dir with main repo
|
|
t.Run("worktree shares common dir with main repo", func(t *testing.T) {
|
|
repoDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Create a branch for the worktree
|
|
if err := exec.Command("git", "-C", repoDir, "branch", "test-branch").Run(); err != nil {
|
|
t.Fatalf("git branch failed: %v", err)
|
|
}
|
|
|
|
// Create worktree
|
|
worktreeDir := filepath.Join(t.TempDir(), "worktree")
|
|
if output, err := exec.Command("git", "-C", repoDir, "worktree", "add", worktreeDir, "test-branch").CombinedOutput(); err != nil {
|
|
t.Fatalf("git worktree add failed: %v\n%s", err, output)
|
|
}
|
|
|
|
// Get common dir for both
|
|
mainCommonDir, err := getGitCommonDir(ctx, repoDir)
|
|
if err != nil {
|
|
t.Fatalf("getGitCommonDir(main) failed: %v", err)
|
|
}
|
|
|
|
worktreeCommonDir, err := getGitCommonDir(ctx, worktreeDir)
|
|
if err != nil {
|
|
t.Fatalf("getGitCommonDir(worktree) failed: %v", err)
|
|
}
|
|
|
|
// Both should return the same common dir
|
|
if mainCommonDir != worktreeCommonDir {
|
|
t.Errorf("common dirs differ: main=%q, worktree=%q", mainCommonDir, worktreeCommonDir)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestIsExternalBeadsDir tests that isExternalBeadsDir correctly identifies
|
|
// when beads directory is in the same vs different git repo.
|
|
// GH#810: This was broken for bare repo worktrees.
|
|
func TestIsExternalBeadsDir(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Test 1: Same directory - not external
|
|
t.Run("same directory is not external", func(t *testing.T) {
|
|
repoDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
beadsDir := filepath.Join(repoDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
t.Fatalf("mkdir failed: %v", err)
|
|
}
|
|
|
|
// Change to the repo directory (isExternalBeadsDir uses cwd)
|
|
origDir, _ := os.Getwd()
|
|
if err := os.Chdir(repoDir); err != nil {
|
|
t.Fatalf("chdir failed: %v", err)
|
|
}
|
|
defer func() { _ = os.Chdir(origDir) }()
|
|
|
|
if isExternalBeadsDir(ctx, beadsDir) {
|
|
t.Error("expected local beads dir to not be external")
|
|
}
|
|
})
|
|
|
|
// Test 2: Different repo - is external
|
|
t.Run("different repo is external", func(t *testing.T) {
|
|
repo1Dir, cleanup1 := setupGitRepo(t)
|
|
defer cleanup1()
|
|
repo2Dir, cleanup2 := setupGitRepo(t)
|
|
defer cleanup2()
|
|
|
|
beadsDir := filepath.Join(repo2Dir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
t.Fatalf("mkdir failed: %v", err)
|
|
}
|
|
|
|
// Change to repo1
|
|
origDir, _ := os.Getwd()
|
|
if err := os.Chdir(repo1Dir); err != nil {
|
|
t.Fatalf("chdir failed: %v", err)
|
|
}
|
|
defer func() { _ = os.Chdir(origDir) }()
|
|
|
|
if !isExternalBeadsDir(ctx, beadsDir) {
|
|
t.Error("expected beads dir in different repo to be external")
|
|
}
|
|
})
|
|
|
|
// Test 3: Worktree with beads - not external (GH#810 fix)
|
|
t.Run("worktree beads dir is not external", func(t *testing.T) {
|
|
repoDir, cleanup := setupGitRepo(t)
|
|
defer cleanup()
|
|
|
|
// Create a branch for the worktree
|
|
if err := exec.Command("git", "-C", repoDir, "branch", "test-branch").Run(); err != nil {
|
|
t.Fatalf("git branch failed: %v", err)
|
|
}
|
|
|
|
// Create worktree
|
|
worktreeDir := filepath.Join(t.TempDir(), "worktree")
|
|
if output, err := exec.Command("git", "-C", repoDir, "worktree", "add", worktreeDir, "test-branch").CombinedOutput(); err != nil {
|
|
t.Fatalf("git worktree add failed: %v\n%s", err, output)
|
|
}
|
|
|
|
// Create beads dir in worktree
|
|
beadsDir := filepath.Join(worktreeDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
t.Fatalf("mkdir failed: %v", err)
|
|
}
|
|
|
|
// Change to worktree
|
|
origDir, _ := os.Getwd()
|
|
if err := os.Chdir(worktreeDir); err != nil {
|
|
t.Fatalf("chdir failed: %v", err)
|
|
}
|
|
defer func() { _ = os.Chdir(origDir) }()
|
|
|
|
// Beads dir in same worktree should NOT be external
|
|
if isExternalBeadsDir(ctx, beadsDir) {
|
|
t.Error("expected beads dir in same worktree to not be external")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestConcurrentEdit tests the pull-first sync flow with concurrent edits.
|
|
// This validates the 3-way merge logic for the pull-first sync refactor (#911).
|
|
//
|
|
// Scenario:
|
|
// - Base state exists (issue bd-1 at version 2025-01-01)
|
|
// - Local modifies issue (version 2025-01-02)
|
|
// - Remote also modifies issue (version 2025-01-03)
|
|
// - 3-way merge detects conflict and resolves using LWW (remote wins)
|
|
func TestConcurrentEdit(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Setup: Initialize git repo
|
|
if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
|
|
t.Fatalf("git init failed: %v", err)
|
|
}
|
|
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
|
|
_ = exec.Command("git", "config", "user.name", "Test User").Run()
|
|
|
|
// Setup: Create beads directory with JSONL (base state)
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("mkdir failed: %v", err)
|
|
}
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
// Base state: single issue at 2025-01-01
|
|
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseIssue := `{"id":"bd-1","title":"Original Title","status":"open","issue_type":"task","priority":2,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}`
|
|
if err := os.WriteFile(jsonlPath, []byte(baseIssue+"\n"), 0644); err != nil {
|
|
t.Fatalf("write JSONL failed: %v", err)
|
|
}
|
|
|
|
// Initial commit
|
|
_ = exec.Command("git", "add", ".").Run()
|
|
if err := exec.Command("git", "commit", "-m", "initial").Run(); err != nil {
|
|
t.Fatalf("initial commit failed: %v", err)
|
|
}
|
|
|
|
// Create database and import base state
|
|
testDBPath := filepath.Join(beadsDir, "beads.db")
|
|
testStore, err := sqlite.New(ctx, testDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test store: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
// Set issue_prefix
|
|
if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Load base state for 3-way merge
|
|
baseIssues, err := loadIssuesFromJSONL(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("loadIssuesFromJSONL (base) failed: %v", err)
|
|
}
|
|
|
|
// Create local issue (modified at 2025-01-02)
|
|
localTime := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
|
|
localIssueObj := &types.Issue{
|
|
ID: "bd-1",
|
|
Title: "Local Edit",
|
|
Status: types.StatusOpen,
|
|
IssueType: types.TypeTask,
|
|
Priority: 2,
|
|
CreatedAt: baseTime,
|
|
UpdatedAt: localTime,
|
|
}
|
|
localIssues := []*types.Issue{localIssueObj}
|
|
|
|
// Simulate "remote" edit: change title in JSONL (modified at 2025-01-03 - later)
|
|
remoteIssue := `{"id":"bd-1","title":"Remote Edit","status":"open","issue_type":"task","priority":2,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-03T00:00:00Z"}`
|
|
if err := os.WriteFile(jsonlPath, []byte(remoteIssue+"\n"), 0644); err != nil {
|
|
t.Fatalf("write remote JSONL failed: %v", err)
|
|
}
|
|
|
|
remoteIssues, err := loadIssuesFromJSONL(jsonlPath)
|
|
if err != nil {
|
|
t.Fatalf("loadIssuesFromJSONL (remote) failed: %v", err)
|
|
}
|
|
|
|
if len(remoteIssues) != 1 {
|
|
t.Fatalf("expected 1 remote issue, got %d", len(remoteIssues))
|
|
}
|
|
|
|
// 3-way merge with base state
|
|
mergeResult := MergeIssues(baseIssues, localIssues, remoteIssues)
|
|
|
|
// Verify merge result
|
|
if len(mergeResult.Merged) != 1 {
|
|
t.Fatalf("expected 1 merged issue, got %d", len(mergeResult.Merged))
|
|
}
|
|
|
|
// LWW: Remote wins because it has later updated_at (2025-01-03 > 2025-01-02)
|
|
if mergeResult.Merged[0].Title != "Remote Edit" {
|
|
t.Errorf("expected merged title 'Remote Edit' (remote wins LWW), got '%s'", mergeResult.Merged[0].Title)
|
|
}
|
|
|
|
// Verify strategy: should be "merged" (conflict resolved by LWW)
|
|
if mergeResult.Strategy["bd-1"] != StrategyMerged {
|
|
t.Errorf("expected strategy '%s' for bd-1, got '%s'", StrategyMerged, mergeResult.Strategy["bd-1"])
|
|
}
|
|
|
|
// Verify 1 conflict was detected and resolved
|
|
if mergeResult.Conflicts != 1 {
|
|
t.Errorf("expected 1 conflict (both sides modified), got %d", mergeResult.Conflicts)
|
|
}
|
|
|
|
t.Log("TestConcurrentEdit: 3-way merge with LWW resolution validated")
|
|
}
|
|
|
|
// TestConcurrentSyncBlocked tests that concurrent syncs are blocked by file lock.
|
|
// This validates the P0 fix for preventing data corruption when running bd sync
|
|
// from multiple terminals simultaneously.
|
|
func TestConcurrentSyncBlocked(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpDir := t.TempDir()
|
|
t.Chdir(tmpDir)
|
|
|
|
// Setup: Initialize git repo
|
|
if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
|
|
t.Fatalf("git init failed: %v", err)
|
|
}
|
|
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
|
|
_ = exec.Command("git", "config", "user.name", "Test User").Run()
|
|
|
|
// Setup: Create beads directory with JSONL
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("mkdir failed: %v", err)
|
|
}
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
// Create initial JSONL
|
|
if err := os.WriteFile(jsonlPath, []byte(`{"id":"bd-1","title":"Test"}`+"\n"), 0644); err != nil {
|
|
t.Fatalf("write JSONL failed: %v", err)
|
|
}
|
|
|
|
// Initial commit
|
|
_ = exec.Command("git", "add", ".").Run()
|
|
if err := exec.Command("git", "commit", "-m", "initial").Run(); err != nil {
|
|
t.Fatalf("initial commit failed: %v", err)
|
|
}
|
|
|
|
// Create database
|
|
testDBPath := filepath.Join(beadsDir, "beads.db")
|
|
testStore, err := sqlite.New(ctx, testDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test store: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
// Set issue_prefix
|
|
if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Simulate another sync holding the lock
|
|
lockPath := filepath.Join(beadsDir, ".sync.lock")
|
|
lock := flock.New(lockPath)
|
|
locked, err := lock.TryLock()
|
|
if err != nil {
|
|
t.Fatalf("failed to acquire lock: %v", err)
|
|
}
|
|
if !locked {
|
|
t.Fatal("expected to acquire lock")
|
|
}
|
|
|
|
// Now try to acquire the same lock (simulating concurrent sync)
|
|
lock2 := flock.New(lockPath)
|
|
locked2, err := lock2.TryLock()
|
|
if err != nil {
|
|
t.Fatalf("TryLock error: %v", err)
|
|
}
|
|
|
|
// Second lock attempt should fail (not block)
|
|
if locked2 {
|
|
lock2.Unlock()
|
|
t.Error("expected second lock attempt to fail (concurrent sync should be blocked)")
|
|
} else {
|
|
t.Log("Concurrent sync correctly blocked by file lock")
|
|
}
|
|
|
|
// Release first lock
|
|
if err := lock.Unlock(); err != nil {
|
|
t.Fatalf("failed to unlock: %v", err)
|
|
}
|
|
|
|
// Now lock should be acquirable again
|
|
lock3 := flock.New(lockPath)
|
|
locked3, err := lock3.TryLock()
|
|
if err != nil {
|
|
t.Fatalf("TryLock error after unlock: %v", err)
|
|
}
|
|
if !locked3 {
|
|
t.Error("expected lock to be acquirable after first sync completes")
|
|
} else {
|
|
lock3.Unlock()
|
|
t.Log("Lock correctly acquirable after first sync completes")
|
|
}
|
|
}
|