Files
beads/cmd/bd/daemon_sync_branch_test.go
Peter Chanthamynavong 1561374c04 feat(sync): pull-first sync with 3-way merge (#918)
* 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>
2026-01-07 21:27:20 -08:00

1960 lines
61 KiB
Go

//go:build integration
// +build integration
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
)
// TestSyncBranchCommitAndPush_NotConfigured tests backward compatibility
// when sync.branch is not configured (should return false, no error)
func TestSyncBranchCommitAndPush_NotConfigured(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
// Setup test store
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Create test issue
issue := &types.Issue{
Title: "Test issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Export to JSONL
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
// Change to temp directory for git operations
t.Chdir(tmpDir)
// Test with no sync.branch configured
log, logMsgs := newTestSyncBranchLogger()
_ = logMsgs // unused in this test
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
// Should return false (not committed), no error
if err != nil {
t.Errorf("Expected no error when sync.branch not configured, got: %v", err)
}
if committed {
t.Error("Expected committed=false when sync.branch not configured")
}
}
// TestSyncBranchCommitAndPush_Success tests successful sync branch commit
func TestSyncBranchCommitAndPush_Success(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
// Setup test store
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Configure sync.branch
syncBranch := "beads-sync"
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Initial commit on main branch (before creating JSONL)
t.Chdir(tmpDir)
initMainBranch(t, tmpDir)
// Create test issue
issue := &types.Issue{
Title: "Test sync branch issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Export to JSONL
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
// Test sync branch commit (without push)
log, logMsgs := newTestSyncBranchLogger()
_ = logMsgs // unused in this test
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Error("Expected committed=true")
}
// Verify worktree was created
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch)
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Errorf("Worktree not created at %s", worktreePath)
}
// Verify sync branch exists
cmd := exec.Command("git", "branch", "--list", syncBranch)
cmd.Dir = tmpDir
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to list branches: %v", err)
}
if !strings.Contains(string(output), syncBranch) {
t.Errorf("Sync branch %s not created", syncBranch)
}
// Verify JSONL was synced to worktree
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
if _, err := os.Stat(worktreeJSONL); os.IsNotExist(err) {
t.Error("JSONL not synced to worktree")
}
// Verify commit was made in worktree
cmd = exec.Command("git", "-C", worktreePath, "log", "--oneline", "-1")
output, err = cmd.Output()
if err != nil {
t.Fatalf("Failed to get log: %v", err)
}
if !strings.Contains(string(output), "bd daemon sync") {
t.Errorf("Expected commit message with 'bd daemon sync', got: %s", string(output))
}
}
// TestSyncBranchCommitAndPush_EnvOverridesDB verifies that BEADS_SYNC_BRANCH
// takes precedence over the sync.branch database config for daemon commits.
func TestSyncBranchCommitAndPush_EnvOverridesDB(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
// Setup test store
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Configure DB sync.branch to one value
if err := store.SetConfig(ctx, "sync.branch", "db-branch"); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Set BEADS_SYNC_BRANCH to a different value and ensure it takes precedence.
t.Setenv(syncbranch.EnvVar, "env-branch")
// Initial commit on main branch
t.Chdir(tmpDir)
initMainBranch(t, tmpDir)
// Create test issue and export JSONL
issue := &types.Issue{
Title: "Env override issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
log, _ := newTestSyncBranchLogger()
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Fatal("Expected committed=true with env override")
}
// Verify that the worktree and branch are created using the env branch.
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", "env-branch")
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Fatalf("Env sync branch worktree not created at %s", worktreePath)
}
cmd := exec.Command("git", "branch", "--list", "env-branch")
cmd.Dir = tmpDir
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to list branches: %v", err)
}
if !strings.Contains(string(output), "env-branch") {
t.Errorf("Env sync branch not created, branches: %s", string(output))
}
}
// TestSyncBranchCommitAndPush_NoChanges tests behavior when no changes to commit
func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
initMainBranch(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
syncBranch := "beads-sync"
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
issue := &types.Issue{
Title: "Test issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
t.Chdir(tmpDir)
log, logMsgs := newTestSyncBranchLogger()
// First commit should succeed
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("First commit failed: %v", err)
}
if !committed {
t.Error("Expected first commit to succeed")
}
// Second commit with no changes should return false
committed, err = syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("Second commit failed: %v", err)
}
if committed {
t.Error("Expected committed=false when no changes")
}
// Verify log message
if !strings.Contains(*logMsgs, "No changes to commit") {
t.Error("Expected 'No changes to commit' log message")
}
}
// TestSyncBranchCommitAndPush_WorktreeHealthCheck tests worktree repair logic
func TestSyncBranchCommitAndPush_WorktreeHealthCheck(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
initMainBranch(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
syncBranch := "beads-sync"
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
issue := &types.Issue{
Title: "Test issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
t.Chdir(tmpDir)
log, logMsgs := newTestSyncBranchLogger()
// First commit to create worktree
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("First commit failed: %v", err)
}
if !committed {
t.Error("Expected first commit to succeed")
}
// Corrupt the worktree by deleting .git file
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch)
worktreeGitFile := filepath.Join(worktreePath, ".git")
if err := os.Remove(worktreeGitFile); err != nil {
t.Fatalf("Failed to corrupt worktree: %v", err)
}
// Update issue to create new changes
if err := store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
"priority": 2,
}, "test"); err != nil {
t.Fatalf("Failed to update issue: %v", err)
}
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
*logMsgs = "" // Reset log
// Should detect corruption and repair (CreateBeadsWorktree handles this silently)
committed, err = syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("Commit after corruption failed: %v", err)
}
if !committed {
t.Error("Expected commit to succeed after repair")
}
// Verify worktree is functional again - .git file should be restored
if _, err := os.Stat(worktreeGitFile); os.IsNotExist(err) {
t.Error("Worktree .git file not restored")
}
}
// TestSyncBranchPull_NotConfigured tests pull with no sync.branch configured
func TestSyncBranchPull_NotConfigured(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
t.Chdir(tmpDir)
log, logMsgs := newTestSyncBranchLogger()
_ = logMsgs // unused in this test
pulled, err := syncBranchPull(ctx, store, log)
// Should return false (not pulled), no error
if err != nil {
t.Errorf("Expected no error when sync.branch not configured, got: %v", err)
}
if pulled {
t.Error("Expected pulled=false when sync.branch not configured")
}
}
// TestSyncBranchPull_Success tests successful pull from sync branch
func TestSyncBranchPull_Success(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Create remote repository
tmpDir := t.TempDir()
remoteDir := filepath.Join(tmpDir, "remote")
if err := os.MkdirAll(remoteDir, 0755); err != nil {
t.Fatalf("Failed to create remote dir: %v", err)
}
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
// Create clone1 (will push changes)
clone1Dir := filepath.Join(tmpDir, "clone1")
runGitCmd(t, tmpDir, "clone", remoteDir, clone1Dir)
configureGit(t, clone1Dir)
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
if err := os.MkdirAll(clone1BeadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
store1, err := sqlite.New(context.Background(), clone1DBPath)
if err != nil {
t.Fatalf("Failed to create store1: %v", err)
}
defer store1.Close()
ctx := context.Background()
if err := store1.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
syncBranch := "beads-sync"
if err := store1.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Create issue in clone1
issue := &types.Issue{
Title: "Test sync pull issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store1.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
clone1JSONLPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store1, clone1JSONLPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
// Commit to main branch first
initMainBranch(t, clone1Dir)
runGitCmd(t, clone1Dir, "push", "origin", "master")
// Change to clone1 directory for sync branch operations
t.Chdir(clone1Dir)
// Push to sync branch using syncBranchCommitAndPush
log, logMsgs := newTestSyncBranchLogger()
_ = logMsgs // unused in this test
committed, err := syncBranchCommitAndPush(ctx, store1, true, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Error("Expected commit to succeed")
}
// Create clone2 (will pull changes)
clone2Dir := filepath.Join(tmpDir, "clone2")
runGitCmd(t, tmpDir, "clone", remoteDir, clone2Dir)
configureGit(t, clone2Dir)
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
store2, err := sqlite.New(context.Background(), clone2DBPath)
if err != nil {
t.Fatalf("Failed to create store2: %v", err)
}
defer store2.Close()
if err := store2.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
if err := store2.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Change to clone2 directory
t.Chdir(clone2Dir)
// Pull from sync branch
log2, logMsgs2 := newTestSyncBranchLogger()
pulled, err := syncBranchPull(ctx, store2, log2)
if err != nil {
t.Fatalf("syncBranchPull failed: %v", err)
}
if !pulled {
t.Error("Expected pulled=true")
}
// Verify JSONL was copied to main repo
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
if _, err := os.Stat(clone2JSONLPath); os.IsNotExist(err) {
t.Error("JSONL not copied to main repo after pull")
}
// On Windows, file I/O may need more time to settle
// Increase delay significantly for reliable CI tests
if runtime.GOOS == "windows" {
time.Sleep(300 * time.Millisecond)
}
// Verify JSONL content matches
clone1Data, err := os.ReadFile(clone1JSONLPath)
if err != nil {
t.Fatalf("Failed to read clone1 JSONL: %v", err)
}
clone2Data, err := os.ReadFile(clone2JSONLPath)
if err != nil {
t.Fatalf("Failed to read clone2 JSONL: %v", err)
}
if string(clone1Data) != string(clone2Data) {
t.Error("JSONL content mismatch after pull")
}
// Verify pull message in log
if !strings.Contains(*logMsgs2, "Pulled sync branch") {
t.Error("Expected 'Pulled sync branch' log message")
}
}
// TestSyncBranchIntegration_EndToEnd tests full sync workflow
func TestSyncBranchIntegration_EndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Setup remote and two clones
tmpDir := t.TempDir()
remoteDir := filepath.Join(tmpDir, "remote")
os.MkdirAll(remoteDir, 0755)
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
// Clone1: Agent A
clone1Dir := filepath.Join(tmpDir, "clone1")
runGitCmd(t, tmpDir, "clone", remoteDir, clone1Dir)
configureGit(t, clone1Dir)
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
os.MkdirAll(clone1BeadsDir, 0755)
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
store1, _ := sqlite.New(context.Background(), clone1DBPath)
defer store1.Close()
ctx := context.Background()
store1.SetConfig(ctx, "issue_prefix", "test")
syncBranch := "beads-sync"
store1.SetConfig(ctx, "sync.branch", syncBranch)
// Agent A creates issue
issue := &types.Issue{
Title: "E2E test issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
store1.CreateIssue(ctx, issue, "agent-a")
issueID := issue.ID
clone1JSONLPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
exportToJSONLWithStore(ctx, store1, clone1JSONLPath)
// Initial commit to main
initMainBranch(t, clone1Dir)
runGitCmd(t, clone1Dir, "push", "origin", "master")
// Change to clone1 directory
t.Chdir(clone1Dir)
// Agent A commits to sync branch
log, logMsgs := newTestSyncBranchLogger()
_ = logMsgs // unused in this test
committed, err := syncBranchCommitAndPush(ctx, store1, true, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Error("Expected commit to succeed")
}
// Clone2: Agent B
clone2Dir := filepath.Join(tmpDir, "clone2")
runGitCmd(t, tmpDir, "clone", remoteDir, clone2Dir)
configureGit(t, clone2Dir)
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
store2, _ := sqlite.New(context.Background(), clone2DBPath)
defer store2.Close()
store2.SetConfig(ctx, "issue_prefix", "test")
store2.SetConfig(ctx, "sync.branch", syncBranch)
// Change to clone2 directory
t.Chdir(clone2Dir)
// Agent B pulls from sync branch
log2, logMsgs2 := newTestSyncBranchLogger()
_ = logMsgs2 // unused in this test
pulled, err := syncBranchPull(ctx, store2, log2)
if err != nil {
t.Fatalf("syncBranchPull failed: %v", err)
}
if !pulled {
t.Error("Expected pull to succeed")
}
// Import JSONL to database
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
if err := importToJSONLWithStore(ctx, store2, clone2JSONLPath); err != nil {
t.Fatalf("Failed to import: %v", err)
}
// Verify issue exists in clone2
clone2Issue, err := store2.GetIssue(ctx, issueID)
if err != nil {
t.Fatalf("Failed to get issue in clone2: %v", err)
}
if clone2Issue.Title != issue.Title {
t.Errorf("Issue title mismatch: expected %s, got %s", issue.Title, clone2Issue.Title)
}
// Agent B closes the issue
store2.CloseIssue(ctx, issueID, "Done by Agent B", "agent-b", "")
exportToJSONLWithStore(ctx, store2, clone2JSONLPath)
// Agent B commits to sync branch
committed, err = syncBranchCommitAndPush(ctx, store2, true, log2)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed for clone2: %v", err)
}
if !committed {
t.Error("Expected commit to succeed for clone2")
}
// Agent A pulls the update
t.Chdir(clone1Dir)
pulled, err = syncBranchPull(ctx, store1, log)
if err != nil {
t.Fatalf("syncBranchPull failed for clone1: %v", err)
}
if !pulled {
t.Error("Expected pull to succeed for clone1")
}
// Import to see the closed status
importToJSONLWithStore(ctx, store1, clone1JSONLPath)
// Verify Agent A sees the closed issue
updatedIssue, err := store1.GetIssue(ctx, issueID)
if err != nil {
t.Fatalf("Failed to get issue in clone1: %v", err)
}
if updatedIssue.Status != types.StatusClosed {
t.Errorf("Issue not closed in clone1: status=%s", updatedIssue.Status)
}
}
// Helper types for testing
func newTestSyncBranchLogger() (daemonLogger, *string) {
// Note: With slog, we can't easily capture formatted messages like before.
// For tests that need to verify log output, use strings.Builder and newTestLoggerWithWriter.
// This helper is kept for backward compatibility but messages won't be captured.
messages := ""
return newTestLogger(), &messages
}
// TestSyncBranchConfigChange tests changing sync.branch after worktree exists
func TestSyncBranchConfigChange(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
initMainBranch(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Set initial sync.branch
syncBranch1 := "beads-sync-v1"
if err := store.SetConfig(ctx, "sync.branch", syncBranch1); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
issue := &types.Issue{
Title: "Test config change",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
t.Chdir(tmpDir)
log, _ := newTestSyncBranchLogger()
// First commit to v1 branch
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("First commit failed: %v", err)
}
if !committed {
t.Error("Expected first commit to succeed")
}
// Verify v1 worktree exists
worktree1Path := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch1)
if _, err := os.Stat(worktree1Path); os.IsNotExist(err) {
t.Errorf("Worktree v1 not created at %s", worktree1Path)
}
// Change sync.branch to v2
syncBranch2 := "beads-sync-v2"
if err := store.SetConfig(ctx, "sync.branch", syncBranch2); err != nil {
t.Fatalf("Failed to change sync.branch: %v", err)
}
// Update issue to create new changes
if err := store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
"priority": 2,
}, "test"); err != nil {
t.Fatalf("Failed to update issue: %v", err)
}
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
// Commit to v2 branch (should create new worktree)
committed, err = syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("Second commit failed: %v", err)
}
if !committed {
t.Error("Expected second commit to succeed")
}
// Verify v2 worktree exists
worktree2Path := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch2)
if _, err := os.Stat(worktree2Path); os.IsNotExist(err) {
t.Errorf("Worktree v2 not created at %s", worktree2Path)
}
// Verify both branches exist
cmd := exec.Command("git", "branch", "--list")
cmd.Dir = tmpDir
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to list branches: %v", err)
}
branches := string(output)
if !strings.Contains(branches, syncBranch1) {
t.Errorf("Branch %s not found", syncBranch1)
}
if !strings.Contains(branches, syncBranch2) {
t.Errorf("Branch %s not found", syncBranch2)
}
// Verify both worktrees exist and are valid
if _, err := os.Stat(worktree1Path); err != nil {
t.Error("Old worktree v1 should still exist")
}
if _, err := os.Stat(worktree2Path); err != nil {
t.Error("New worktree v2 should exist")
}
}
// TestSyncBranchMultipleConcurrentClones tests three clones working simultaneously
func TestSyncBranchMultipleConcurrentClones(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Setup remote and three clones
tmpDir := t.TempDir()
remoteDir := filepath.Join(tmpDir, "remote")
os.MkdirAll(remoteDir, 0755)
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
syncBranch := "beads-sync"
// Helper to setup a clone
setupClone := func(name string) (string, *sqlite.SQLiteStorage) {
cloneDir := filepath.Join(tmpDir, name)
runGitCmd(t, tmpDir, "clone", remoteDir, cloneDir)
configureGit(t, cloneDir)
beadsDir := filepath.Join(cloneDir, ".beads")
os.MkdirAll(beadsDir, 0755)
dbPath := filepath.Join(beadsDir, "test.db")
store, _ := sqlite.New(context.Background(), dbPath)
ctx := context.Background()
store.SetConfig(ctx, "issue_prefix", "test")
store.SetConfig(ctx, "sync.branch", syncBranch)
return cloneDir, store
}
// Setup three clones
clone1Dir, store1 := setupClone("clone1")
defer store1.Close()
clone2Dir, store2 := setupClone("clone2")
defer store2.Close()
clone3Dir, store3 := setupClone("clone3")
defer store3.Close()
ctx := context.Background()
// Initial commit on main
initMainBranch(t, clone1Dir)
runGitCmd(t, clone1Dir, "push", "origin", "master")
// Clone1: Create and push issue A
t.Chdir(clone1Dir)
issueA := &types.Issue{
Title: "Issue A from clone1",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
store1.CreateIssue(ctx, issueA, "agent1")
jsonlPath1 := filepath.Join(clone1Dir, ".beads", "issues.jsonl")
exportToJSONLWithStore(ctx, store1, jsonlPath1)
log1, _ := newTestSyncBranchLogger()
committed, err := syncBranchCommitAndPush(ctx, store1, true, log1)
if err != nil || !committed {
t.Fatalf("Clone1 commit failed: err=%v, committed=%v", err, committed)
}
// Clone2: Fetch, pull, create issue B, push
t.Chdir(clone2Dir)
runGitCmd(t, clone2Dir, "fetch", "origin")
log2, _ := newTestSyncBranchLogger()
syncBranchPull(ctx, store2, log2)
jsonlPath2 := filepath.Join(clone2Dir, ".beads", "issues.jsonl")
importToJSONLWithStore(ctx, store2, jsonlPath2)
issueB := &types.Issue{
Title: "Issue B from clone2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
store2.CreateIssue(ctx, issueB, "agent2")
exportToJSONLWithStore(ctx, store2, jsonlPath2)
committed, err = syncBranchCommitAndPush(ctx, store2, true, log2)
if err != nil || !committed {
t.Fatalf("Clone2 commit failed: err=%v, committed=%v", err, committed)
}
// Clone3: Fetch, pull, create issue C, push
t.Chdir(clone3Dir)
runGitCmd(t, clone3Dir, "fetch", "origin")
log3, _ := newTestSyncBranchLogger()
syncBranchPull(ctx, store3, log3)
jsonlPath3 := filepath.Join(clone3Dir, ".beads", "issues.jsonl")
importToJSONLWithStore(ctx, store3, jsonlPath3)
issueC := &types.Issue{
Title: "Issue C from clone3",
Status: types.StatusOpen,
Priority: 3,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
store3.CreateIssue(ctx, issueC, "agent3")
exportToJSONLWithStore(ctx, store3, jsonlPath3)
committed, err = syncBranchCommitAndPush(ctx, store3, true, log3)
if err != nil || !committed {
t.Fatalf("Clone3 commit failed: err=%v, committed=%v", err, committed)
}
// All clones pull final state
t.Chdir(clone1Dir)
syncBranchPull(ctx, store1, log1)
importToJSONLWithStore(ctx, store1, jsonlPath1)
t.Chdir(clone2Dir)
syncBranchPull(ctx, store2, log2)
importToJSONLWithStore(ctx, store2, jsonlPath2)
// Verify all three issues exist in all clones
verifyIssueCount := func(store *sqlite.SQLiteStorage, expected int, cloneName string) {
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Errorf("%s: Failed to search issues: %v", cloneName, err)
}
if len(issues) != expected {
t.Errorf("%s: Expected %d issues, got %d", cloneName, expected, len(issues))
}
}
verifyIssueCount(store1, 3, "clone1")
verifyIssueCount(store2, 3, "clone2")
verifyIssueCount(store3, 3, "clone3")
// Verify specific issues exist
verifyIssueExists := func(store *sqlite.SQLiteStorage, id, cloneName string) {
_, err := store.GetIssue(ctx, id)
if err != nil {
t.Errorf("%s: Issue %s not found: %v", cloneName, id, err)
}
}
verifyIssueExists(store1, issueA.ID, "clone1")
verifyIssueExists(store1, issueB.ID, "clone1")
verifyIssueExists(store1, issueC.ID, "clone1")
}
// TestSyncBranchPerformance tests that sync branch operations have acceptable overhead
func TestSyncBranchPerformance(t *testing.T) {
if testing.Short() {
t.Skip("Skipping performance test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
initMainBranch(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
os.MkdirAll(beadsDir, 0755)
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
store.SetConfig(ctx, "issue_prefix", "test")
store.SetConfig(ctx, "sync.branch", "beads-sync")
// Create initial issue
issue := &types.Issue{
Title: "Performance test issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
store.CreateIssue(ctx, issue, "test")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
exportToJSONLWithStore(ctx, store, jsonlPath)
t.Chdir(tmpDir)
log, _ := newTestSyncBranchLogger()
// First commit (creates worktree - expected to be slower)
start := time.Now()
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
firstDuration := time.Since(start)
if err != nil || !committed {
t.Fatalf("First commit failed: err=%v, committed=%v", err, committed)
}
t.Logf("First commit (with worktree creation): %v", firstDuration)
// Subsequent commits (should be fast)
const iterations = 5
var totalDuration time.Duration
for i := 0; i < iterations; i++ {
// Make a small change
store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
"priority": (i % 4) + 1,
}, "test")
exportToJSONLWithStore(ctx, store, jsonlPath)
start = time.Now()
committed, err = syncBranchCommitAndPush(ctx, store, false, log)
duration := time.Since(start)
totalDuration += duration
if err != nil || !committed {
t.Fatalf("Commit %d failed: err=%v, committed=%v", i+1, err, committed)
}
t.Logf("Commit %d: %v", i+1, duration)
}
avgDuration := totalDuration / iterations
// Windows git operations are significantly slower - use platform-specific thresholds
maxAllowed := 150 * time.Millisecond
if runtime.GOOS == "windows" {
maxAllowed = 500 * time.Millisecond
}
t.Logf("Average commit time: %v (max allowed: %v)", avgDuration, maxAllowed)
if avgDuration > maxAllowed {
t.Errorf("Average commit overhead %v exceeds maximum allowed %v", avgDuration, maxAllowed)
}
}
// TestSyncBranchNetworkFailure tests graceful handling of network errors
func TestSyncBranchNetworkFailure(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
initMainBranch(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
os.MkdirAll(beadsDir, 0755)
dbPath := filepath.Join(beadsDir, "test.db")
store, err := sqlite.New(context.Background(), dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
store.SetConfig(ctx, "issue_prefix", "test")
store.SetConfig(ctx, "sync.branch", "beads-sync")
// Create issue
issue := &types.Issue{
Title: "Test network failure",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
store.CreateIssue(ctx, issue, "test")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
exportToJSONLWithStore(ctx, store, jsonlPath)
t.Chdir(tmpDir)
log, logMsgs := newTestSyncBranchLogger()
// Commit locally (without push to simulate offline mode)
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("Local commit failed: %v", err)
}
if !committed {
t.Error("Expected commit to succeed locally")
}
// Now try to push to non-existent remote (simulates network failure)
// Set up a bogus remote
runGitCmd(t, tmpDir, "remote", "add", "origin", "https://invalid-remote.example.com/repo.git")
// Update issue
store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
"priority": 2,
}, "test")
exportToJSONLWithStore(ctx, store, jsonlPath)
// Try commit with push - should handle network error gracefully
committed, err = syncBranchCommitAndPush(ctx, store, true, log)
// The commit should succeed locally even if push fails
// (Current implementation may vary - this documents expected behavior)
pushFailed := false
if err != nil {
// Network error is acceptable - verify it's a git/network error
if !strings.Contains(err.Error(), "git") && !strings.Contains(err.Error(), "push") {
t.Errorf("Expected git/push error, got: %v", err)
}
t.Logf("Network error (expected): %v", err)
pushFailed = true
}
// Verify local commit still succeeded
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", "beads-sync")
cmd := exec.Command("git", "-C", worktreePath, "log", "--oneline")
output, cmdErr := cmd.Output()
if cmdErr != nil {
t.Fatalf("Failed to get log: %v", cmdErr)
}
// Should have at least 2 commits (initial + update)
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(commits) < 2 {
t.Errorf("Expected at least 2 commits, got %d", len(commits))
}
// Verify log contains appropriate messages
// If push failed, we might not have the success message
if !pushFailed {
if !strings.Contains(*logMsgs, "Committed") || !strings.Contains(*logMsgs, "beads-sync") {
t.Error("Expected commit success message in log")
}
}
}
// TestSyncBranchCommitAndPush_WithPreCommitHook is a regression test for the bug where
// daemon auto-sync failed when pre-commit hooks were installed.
//
// Bug: The gitCommitInWorktree function was missing --no-verify flag, causing
// pre-commit hooks to execute in the worktree context. The bd pre-commit hook
// runs "bd sync --flush-only" which fails in a worktree because:
// 1. The worktree's .beads directory triggers hook execution
// 2. But bd sync fails in the worktree context (wrong database path)
// 3. This causes the hook to exit 1, failing the commit
//
// Fix: Add --no-verify to gitCommitInWorktree to skip hooks, matching the
// behavior of the library function in internal/syncbranch/worktree.go
//
// This test verifies that sync branch commits succeed even when a failing
// pre-commit hook is present.
func TestSyncBranchCommitAndPush_WithPreCommitHook(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
tmpDir := t.TempDir()
initTestGitRepo(t, tmpDir)
initMainBranch(t, tmpDir)
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, "test.db")
store, err := sqlite.New(context.Background(), testDBPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
// Set global dbPath so findJSONLPath() works
oldDBPath := dbPath
defer func() { dbPath = oldDBPath }()
dbPath = testDBPath
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
syncBranch := "beads-sync"
if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Create a pre-commit hook that simulates the bd pre-commit hook behavior.
// The actual bd hook runs "bd sync --flush-only" which fails in worktree context.
// We simulate this by creating a hook that:
// 1. Checks if .beads directory exists (like bd hook does)
// 2. If yes, exits with error 1 (simulating bd sync failure)
// Without --no-verify, this would cause gitCommitInWorktree to fail.
hooksDir := filepath.Join(tmpDir, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0755); err != nil {
t.Fatalf("Failed to create hooks dir: %v", err)
}
preCommitHook := filepath.Join(hooksDir, "pre-commit")
hookScript := `#!/bin/sh
# Simulates bd pre-commit hook behavior that fails in worktree context
# The real hook runs "bd sync --flush-only" which fails in worktrees
if [ -d .beads ]; then
echo "Error: Simulated pre-commit hook failure (bd sync would fail here)" >&2
exit 1
fi
exit 0
`
if err := os.WriteFile(preCommitHook, []byte(hookScript), 0755); err != nil {
t.Fatalf("Failed to write pre-commit hook: %v", err)
}
// Add a dummy remote so hasGitRemote() returns true
// (syncBranchCommitAndPush skips if no remote is configured)
runGitCmd(t, tmpDir, "remote", "add", "origin", "https://example.com/dummy.git")
// Create a test issue
issue := &types.Issue{
Title: "Test with pre-commit hook",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
// Export to JSONL
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export: %v", err)
}
t.Chdir(tmpDir)
log, logMsgs := newTestSyncBranchLogger()
// This is the critical test: with the fix (--no-verify), this should succeed.
// Without the fix, this would fail because the pre-commit hook exits 1.
committed, err := syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed with pre-commit hook present: %v\n"+
"This indicates the --no-verify flag is missing from gitCommitInWorktree.\n"+
"Logs: %s", err, *logMsgs)
}
if !committed {
t.Error("Expected committed=true with pre-commit hook present")
}
// Verify worktree was created
worktreePath := filepath.Join(tmpDir, ".git", "beads-worktrees", syncBranch)
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Errorf("Worktree not created at %s", worktreePath)
}
// Verify JSONL was synced to worktree
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
if _, err := os.Stat(worktreeJSONL); os.IsNotExist(err) {
t.Error("JSONL not synced to worktree")
}
// Verify commit was made in worktree
cmd := exec.Command("git", "-C", worktreePath, "log", "--oneline", "-1")
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get log: %v", err)
}
if !strings.Contains(string(output), "bd daemon sync") {
t.Errorf("Expected commit message with 'bd daemon sync', got: %s", string(output))
}
// Test multiple commits to ensure hook is consistently bypassed
for i := 0; i < 3; i++ {
// Update issue to create new changes
if err := store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
"priority": (i % 4) + 1,
}, "test"); err != nil {
t.Fatalf("Failed to update issue on iteration %d: %v", i, err)
}
if err := exportToJSONLWithStore(ctx, store, jsonlPath); err != nil {
t.Fatalf("Failed to export on iteration %d: %v", i, err)
}
committed, err = syncBranchCommitAndPush(ctx, store, false, log)
if err != nil {
t.Fatalf("syncBranchCommitAndPush failed on iteration %d: %v", i, err)
}
if !committed {
t.Errorf("Expected committed=true on iteration %d", i)
}
}
// Verify we have multiple commits (initial sync branch commit + 1 initial + 3 updates)
cmd = exec.Command("git", "-C", worktreePath, "rev-list", "--count", "HEAD")
output, err = cmd.Output()
if err != nil {
t.Fatalf("Failed to count commits: %v", err)
}
commitCount := strings.TrimSpace(string(output))
// At least 4 commits expected (may be more due to sync branch initialization)
if commitCount == "0" || commitCount == "1" {
t.Errorf("Expected multiple commits, got %s", commitCount)
}
t.Log("Pre-commit hook regression test passed: --no-verify correctly bypasses hooks")
}
// initMainBranch creates an initial commit on main branch
// The JSONL file should not exist yet when this is called
func initMainBranch(t *testing.T, dir string) {
t.Helper()
// Create a simple README to have something to commit
readme := filepath.Join(dir, "README.md")
if err := os.WriteFile(readme, []byte("# Test Repository\n"), 0644); err != nil {
t.Fatalf("Failed to write README: %v", err)
}
runGitCmd(t, dir, "add", "README.md")
runGitCmd(t, dir, "commit", "-m", "Initial commit")
}
// TestGitPushFromWorktree_FetchRebaseRetry tests that gitPushFromWorktree handles
// the case where the remote has newer commits by fetching, rebasing, and retrying.
// This is a regression test for the bug where daemon push would fail with
// "fetch first" error when another clone had pushed to the sync branch.
//
// Bug scenario:
// 1. Clone A pushes commit X to sync branch
// 2. Clone B has local commit Y (not based on X)
// 3. Clone B's push fails with "fetch first" error
// 4. Without this fix: daemon logs failure and stops
// 5. With this fix: daemon fetches, rebases Y on X, and retries push
func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Skip on Windows due to path issues
if runtime.GOOS == "windows" {
t.Skip("Skipping on Windows")
}
ctx := context.Background()
// Create a "remote" bare repository
remoteDir := t.TempDir()
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
// Create first clone (simulates another developer's clone)
clone1Dir := t.TempDir()
runGitCmd(t, clone1Dir, "clone", remoteDir, ".")
runGitCmd(t, clone1Dir, "config", "user.email", "test@example.com")
runGitCmd(t, clone1Dir, "config", "user.name", "Test User")
// Create initial commit on main
initMainBranch(t, clone1Dir)
runGitCmd(t, clone1Dir, "push", "-u", "origin", "main")
// Create sync branch in clone1
runGitCmd(t, clone1Dir, "checkout", "-b", "beads-sync")
beadsDir1 := filepath.Join(clone1Dir, ".beads")
if err := os.MkdirAll(beadsDir1, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
jsonl1 := filepath.Join(beadsDir1, "issues.jsonl")
if err := os.WriteFile(jsonl1, []byte(`{"id":"clone1-issue","title":"Issue from clone1"}`+"\n"), 0644); err != nil {
t.Fatalf("Failed to write JSONL: %v", err)
}
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
runGitCmd(t, clone1Dir, "commit", "-m", "Clone 1 commit")
runGitCmd(t, clone1Dir, "push", "-u", "origin", "beads-sync")
// Create second clone (simulates our local clone)
clone2Dir := t.TempDir()
runGitCmd(t, clone2Dir, "clone", remoteDir, ".")
runGitCmd(t, clone2Dir, "config", "user.email", "test@example.com")
runGitCmd(t, clone2Dir, "config", "user.name", "Test User")
// Create worktree for sync branch in clone2
worktreePath := filepath.Join(clone2Dir, ".git", "beads-worktrees", "beads-sync")
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
t.Fatalf("Failed to create worktree parent: %v", err)
}
// Fetch the sync branch first
runGitCmd(t, clone2Dir, "fetch", "origin", "beads-sync:beads-sync")
// Create worktree - but don't pull latest yet (to simulate diverged state)
runGitCmd(t, clone2Dir, "worktree", "add", worktreePath, "beads-sync")
// Now clone1 makes another commit and pushes (simulating another clone pushing)
runGitCmd(t, clone1Dir, "checkout", "beads-sync")
if err := os.WriteFile(jsonl1, []byte(`{"id":"clone1-issue","title":"Issue from clone1"}`+"\n"+`{"id":"clone1-issue2","title":"Second issue"}`+"\n"), 0644); err != nil {
t.Fatalf("Failed to update JSONL: %v", err)
}
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
runGitCmd(t, clone1Dir, "commit", "-m", "Clone 1 second commit")
runGitCmd(t, clone1Dir, "push", "origin", "beads-sync")
// Clone2's worktree makes a different commit (diverged from remote)
// We create a different file to avoid merge conflicts - this simulates
// non-conflicting JSONL changes (e.g., different issues being created)
beadsDir2 := filepath.Join(worktreePath, ".beads")
if err := os.MkdirAll(beadsDir2, 0755); err != nil {
t.Fatalf("Failed to create .beads in worktree: %v", err)
}
// Create a separate metadata file to avoid JSONL conflict
metadataPath := filepath.Join(beadsDir2, "metadata.json")
if err := os.WriteFile(metadataPath, []byte(`{"clone":"clone2"}`+"\n"), 0644); err != nil {
t.Fatalf("Failed to write metadata in worktree: %v", err)
}
runGitCmd(t, worktreePath, "add", ".beads/metadata.json")
runGitCmd(t, worktreePath, "commit", "-m", "Clone 2 commit")
// Now try to push from worktree - this should trigger the fetch-rebase-retry logic
// because the remote has commits that the local worktree doesn't have
err := gitPushFromWorktree(ctx, worktreePath, "beads-sync", "")
if err != nil {
t.Fatalf("gitPushFromWorktree failed: %v (expected fetch-rebase-retry to succeed)", err)
}
// Verify the push succeeded by checking the remote has all commits
cmd := exec.Command("git", "-C", remoteDir, "rev-list", "--count", "beads-sync")
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to count commits: %v", err)
}
commitCount := strings.TrimSpace(string(output))
// Should have at least 3 commits: initial sync, clone1's second commit, clone2's rebased commit
if commitCount == "0" || commitCount == "1" || commitCount == "2" {
t.Errorf("Expected at least 3 commits after rebase-push, got %s", commitCount)
}
t.Log("Fetch-rebase-retry test passed: diverged sync branch was successfully rebased and pushed")
}
// TestDaemonSyncBranchE2E tests the daemon sync-branch flow with concurrent changes from
// two machines using a real bare repo. This tests the daemon path (syncBranchCommitAndPush/Pull)
// as opposed to TestSyncBranchE2E which tests the CLI path (syncbranch.CommitToSyncBranch/Pull).
//
// Key difference from CLI path tests:
// - CLI: Uses syncbranch.CommitToSyncBranch() from internal/syncbranch
// - Daemon: Uses syncBranchCommitAndPush() from daemon_sync_branch.go
//
// Flow:
// 1. Machine A creates bd-1, calls daemon syncBranchCommitAndPush(), pushes to bare remote
// 2. Machine B creates bd-2, calls daemon syncBranchCommitAndPush(), pushes to bare remote
// 3. Machine A calls daemon syncBranchPull(), should merge both issues
// 4. Verify both issues present after merge
func TestDaemonSyncBranchE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Skip on Windows due to path issues with worktrees
if runtime.GOOS == "windows" {
t.Skip("Skipping on Windows")
}
ctx := context.Background()
// Setup: Create bare remote with two clones using Phase 1 helper
_, machineA, machineB, cleanup := setupBareRemoteWithClones(t)
defer cleanup()
// Use unique sync branch name and set via env var (highest priority)
// This overrides any config.yaml setting
syncBranch := "beads-daemon-sync"
t.Setenv(syncbranch.EnvVar, syncBranch)
// Machine A: Setup database with sync.branch configured
var storeA *sqlite.SQLiteStorage
var jsonlPathA string
withBeadsDir(t, machineA, func() {
beadsDirA := filepath.Join(machineA, ".beads")
dbPathA := filepath.Join(beadsDirA, "beads.db")
var err error
storeA, err = sqlite.New(ctx, dbPathA)
if err != nil {
t.Fatalf("Failed to create store for Machine A: %v", err)
}
// Configure store
if err := storeA.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
if err := storeA.SetConfig(ctx, syncbranch.ConfigKey, syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Create issue in Machine A
issueA := &types.Issue{
Title: "Issue from Machine A (daemon path)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := storeA.CreateIssue(ctx, issueA, "machineA"); err != nil {
t.Fatalf("Failed to create issue in Machine A: %v", err)
}
t.Logf("Machine A created issue: %s", issueA.ID)
// Export to JSONL
jsonlPathA = filepath.Join(beadsDirA, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, storeA, jsonlPathA); err != nil {
t.Fatalf("Failed to export JSONL for Machine A: %v", err)
}
// Change to machineA directory for git operations
if err := os.Chdir(machineA); err != nil {
t.Fatalf("Failed to chdir to machineA: %v", err)
}
// Set global dbPath so findJSONLPath() works for daemon functions
oldDBPath := dbPath
dbPath = dbPathA
defer func() { dbPath = oldDBPath }()
// Machine A: Commit and push using daemon path (syncBranchCommitAndPush)
log, _ := newTestSyncBranchLogger()
committed, err := syncBranchCommitAndPush(ctx, storeA, true, log)
if err != nil {
t.Fatalf("Machine A syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Fatal("Expected Machine A daemon commit to succeed")
}
t.Log("Machine A: Daemon committed and pushed issue to sync branch")
})
defer storeA.Close()
// Reset git caches before switching to Machine B to prevent path caching issues
git.ResetCaches()
// Machine B: Setup database and sync with Machine A's changes first
var storeB *sqlite.SQLiteStorage
var jsonlPathB string
withBeadsDir(t, machineB, func() {
beadsDirB := filepath.Join(machineB, ".beads")
dbPathB := filepath.Join(beadsDirB, "beads.db")
var err error
storeB, err = sqlite.New(ctx, dbPathB)
if err != nil {
t.Fatalf("Failed to create store for Machine B: %v", err)
}
// Configure store
if err := storeB.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
if err := storeB.SetConfig(ctx, syncbranch.ConfigKey, syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
jsonlPathB = filepath.Join(beadsDirB, "issues.jsonl")
// Change to machineB directory for git operations
if err := os.Chdir(machineB); err != nil {
t.Fatalf("Failed to chdir to machineB: %v", err)
}
// Set global dbPath so findJSONLPath() works for daemon functions
oldDBPath := dbPath
dbPath = dbPathB
defer func() { dbPath = oldDBPath }()
// Machine B: First pull from sync branch to get Machine A's issue
// This is the correct workflow - always pull before creating local changes
log, _ := newTestSyncBranchLogger()
pulled, err := syncBranchPull(ctx, storeB, log)
if err != nil {
t.Logf("Machine B initial pull error (may be expected): %v", err)
}
if pulled {
t.Log("Machine B: Pulled Machine A's changes from sync branch")
}
// Import the pulled JSONL into Machine B's database
if _, err := os.Stat(jsonlPathB); err == nil {
if err := importToJSONLWithStore(ctx, storeB, jsonlPathB); err != nil {
t.Logf("Machine B import warning: %v", err)
}
}
// Create issue in Machine B (different from A)
issueB := &types.Issue{
Title: "Issue from Machine B (daemon path)",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now().Add(time.Second), // Ensure different timestamp
UpdatedAt: time.Now().Add(time.Second),
}
if err := storeB.CreateIssue(ctx, issueB, "machineB"); err != nil {
t.Fatalf("Failed to create issue in Machine B: %v", err)
}
t.Logf("Machine B created issue: %s", issueB.ID)
// Export to JSONL (now includes both Machine A's and Machine B's issues)
if err := exportToJSONLWithStore(ctx, storeB, jsonlPathB); err != nil {
t.Fatalf("Failed to export JSONL for Machine B: %v", err)
}
// Machine B: Commit and push using daemon path
// This should succeed without conflict because we pulled first
committed, err := syncBranchCommitAndPush(ctx, storeB, true, log)
if err != nil {
t.Fatalf("Machine B syncBranchCommitAndPush failed: %v", err)
}
if !committed {
t.Fatal("Expected Machine B daemon commit to succeed")
}
t.Log("Machine B: Daemon committed and pushed issue to sync branch")
})
defer storeB.Close()
// Reset git caches before switching back to Machine A
git.ResetCaches()
// Machine A: Pull from sync branch using daemon path
withBeadsDir(t, machineA, func() {
beadsDirA := filepath.Join(machineA, ".beads")
dbPathA := filepath.Join(beadsDirA, "beads.db")
// Change to machineA directory for git operations
if err := os.Chdir(machineA); err != nil {
t.Fatalf("Failed to chdir to machineA: %v", err)
}
// Set global dbPath so findJSONLPath() works for daemon functions
oldDBPath := dbPath
dbPath = dbPathA
defer func() { dbPath = oldDBPath }()
log, _ := newTestSyncBranchLogger()
pulled, err := syncBranchPull(ctx, storeA, log)
if err != nil {
t.Fatalf("Machine A syncBranchPull failed: %v", err)
}
if !pulled {
t.Log("Machine A syncBranchPull returned false (may be expected if no remote changes)")
} else {
t.Log("Machine A: Daemon pulled from sync branch")
}
})
// Verify: Both issues should be present in Machine A's JSONL after merge
content, err := os.ReadFile(jsonlPathA)
if err != nil {
t.Fatalf("Failed to read Machine A's JSONL: %v", err)
}
contentStr := string(content)
hasMachineA := strings.Contains(contentStr, "Machine A")
hasMachineB := strings.Contains(contentStr, "Machine B")
if hasMachineA {
t.Log("Issue from Machine A preserved in JSONL")
} else {
t.Error("FAIL: Issue from Machine A missing after merge")
}
if hasMachineB {
t.Log("Issue from Machine B merged into JSONL")
} else {
t.Error("FAIL: Issue from Machine B missing after merge")
}
if hasMachineA && hasMachineB {
t.Log("Daemon sync-branch E2E test PASSED: both issues present after merge")
}
// Clean up git caches to prevent test pollution
git.ResetCaches()
}
// TestDaemonSyncBranchForceOverwrite tests the forceOverwrite flag behavior for delete mutations.
// When forceOverwrite is true, the local JSONL is copied directly to the worktree without merging,
// which is necessary for delete mutations to be properly reflected in the sync branch.
//
// Flow:
// 1. Machine A creates issue, commits to sync branch
// 2. Machine A deletes issue locally, calls syncBranchCommitAndPushWithOptions(forceOverwrite=true)
// 3. Verify the deletion is reflected in the sync branch worktree
func TestDaemonSyncBranchForceOverwrite(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Skip on Windows due to path issues with worktrees
if runtime.GOOS == "windows" {
t.Skip("Skipping on Windows")
}
ctx := context.Background()
// Setup: Create bare remote with two clones
_, machineA, _, cleanup := setupBareRemoteWithClones(t)
defer cleanup()
// Use unique sync branch name and set via env var (highest priority)
// This overrides any config.yaml setting
syncBranch := "beads-force-sync"
t.Setenv(syncbranch.EnvVar, syncBranch)
withBeadsDir(t, machineA, func() {
beadsDirA := filepath.Join(machineA, ".beads")
dbPathA := filepath.Join(beadsDirA, "beads.db")
storeA, err := sqlite.New(ctx, dbPathA)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer storeA.Close()
// Configure store
if err := storeA.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
if err := storeA.SetConfig(ctx, syncbranch.ConfigKey, syncBranch); err != nil {
t.Fatalf("Failed to set sync.branch: %v", err)
}
// Create two issues
issue1 := &types.Issue{
Title: "Issue to keep",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := storeA.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create issue1: %v", err)
}
issue2 := &types.Issue{
Title: "Issue to delete",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := storeA.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("Failed to create issue2: %v", err)
}
issue2ID := issue2.ID
t.Logf("Created issue to delete: %s", issue2ID)
// Export to JSONL
jsonlPath := filepath.Join(beadsDirA, "issues.jsonl")
if err := exportToJSONLWithStore(ctx, storeA, jsonlPath); err != nil {
t.Fatalf("Failed to export JSONL: %v", err)
}
// Change to machineA directory for git operations
if err := os.Chdir(machineA); err != nil {
t.Fatalf("Failed to chdir: %v", err)
}
// Set global dbPath so findJSONLPath() works for daemon functions
oldDBPath := dbPath
dbPath = dbPathA
defer func() { dbPath = oldDBPath }()
// First commit with both issues (without forceOverwrite)
log, _ := newTestSyncBranchLogger()
committed, err := syncBranchCommitAndPush(ctx, storeA, true, log)
if err != nil {
t.Fatalf("Initial commit failed: %v", err)
}
if !committed {
t.Fatal("Expected initial commit to succeed")
}
t.Log("Initial commit with both issues succeeded")
// Verify worktree has both issues
worktreePath := filepath.Join(machineA, ".git", "beads-worktrees", syncBranch)
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
initialContent, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read worktree JSONL: %v", err)
}
if !strings.Contains(string(initialContent), "Issue to delete") {
t.Error("Initial worktree JSONL should contain 'Issue to delete'")
}
// Now delete the issue from database
if err := storeA.DeleteIssue(ctx, issue2ID); err != nil {
t.Fatalf("Failed to delete issue: %v", err)
}
t.Logf("Deleted issue %s from database", issue2ID)
// Export JSONL after deletion (issue2 should not be in the file)
if err := exportToJSONLWithStore(ctx, storeA, jsonlPath); err != nil {
t.Fatalf("Failed to export JSONL after deletion: %v", err)
}
// Verify local JSONL no longer has the deleted issue
localContent, err := os.ReadFile(jsonlPath)
if err != nil {
t.Fatalf("Failed to read local JSONL: %v", err)
}
if strings.Contains(string(localContent), "Issue to delete") {
t.Error("Local JSONL should not contain deleted issue")
}
// Commit with forceOverwrite=true (simulating delete mutation)
committed, err = syncBranchCommitAndPushWithOptions(ctx, storeA, true, true, log)
if err != nil {
t.Fatalf("forceOverwrite commit failed: %v", err)
}
if !committed {
t.Fatal("Expected forceOverwrite commit to succeed")
}
t.Log("forceOverwrite commit succeeded")
// Verify worktree JSONL no longer has the deleted issue
afterContent, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read worktree JSONL after forceOverwrite: %v", err)
}
if strings.Contains(string(afterContent), "Issue to delete") {
t.Error("FAIL: Worktree JSONL still contains deleted issue after forceOverwrite")
} else {
t.Log("Worktree JSONL correctly reflects deletion after forceOverwrite")
}
if !strings.Contains(string(afterContent), "Issue to keep") {
t.Error("FAIL: Worktree JSONL should still contain 'Issue to keep'")
} else {
t.Log("Worktree JSONL correctly preserves non-deleted issue")
}
t.Log("forceOverwrite test PASSED: delete mutation correctly propagated")
})
// Clean up git caches to prevent test pollution
git.ResetCaches()
}