* 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.
3044 lines
94 KiB
Go
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()
|
|
}
|