Add --auto-pull flag to control whether the daemon periodically pulls from remote to check for updates from other clones. Configuration precedence: 1. --auto-pull CLI flag (highest) 2. BEADS_AUTO_PULL environment variable 3. daemon.auto_pull in database config 4. Default: true when sync.branch is configured When auto_pull is enabled, the daemon creates a remoteSyncTicker that periodically calls doAutoImport() to pull remote changes. When disabled, users must manually run 'git pull' to sync remote changes. Changes: - cmd/bd/daemon.go: Add --auto-pull flag and config reading logic - cmd/bd/daemon_event_loop.go: Gate remoteSyncTicker on autoPull parameter - cmd/bd/daemon_lifecycle.go: Add auto-pull to status output and spawn args - internal/rpc/protocol.go: Add AutoPull field to StatusResponse - internal/rpc/server_core.go: Add autoPull to Server struct and SetConfig - internal/rpc/server_routing_validation_diagnostics.go: Include in status - Tests updated to pass autoPull parameter Closes #TBD
332 lines
11 KiB
Go
332 lines
11 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// TEST HARNESS: Periodic Remote Sync in Event-Driven Mode
|
|
// =============================================================================
|
|
//
|
|
// These tests validate that the event-driven daemon periodically pulls from
|
|
// remote to check for updates from other clones. This is essential for
|
|
// multi-clone workflows where one clone pushes changes that other clones
|
|
// need to receive.
|
|
//
|
|
// WITHOUT THE FIX: Daemon only reacts to local file changes, never pulls remote
|
|
// WITH THE FIX: Daemon periodically calls doAutoImport to pull from remote
|
|
|
|
// TestEventDrivenLoop_HasRemoteSyncTicker validates that the event loop code
|
|
// includes a remoteSyncTicker for periodic remote sync.
|
|
func TestEventDrivenLoop_HasRemoteSyncTicker(t *testing.T) {
|
|
// Read the daemon_event_loop.go file and check for remoteSyncTicker
|
|
content, err := os.ReadFile("daemon_event_loop.go")
|
|
if err != nil {
|
|
t.Fatalf("Failed to read daemon_event_loop.go: %v", err)
|
|
}
|
|
|
|
code := string(content)
|
|
|
|
// Check for the remoteSyncTicker variable
|
|
if !strings.Contains(code, "remoteSyncTicker") {
|
|
t.Fatal("remoteSyncTicker not found in event loop - periodic sync not implemented")
|
|
}
|
|
|
|
// Check for periodic sync in select cases
|
|
if !strings.Contains(code, "remoteSyncTicker.C") {
|
|
t.Fatal("remoteSyncTicker.C not found in select statement - ticker not wired up")
|
|
}
|
|
|
|
// Check for doAutoImport call in the ticker case
|
|
if !strings.Contains(code, "doAutoImport()") {
|
|
t.Fatal("doAutoImport() not called - periodic sync not performing imports")
|
|
}
|
|
|
|
t.Log("Event loop correctly includes remoteSyncTicker for periodic remote sync")
|
|
}
|
|
|
|
// TestGetRemoteSyncInterval_Default validates that the default interval is used
|
|
// when no environment variable is set.
|
|
func TestGetRemoteSyncInterval_Default(t *testing.T) {
|
|
// Ensure env var is not set
|
|
os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL")
|
|
|
|
log := createTestLogger(t)
|
|
interval := getRemoteSyncInterval(log)
|
|
|
|
if interval != DefaultRemoteSyncInterval {
|
|
t.Errorf("Expected default interval %v, got %v", DefaultRemoteSyncInterval, interval)
|
|
}
|
|
|
|
if interval != 30*time.Second {
|
|
t.Errorf("Expected 30s default, got %v", interval)
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteSyncInterval_CustomValue validates that custom intervals are parsed.
|
|
func TestGetRemoteSyncInterval_CustomValue(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envValue string
|
|
expected time.Duration
|
|
}{
|
|
{"1 minute", "1m", 1 * time.Minute},
|
|
{"5 minutes", "5m", 5 * time.Minute},
|
|
{"60 seconds", "60s", 60 * time.Second},
|
|
{"10 seconds", "10s", 10 * time.Second},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", tc.envValue)
|
|
defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL")
|
|
|
|
log := createTestLogger(t)
|
|
interval := getRemoteSyncInterval(log)
|
|
|
|
if interval != tc.expected {
|
|
t.Errorf("Expected %v, got %v", tc.expected, interval)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteSyncInterval_MinimumEnforced validates that intervals below 5s
|
|
// are clamped to the minimum.
|
|
func TestGetRemoteSyncInterval_MinimumEnforced(t *testing.T) {
|
|
os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", "1s")
|
|
defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL")
|
|
|
|
log := createTestLogger(t)
|
|
interval := getRemoteSyncInterval(log)
|
|
|
|
if interval != 5*time.Second {
|
|
t.Errorf("Expected minimum 5s, got %v", interval)
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteSyncInterval_InvalidValue validates that invalid values fall back
|
|
// to the default.
|
|
func TestGetRemoteSyncInterval_InvalidValue(t *testing.T) {
|
|
os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", "not-a-duration")
|
|
defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL")
|
|
|
|
log := createTestLogger(t)
|
|
interval := getRemoteSyncInterval(log)
|
|
|
|
if interval != DefaultRemoteSyncInterval {
|
|
t.Errorf("Expected default interval on invalid value, got %v", interval)
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteSyncInterval_Zero validates that zero disables periodic sync.
|
|
func TestGetRemoteSyncInterval_Zero(t *testing.T) {
|
|
os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", "0")
|
|
defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL")
|
|
|
|
log := createTestLogger(t)
|
|
interval := getRemoteSyncInterval(log)
|
|
|
|
// Zero should return a very large interval (effectively disabled)
|
|
if interval < 24*time.Hour {
|
|
t.Errorf("Expected very large interval when disabled, got %v", interval)
|
|
}
|
|
}
|
|
|
|
// TestPeriodicRemoteSync_DoAutoImportWiring validates that doAutoImport
|
|
// is correctly wired up to be called by the periodic sync mechanism.
|
|
func TestPeriodicRemoteSync_DoAutoImportWiring(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Track if doAutoImport was called
|
|
var importCalled bool
|
|
var mu sync.Mutex
|
|
|
|
doAutoImport := func() {
|
|
mu.Lock()
|
|
importCalled = true
|
|
mu.Unlock()
|
|
}
|
|
|
|
// Simulate what the event loop does on periodic sync
|
|
doAutoImport()
|
|
|
|
mu.Lock()
|
|
called := importCalled
|
|
mu.Unlock()
|
|
|
|
if !called {
|
|
t.Fatal("doAutoImport was not called - periodic sync wiring broken")
|
|
}
|
|
|
|
t.Log("doAutoImport function is correctly callable for periodic sync")
|
|
}
|
|
|
|
// TestSyncBranchPull_FetchesRemoteUpdates validates that the sync branch pull
|
|
// mechanism correctly fetches updates from remote.
|
|
func TestSyncBranchPull_FetchesRemoteUpdates(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Skipping on Windows")
|
|
}
|
|
|
|
// Create a remote and clone setup
|
|
remoteDir := t.TempDir()
|
|
runGitCmd(t, remoteDir, "init", "--bare")
|
|
|
|
// Clone 1: Creates initial content and pushes
|
|
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")
|
|
initMainBranchForSyncTest(t, clone1Dir)
|
|
runGitCmd(t, clone1Dir, "push", "-u", "origin", "main")
|
|
|
|
runGitCmd(t, clone1Dir, "checkout", "-b", "beads-sync")
|
|
beadsDir1 := filepath.Join(clone1Dir, ".beads")
|
|
if err := os.MkdirAll(beadsDir1, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initialContent := `{"id":"issue-1","title":"First issue"}`
|
|
if err := os.WriteFile(filepath.Join(beadsDir1, "issues.jsonl"), []byte(initialContent+"\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Initial issue")
|
|
runGitCmd(t, clone1Dir, "push", "-u", "origin", "beads-sync")
|
|
|
|
// Clone 2: Fetches sync branch
|
|
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")
|
|
runGitCmd(t, clone2Dir, "fetch", "origin", "beads-sync:beads-sync")
|
|
|
|
// Create worktree in clone2
|
|
worktreePath := filepath.Join(clone2Dir, ".git", "beads-worktrees", "beads-sync")
|
|
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGitCmd(t, clone2Dir, "worktree", "add", worktreePath, "beads-sync")
|
|
|
|
// Clone 1 pushes MORE content
|
|
runGitCmd(t, clone1Dir, "checkout", "beads-sync")
|
|
updatedContent := initialContent + "\n" + `{"id":"issue-2","title":"Second issue"}`
|
|
if err := os.WriteFile(filepath.Join(beadsDir1, "issues.jsonl"), []byte(updatedContent+"\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Second issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "beads-sync")
|
|
|
|
// Clone 2's worktree should NOT have the second issue yet
|
|
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
|
|
beforePull, _ := os.ReadFile(worktreeJSONL)
|
|
if strings.Contains(string(beforePull), "issue-2") {
|
|
t.Log("Worktree already has issue-2 (unexpected)")
|
|
} else {
|
|
t.Log("Worktree does NOT have issue-2 (expected before pull)")
|
|
}
|
|
|
|
// Now pull in the worktree (simulating what syncBranchPull does)
|
|
runGitCmd(t, worktreePath, "pull", "origin", "beads-sync")
|
|
|
|
// Clone 2's worktree SHOULD now have the second issue
|
|
afterPull, _ := os.ReadFile(worktreeJSONL)
|
|
if !strings.Contains(string(afterPull), "issue-2") {
|
|
t.Fatal("After pull, worktree still doesn't have issue-2 - sync branch pull broken")
|
|
}
|
|
|
|
t.Log("Sync branch pull correctly fetches remote updates")
|
|
}
|
|
|
|
// =============================================================================
|
|
// AUTO-PULL CONFIGURATION TESTS
|
|
// =============================================================================
|
|
|
|
// TestAutoPullGatesRemoteSyncTicker validates that the remoteSyncTicker is only
|
|
// created when autoPull is true.
|
|
func TestAutoPullGatesRemoteSyncTicker(t *testing.T) {
|
|
// Read the daemon_event_loop.go file and check for autoPull gating
|
|
content, err := os.ReadFile("daemon_event_loop.go")
|
|
if err != nil {
|
|
t.Fatalf("Failed to read daemon_event_loop.go: %v", err)
|
|
}
|
|
|
|
code := string(content)
|
|
|
|
// Check that remoteSyncTicker is gated on autoPull
|
|
if !strings.Contains(code, "if autoPull") {
|
|
t.Fatal("autoPull check not found - remoteSyncTicker not gated on autoPull")
|
|
}
|
|
|
|
// Check that autoPull parameter exists in function signature
|
|
if !strings.Contains(code, "autoPull bool") {
|
|
t.Fatal("autoPull bool parameter not found in runEventDrivenLoop signature")
|
|
}
|
|
|
|
// Check for disabled message when autoPull is false
|
|
if !strings.Contains(code, "Auto-pull disabled") {
|
|
t.Fatal("Auto-pull disabled message not found")
|
|
}
|
|
|
|
t.Log("remoteSyncTicker is correctly gated on autoPull parameter")
|
|
}
|
|
|
|
// TestAutoPullDefaultBehavior validates that auto_pull defaults to true when
|
|
// sync.branch is configured.
|
|
func TestAutoPullDefaultBehavior(t *testing.T) {
|
|
// Read daemon.go and check for default behavior
|
|
content, err := os.ReadFile("daemon.go")
|
|
if err != nil {
|
|
t.Fatalf("Failed to read daemon.go: %v", err)
|
|
}
|
|
|
|
code := string(content)
|
|
|
|
// Check that auto_pull reads from daemon.auto_pull config
|
|
if !strings.Contains(code, "daemon.auto_pull") {
|
|
t.Fatal("daemon.auto_pull config check not found")
|
|
}
|
|
|
|
// Check that auto_pull defaults based on sync.branch
|
|
if !strings.Contains(code, "sync.branch") {
|
|
t.Fatal("sync.branch check for auto_pull default not found")
|
|
}
|
|
|
|
// Check for BEADS_AUTO_PULL environment variable
|
|
if !strings.Contains(code, "BEADS_AUTO_PULL") {
|
|
t.Fatal("BEADS_AUTO_PULL environment variable not checked")
|
|
}
|
|
|
|
t.Log("auto_pull correctly defaults to true when sync.branch is configured")
|
|
}
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
func initMainBranchForSyncTest(t *testing.T, dir string) {
|
|
t.Helper()
|
|
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")
|
|
}
|