Files
beads/cmd/bd/periodic_remote_sync_test.go
Charles P. Cross 03f5afb605 feat(daemon): add auto_pull config parameter for periodic remote sync
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
2025-12-22 18:47:18 -05:00

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")
}