Files
beads/cmd/bd/daemon_sync_branch_test.go
Peter Chanthamynavong dbd505656d fix(daemon): add sync-branch guard to daemon code paths (#1271)
* fix(daemon): skip export when sync-branch matches current

Prevent redundant export operations by checking if the daemon's sync
branch matches the current active branch.

Previously, the daemon would attempt to perform an export even when
already on the target branch. This logic now skips the export step in
such cases to avoid unnecessary overhead and potential conflicts.
Includes a new integration test to verify the guard logic.

* fix(daemon): prevent sync on guarded branches

Add checks to verify if a branch is guarded before performing automated
sync cycles, auto-imports, or branch-specific commit and pull operations.
This prevents the daemon from modifying protected branches or running
synchronization tasks where they are restricted.

Includes comprehensive integration tests to verify the guard logic
during sync-branch operations.

* fix(daemon): warn on sync branch misconfiguration at startup

The daemon now checks for sync branch name conflicts during its startup
loop. This provides early feedback if the sync branch is configured
in a way that might conflict with existing branches or other settings.

The warnIfSyncBranchMisconfigured function performs the validation
and logs a warning to the console. Integration tests verify that
the daemon correctly identifies and reports these misconfigurations
at initialization.
2026-01-24 17:10:08 -08:00

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