Files
beads/cmd/bd/daemon_sync_branch_test.go
Charles P. Cross ee016bbb25 fix(daemon): handle diverged sync branch with fetch-rebase-retry on push (#697)
* fix(daemon): handle diverged sync branch with fetch-rebase-retry on push

When pushing to the sync branch, if the remote has newer commits that
the local worktree doesn't have, the push would fail with "fetch first"
error and the daemon would log the failure without recovery.

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

This fix adds fetch-rebase-retry logic to gitPushFromWorktree():
1. Detect push rejection due to remote having newer commits
2. Fetch the latest remote sync branch
3. Rebase local commits on top of remote
4. Retry the push

If rebase fails (e.g., due to conflicts), the rebase is aborted and
an error is returned with helpful context.

This allows multiple clones to push to the same sync branch without
manual intervention, as long as the changes don't conflict.

Adds integration test TestGitPushFromWorktree_FetchRebaseRetry that
verifies the fetch-rebase-retry behavior with diverged branches.

* fix: resolve all golangci-lint errors (cherry-pick from fix/linting-errors)

Cherry-picked linting fixes to ensure CI passes.

---------

Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
2025-12-22 14:17:26 -08:00

1548 lines
47 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/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")
// 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")
// 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) {
messages := ""
logger := daemonLogger{
logFunc: func(format string, args ...interface{}) {
messages += "\n" + format
},
}
return logger, &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")
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")
// 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")
}