* 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>
1548 lines
47 KiB
Go
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")
|
|
}
|