diff --git a/cmd/bd/daemon_autostart.go b/cmd/bd/daemon_autostart.go index e914b8c2..89af0f41 100644 --- a/cmd/bd/daemon_autostart.go +++ b/cmd/bd/daemon_autostart.go @@ -28,6 +28,15 @@ func shouldAutoStartDaemon() bool { return false // Explicit opt-out } + // Check if we're in a git worktree without sync-branch configured. + // In this case, daemon is unsafe because all worktrees share the same + // .beads directory and the daemon would commit to the wrong branch. + // When sync-branch is configured, daemon is safe because commits go + // to a dedicated branch via an internal worktree. + if shouldDisableDaemonForWorktree() { + return false + } + // Use viper to read from config file or BEADS_AUTO_START_DAEMON env var // Viper handles BEADS_AUTO_START_DAEMON automatically via BindEnv return config.GetBool("auto-start-daemon") // Defaults to true @@ -389,6 +398,9 @@ func emitVerboseWarning() { fmt.Fprintf(os.Stderr, "Warning: Failed to auto-start daemon. Running in direct mode. Hint: bd daemon --status\n") case FallbackDaemonUnsupported: fmt.Fprintf(os.Stderr, "Warning: Daemon does not support this command yet. Running in direct mode. Hint: update daemon or use local mode.\n") + case FallbackWorktreeSafety: + // Don't warn - this is expected behavior. User can configure sync-branch to enable daemon. + return case FallbackFlagNoDaemon: // Don't warn when user explicitly requested --no-daemon return diff --git a/cmd/bd/main.go b/cmd/bd/main.go index a883fcb3..59d12761 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -45,6 +45,7 @@ const ( FallbackFlagNoDaemon = "flag_no_daemon" FallbackConnectFailed = "connect_failed" FallbackHealthFailed = "health_failed" + FallbackWorktreeSafety = "worktree_safety" cmdDaemon = "daemon" cmdImport = "import" statusHealthy = "healthy" @@ -397,10 +398,16 @@ var rootCmd = &cobra.Command{ FallbackReason: FallbackNone, } - // Try to connect to daemon first (unless --no-daemon flag is set) + // Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails) if noDaemon { daemonStatus.FallbackReason = FallbackFlagNoDaemon debug.Logf("--no-daemon flag set, using direct mode") + } else if shouldDisableDaemonForWorktree() { + // In a git worktree without sync-branch configured - daemon is unsafe + // because all worktrees share the same .beads directory and the daemon + // would commit to whatever branch its working directory has checked out. + daemonStatus.FallbackReason = FallbackWorktreeSafety + debug.Logf("git worktree detected without sync-branch, using direct mode for safety") } else { // Attempt daemon connection client, err := rpc.TryConnect(socketPath) diff --git a/cmd/bd/worktree.go b/cmd/bd/worktree.go index 879a2e54..0b707b31 100644 --- a/cmd/bd/worktree.go +++ b/cmd/bd/worktree.go @@ -9,6 +9,7 @@ import ( "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/git" + "github.com/steveyegge/beads/internal/syncbranch" ) // isGitWorktree detects if the current directory is in a git worktree. @@ -17,6 +18,37 @@ func isGitWorktree() bool { return git.IsWorktree() } +// shouldDisableDaemonForWorktree returns true if daemon should be disabled +// due to being in a git worktree without sync-branch configured. +// +// The daemon is unsafe in worktrees because all worktrees share the same +// .beads directory, and the daemon commits to whatever branch its working +// directory has checked out - which can cause commits to go to the wrong branch. +// +// However, when sync-branch is configured, the daemon commits to a dedicated +// branch (e.g., "beads-metadata") using an internal worktree, so the user's +// current branch is never affected. This makes daemon mode safe in worktrees. +// +// Returns: +// - true: Disable daemon (in worktree without sync-branch) +// - false: Allow daemon (not in worktree, or sync-branch is configured) +func shouldDisableDaemonForWorktree() bool { + // If not in a worktree, daemon is safe + if !isGitWorktree() { + return false + } + + // In a worktree - check if sync-branch is configured + // IsConfiguredWithDB checks env var, config.yaml, AND database config + if syncbranch.IsConfiguredWithDB("") { + // Sync-branch is configured, daemon is safe (commits go to dedicated branch) + return false + } + + // In worktree without sync-branch - daemon is unsafe, disable it + return true +} + // gitRevParse runs git rev-parse with the given flag and returns the trimmed output. // This is a helper for CLI utilities that need git command execution. func gitRevParse(flag string) string { @@ -37,12 +69,24 @@ func getWorktreeGitDir() string { return gitDir } -// warnWorktreeDaemon prints a warning if using daemon with worktrees -// Call this only when daemon mode is actually active (connected) +// warnWorktreeDaemon prints a warning if using daemon with worktrees without sync-branch. +// Call this only when daemon mode is actually active (connected). +// +// With the new worktree safety logic, this warning should rarely appear because: +// - Daemon is auto-disabled in worktrees without sync-branch +// - When sync-branch is configured, daemon is safe (commits go to dedicated branch) +// +// This warning is kept as a safety net for edge cases where daemon might still +// be connected in a worktree (e.g., daemon started in main repo, then user cd's to worktree). func warnWorktreeDaemon(dbPathForWarning string) { if !isGitWorktree() { return } + + // If sync-branch is configured, daemon is safe in worktrees - no warning needed + if syncbranch.IsConfiguredWithDB("") { + return + } gitDir := getWorktreeGitDir() beadsDir := filepath.Dir(dbPathForWarning) @@ -61,11 +105,9 @@ func warnWorktreeDaemon(dbPathForWarning string) { fmt.Fprintf(os.Stderr, "║ Worktree git dir: %-54s ║\n", truncateForBox(gitDir, 54)) fmt.Fprintln(os.Stderr, "║ ║") fmt.Fprintln(os.Stderr, "║ RECOMMENDED SOLUTIONS: ║") - fmt.Fprintln(os.Stderr, "║ 1. Use --no-daemon flag: bd --no-daemon ║") - fmt.Fprintln(os.Stderr, "║ 2. Disable daemon mode: export BEADS_NO_DAEMON=1 ║") - fmt.Fprintln(os.Stderr, "║ ║") - fmt.Fprintln(os.Stderr, "║ Note: BEADS_AUTO_START_DAEMON=false only prevents auto-start; ║") - fmt.Fprintln(os.Stderr, "║ you can still connect to a running daemon. ║") + fmt.Fprintln(os.Stderr, "║ 1. Configure sync-branch: bd config set sync-branch beads-metadata ║") + fmt.Fprintln(os.Stderr, "║ 2. Use --no-daemon flag: bd --no-daemon ║") + fmt.Fprintln(os.Stderr, "║ 3. Disable daemon mode: export BEADS_NO_DAEMON=1 ║") fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════════════╝") fmt.Fprintln(os.Stderr) } diff --git a/cmd/bd/worktree_daemon_test.go b/cmd/bd/worktree_daemon_test.go new file mode 100644 index 00000000..6e793499 --- /dev/null +++ b/cmd/bd/worktree_daemon_test.go @@ -0,0 +1,409 @@ +package main + +import ( + "database/sql" + "os" + "os/exec" + "testing" + + "github.com/steveyegge/beads/internal/config" + + // Import SQLite driver for test database creation + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +// TestShouldDisableDaemonForWorktree tests the worktree daemon disable logic. +// The function should return true (disable daemon) when: +// - In a git worktree AND sync-branch is NOT configured +// The function should return false (allow daemon) when: +// - Not in a worktree (regular repo) +// - In a worktree but sync-branch IS configured +func TestShouldDisableDaemonForWorktree(t *testing.T) { + // Initialize config for tests + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + // Save and restore environment variables + origSyncBranch := os.Getenv("BEADS_SYNC_BRANCH") + defer func() { + if origSyncBranch != "" { + os.Setenv("BEADS_SYNC_BRANCH", origSyncBranch) + } else { + os.Unsetenv("BEADS_SYNC_BRANCH") + } + }() + + t.Run("returns false in regular repo without sync-branch", func(t *testing.T) { + // Create a regular git repo (not a worktree) using existing helper + repoPath, cleanup := setupGitRepo(t) + defer cleanup() + _ = repoPath // repoPath is the current directory after setupGitRepo + + // No sync-branch configured + os.Unsetenv("BEADS_SYNC_BRANCH") + + result := shouldDisableDaemonForWorktree() + if result { + t.Error("Expected shouldDisableDaemonForWorktree() to return false in regular repo") + } + }) + + t.Run("returns false in regular repo with sync-branch", func(t *testing.T) { + // Create a regular git repo (not a worktree) using existing helper + _, cleanup := setupGitRepo(t) + defer cleanup() + + // Sync-branch configured + os.Setenv("BEADS_SYNC_BRANCH", "beads-metadata") + + result := shouldDisableDaemonForWorktree() + if result { + t.Error("Expected shouldDisableDaemonForWorktree() to return false in regular repo with sync-branch") + } + }) + + t.Run("returns true in worktree without sync-branch", func(t *testing.T) { + // Create a git repo with a worktree + mainDir, worktreeDir := setupWorktreeTestRepo(t) + + // Change to the worktree directory + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + // Reinitialize config to restore original state + _ = config.Initialize() + }() + if err := os.Chdir(worktreeDir); err != nil { + t.Fatalf("Failed to change to worktree dir: %v", err) + } + + // No sync-branch configured + os.Unsetenv("BEADS_SYNC_BRANCH") + + // Reinitialize config to pick up the new directory's config.yaml + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to reinitialize config: %v", err) + } + + // Debug: verify we're actually in a worktree + isWorktree := isGitWorktree() + t.Logf("isGitWorktree() = %v, worktreeDir = %s", isWorktree, worktreeDir) + + result := shouldDisableDaemonForWorktree() + if !result { + t.Errorf("Expected shouldDisableDaemonForWorktree() to return true in worktree without sync-branch (isWorktree=%v)", isWorktree) + } + + // Cleanup + cleanupTestWorktree(t, mainDir, worktreeDir) + }) + + t.Run("returns false in worktree with sync-branch configured", func(t *testing.T) { + // Create a git repo with a worktree + mainDir, worktreeDir := setupWorktreeTestRepo(t) + + // Change to the worktree directory + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + _ = config.Initialize() + }() + if err := os.Chdir(worktreeDir); err != nil { + t.Fatalf("Failed to change to worktree dir: %v", err) + } + + // Reinitialize config to pick up the new directory's config.yaml + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to reinitialize config: %v", err) + } + + // Sync-branch configured via environment variable + os.Setenv("BEADS_SYNC_BRANCH", "beads-metadata") + + result := shouldDisableDaemonForWorktree() + if result { + t.Error("Expected shouldDisableDaemonForWorktree() to return false in worktree with sync-branch") + } + + // Cleanup + cleanupTestWorktree(t, mainDir, worktreeDir) + }) + + t.Run("returns false in worktree with sync-branch in database config", func(t *testing.T) { + // Create a git repo with a worktree AND a database with sync.branch config + mainDir, worktreeDir := setupWorktreeTestRepoWithDB(t, "beads-metadata") + + // Change to the worktree directory + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + _ = config.Initialize() + }() + if err := os.Chdir(worktreeDir); err != nil { + t.Fatalf("Failed to change to worktree dir: %v", err) + } + + // Reinitialize config to pick up the new directory's config.yaml + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to reinitialize config: %v", err) + } + + // NO env var or config.yaml sync-branch - only database config + os.Unsetenv("BEADS_SYNC_BRANCH") + + result := shouldDisableDaemonForWorktree() + if result { + t.Error("Expected shouldDisableDaemonForWorktree() to return false in worktree with sync-branch in database") + } + + // Cleanup + cleanupTestWorktree(t, mainDir, worktreeDir) + }) +} + +// TestShouldAutoStartDaemonWorktreeIntegration tests that shouldAutoStartDaemon +// respects the worktree+sync-branch logic. +func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) { + // Initialize config for tests + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + // Save and restore environment variables + origNoDaemon := os.Getenv("BEADS_NO_DAEMON") + origAutoStart := os.Getenv("BEADS_AUTO_START_DAEMON") + origSyncBranch := os.Getenv("BEADS_SYNC_BRANCH") + defer func() { + restoreTestEnv("BEADS_NO_DAEMON", origNoDaemon) + restoreTestEnv("BEADS_AUTO_START_DAEMON", origAutoStart) + restoreTestEnv("BEADS_SYNC_BRANCH", origSyncBranch) + }() + + t.Run("disables auto-start in worktree without sync-branch", func(t *testing.T) { + // Create a git repo with a worktree + mainDir, worktreeDir := setupWorktreeTestRepo(t) + + // Change to the worktree directory + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + _ = config.Initialize() + }() + if err := os.Chdir(worktreeDir); err != nil { + t.Fatalf("Failed to change to worktree dir: %v", err) + } + + // Clear all daemon-related env vars + os.Unsetenv("BEADS_NO_DAEMON") + os.Unsetenv("BEADS_AUTO_START_DAEMON") + os.Unsetenv("BEADS_SYNC_BRANCH") + + // Reinitialize config to pick up the new directory's config.yaml + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to reinitialize config: %v", err) + } + + result := shouldAutoStartDaemon() + if result { + t.Error("Expected shouldAutoStartDaemon() to return false in worktree without sync-branch") + } + + // Cleanup + cleanupTestWorktree(t, mainDir, worktreeDir) + }) + + t.Run("enables auto-start in worktree with sync-branch", func(t *testing.T) { + // Create a git repo with a worktree + mainDir, worktreeDir := setupWorktreeTestRepo(t) + + // Change to the worktree directory + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + _ = config.Initialize() + }() + if err := os.Chdir(worktreeDir); err != nil { + t.Fatalf("Failed to change to worktree dir: %v", err) + } + + // Reinitialize config to pick up the new directory's config.yaml + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to reinitialize config: %v", err) + } + + // Clear daemon env vars but set sync-branch + os.Unsetenv("BEADS_NO_DAEMON") + os.Unsetenv("BEADS_AUTO_START_DAEMON") + os.Setenv("BEADS_SYNC_BRANCH", "beads-metadata") + + result := shouldAutoStartDaemon() + if !result { + t.Error("Expected shouldAutoStartDaemon() to return true in worktree with sync-branch") + } + + // Cleanup + cleanupTestWorktree(t, mainDir, worktreeDir) + }) + + t.Run("BEADS_NO_DAEMON still takes precedence in worktree", func(t *testing.T) { + // Create a git repo with a worktree + mainDir, worktreeDir := setupWorktreeTestRepo(t) + + // Change to the worktree directory + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + _ = config.Initialize() + }() + if err := os.Chdir(worktreeDir); err != nil { + t.Fatalf("Failed to change to worktree dir: %v", err) + } + + // Reinitialize config to pick up the new directory's config.yaml + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to reinitialize config: %v", err) + } + + // Set BEADS_NO_DAEMON (should override everything) + os.Setenv("BEADS_NO_DAEMON", "1") + os.Setenv("BEADS_SYNC_BRANCH", "beads-metadata") + + result := shouldAutoStartDaemon() + if result { + t.Error("Expected BEADS_NO_DAEMON=1 to disable auto-start even with sync-branch") + } + + // Cleanup + cleanupTestWorktree(t, mainDir, worktreeDir) + }) +} + +// Helper functions for worktree daemon tests + +func restoreTestEnv(key, value string) { + if value != "" { + os.Setenv(key, value) + } else { + os.Unsetenv(key) + } +} + +// setupWorktreeTestRepo creates a git repo with a worktree for testing. +// Returns the main repo directory and worktree directory. +// Caller is responsible for cleanup via cleanupTestWorktree. +// +// IMPORTANT: This function also reinitializes the config package to use the +// temp directory's config, avoiding interference from the beads project's own config. +func setupWorktreeTestRepo(t *testing.T) (mainDir, worktreeDir string) { + t.Helper() + + // Create main repo directory + mainDir = t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = mainDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to init git repo: %v\n%s", err, output) + } + + // Configure git user for commits + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = mainDir + _ = cmd.Run() + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = mainDir + _ = cmd.Run() + + // Create .beads directory with empty config (no sync-branch) + beadsDir := mainDir + "/.beads" + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + // Create minimal config.yaml without sync-branch + configContent := "# Test config\nissue-prefix: \"test\"\n" + if err := os.WriteFile(beadsDir+"/config.yaml", []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to create config.yaml: %v", err) + } + + // Create initial commit (required for worktrees) + if err := os.WriteFile(mainDir+"/README.md", []byte("# Test\n"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + cmd = exec.Command("git", "add", ".") + cmd.Dir = mainDir + _ = cmd.Run() + + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = mainDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to create initial commit: %v\n%s", err, output) + } + + // Create a branch for the worktree + cmd = exec.Command("git", "branch", "feature-branch") + cmd.Dir = mainDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to create branch: %v\n%s", err, output) + } + + // Create worktree directory (must be outside main repo) + worktreeDir = t.TempDir() + + // Add worktree + cmd = exec.Command("git", "worktree", "add", worktreeDir, "feature-branch") + cmd.Dir = mainDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to create worktree: %v\n%s", err, output) + } + + return mainDir, worktreeDir +} + +// cleanupTestWorktree removes a worktree created by setupWorktreeTestRepo. +func cleanupTestWorktree(t *testing.T, mainDir, worktreeDir string) { + t.Helper() + + // Remove worktree + cmd := exec.Command("git", "worktree", "remove", worktreeDir, "--force") + cmd.Dir = mainDir + _ = cmd.Run() // Best effort cleanup +} + +// setupWorktreeTestRepoWithDB creates a git repo with a worktree AND a database +// that has sync.branch configured. This tests the database config path. +func setupWorktreeTestRepoWithDB(t *testing.T, syncBranch string) (mainDir, worktreeDir string) { + t.Helper() + + // First create the basic worktree repo + mainDir, worktreeDir = setupWorktreeTestRepo(t) + + // Now create a database with sync.branch config + beadsDir := mainDir + "/.beads" + dbPath := beadsDir + "/beads.db" + + // Create a minimal SQLite database with the config table and sync.branch value + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create config table + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)`) + if err != nil { + t.Fatalf("Failed to create config table: %v", err) + } + + // Insert sync.branch config + _, err = db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, "sync.branch", syncBranch) + if err != nil { + t.Fatalf("Failed to insert sync.branch config: %v", err) + } + + return mainDir, worktreeDir +} diff --git a/docs/DAEMON.md b/docs/DAEMON.md index c4a468f9..3852dbb3 100644 --- a/docs/DAEMON.md +++ b/docs/DAEMON.md @@ -24,12 +24,23 @@ bd runs a background daemon per workspace for auto-sync, RPC operations, and rea | Scenario | How to Disable | |----------|----------------| -| **Git worktrees** | `bd --no-daemon ` (required!) | +| **Git worktrees (no sync-branch)** | Auto-disabled for safety | | **CI/CD pipelines** | `BEADS_NO_DAEMON=true` | | **Offline work** | `--no-daemon` (no git push available) | | **Resource-constrained** | `BEADS_NO_DAEMON=true` | | **Deterministic testing** | Use exclusive lock (see below) | +### Git Worktrees and Daemon + +**Automatic safety:** Daemon is automatically disabled in git worktrees unless sync-branch is configured. This prevents commits going to the wrong branch. + +**Enable daemon in worktrees:** Configure sync-branch to safely use daemon across all worktrees: +```bash +bd config set sync-branch beads-metadata +``` + +With sync-branch configured, daemon commits to a dedicated branch using an internal worktree, so your current branch is never affected. See [WORKTREES.md](WORKTREES.md) for details. + ### Local-Only Users If you're working alone on a local project with no git remote: diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 96da22c2..66d33f3f 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -31,53 +31,51 @@ Main Repository - ✅ **Concurrent access** - SQLite locking prevents corruption - ✅ **Git integration** - Issues sync via JSONL in main repo -### Worktree Detection & Warnings +### Worktree Detection & Daemon Safety -bd automatically detects when you're in a git worktree and provides appropriate guidance: +bd automatically detects when you're in a git worktree and handles daemon mode safely: -```bash -# In a worktree with daemon active -$ bd ready -╔══════════════════════════════════════════════════════════════════════════╗ -║ WARNING: Git worktree detected with daemon mode ║ -╠══════════════════════════════════════════════════════════════════════════╣ -║ Git worktrees share the same .beads directory, which can cause the ║ -║ daemon to commit/push to the wrong branch. ║ -║ ║ -║ Shared database: /path/to/main/.beads ║ -║ Worktree git dir: /path/to/shared/.git ║ -║ ║ -║ RECOMMENDED SOLUTIONS: ║ -║ 1. Use --no-daemon flag: bd --no-daemon ║ -║ 2. Disable daemon mode: export BEADS_NO_DAEMON=1 ║ -╚══════════════════════════════════════════════════════════════════════════╝ -``` +**Default behavior (no sync-branch configured):** +- Daemon is **automatically disabled** in worktrees +- Uses direct mode for safety (no warning needed) +- All commands work correctly without configuration + +**With sync-branch configured:** +- Daemon is **enabled** in worktrees +- Commits go to dedicated sync branch (e.g., `beads-metadata`) +- Full daemon functionality available across all worktrees ## Usage Patterns -### Recommended: Direct Mode in Worktrees +### Recommended: Configure Sync-Branch for Full Daemon Support ```bash -# Disable daemon for worktree usage -export BEADS_NO_DAEMON=1 +# Configure sync-branch once (in main repo or any worktree) +bd config set sync-branch beads-metadata -# Work normally - all commands work correctly +# Now daemon works safely in all worktrees cd feature-worktree bd create "Implement feature X" -t feature -p 1 bd update bd-a1b2 --status in_progress -bd ready -bd sync # Manual sync when needed +bd ready # Daemon auto-syncs to beads-metadata branch ``` -### Alternative: Daemon in Main Repo Only +### Alternative: Direct Mode (No Configuration Needed) ```bash -# Use daemon only in main repository -cd main-repo -bd ready # Daemon works here +# Without sync-branch, daemon is auto-disabled in worktrees +cd feature-worktree +bd create "Implement feature X" -t feature -p 1 +bd ready # Uses direct mode automatically +bd sync # Manual sync when needed +``` -# Use direct mode in worktrees -cd ../feature-worktree +### Legacy: Explicit Daemon Disable + +```bash +# Still works if you prefer explicit control +export BEADS_NO_DAEMON=1 +# or bd --no-daemon ready ``` @@ -160,9 +158,14 @@ bd create "Fix password validation" -t bug -p 0 **Symptoms:** Changes appear on unexpected branch in git history -**Solution:** +**Note:** This issue should no longer occur with the new worktree safety feature. Daemon is automatically disabled in worktrees unless sync-branch is configured. + +**Solution (if still occurring):** ```bash -# Disable daemon in worktrees +# Option 1: Configure sync-branch (recommended) +bd config set sync-branch beads-metadata + +# Option 2: Explicitly disable daemon export BEADS_NO_DAEMON=1 # Or use --no-daemon flag for individual commands bd --no-daemon sync diff --git a/internal/syncbranch/syncbranch.go b/internal/syncbranch/syncbranch.go index 9b1f5f2b..c5b6e4e7 100644 --- a/internal/syncbranch/syncbranch.go +++ b/internal/syncbranch/syncbranch.go @@ -2,12 +2,18 @@ package syncbranch import ( "context" + "database/sql" "fmt" "os" "regexp" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/storage" + + // Import SQLite driver (same as used by storage/sqlite) + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" ) const ( @@ -114,6 +120,62 @@ func IsConfigured() bool { return GetFromYAML() != "" } +// IsConfiguredWithDB returns true if sync-branch is configured in any source: +// 1. BEADS_SYNC_BRANCH environment variable +// 2. sync-branch in config.yaml +// 3. sync.branch in database config +// +// The dbPath parameter should be the path to the beads.db file. +// If dbPath is empty, it will use beads.FindDatabasePath() to locate the database. +// This function is safe to call even if the database doesn't exist (returns false in that case). +func IsConfiguredWithDB(dbPath string) bool { + // First check env var and config.yaml (fast path) + if GetFromYAML() != "" { + return true + } + + // Try to read from database + if dbPath == "" { + // Use existing beads.FindDatabasePath() which is worktree-aware + dbPath = beads.FindDatabasePath() + if dbPath == "" { + return false + } + } + + // Read sync.branch from database config table + branch := getConfigFromDB(dbPath, ConfigKey) + return branch != "" +} + +// getConfigFromDB reads a config value directly from the database file. +// This is a lightweight read that doesn't require the full storage layer. +// Returns empty string if the database doesn't exist or the key is not found. +func getConfigFromDB(dbPath string, key string) string { + // Check if database exists + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return "" + } + + // Open database in read-only mode + // Use file: prefix as required by ncruces/go-sqlite3 driver + connStr := fmt.Sprintf("file:%s?mode=ro", dbPath) + db, err := sql.Open("sqlite3", connStr) + if err != nil { + return "" + } + defer db.Close() + + // Query the config table + var value string + err = db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&value) + if err != nil { + return "" + } + + return value +} + // Set stores the sync branch configuration in the database func Set(ctx context.Context, store storage.Storage, branch string) error { if err := ValidateBranchName(branch); err != nil {