Auto-disable daemon in git worktrees for safety (#567)

* feat: auto-disable daemon in git worktrees for safety

Implement worktree daemon compatibility as proposed in the analysis.
The daemon is now automatically disabled when running in a git worktree
unless sync-branch is configured.

Git worktrees share the same .beads directory, and the daemon commits
to whatever branch its working directory has checked out. This causes
commits to go to the wrong branch when using daemon in worktrees.

- Add shouldDisableDaemonForWorktree() helper that checks:
  1. If current directory is a git worktree (via git rev-parse)
  2. If sync-branch is configured (env var or config.yaml)
- Modify shouldAutoStartDaemon() to call the helper
- Modify daemon connection logic in main.go to skip connection
- Add FallbackWorktreeSafety constant for daemon status reporting
- Update warnWorktreeDaemon() to skip warning when sync-branch configured

- In worktree WITHOUT sync-branch: daemon auto-disabled, direct mode used
- In worktree WITH sync-branch: daemon enabled (commits go to dedicated branch)
- In regular repo: no change (daemon works as before)

- Added comprehensive unit tests for shouldDisableDaemonForWorktree()
- Added integration tests for shouldAutoStartDaemon() in worktree contexts
- Manual E2E testing verified correct behavior

- Updated WORKTREES.md with new automatic safety behavior
- Updated DAEMON.md with Git Worktrees section

* feat: check database config for sync-branch in worktree safety logic

Previously, the worktree daemon safety check only looked at:
- BEADS_SYNC_BRANCH environment variable
- sync-branch in config.yaml

This meant users who configured sync-branch via `bd config set sync-branch`
(which stores in the database) would still have daemon disabled in worktrees.

Now the check also reads sync.branch from the database config table,
making daemon work in worktrees when sync-branch is configured via any method.

Changes:
- Add IsConfiguredWithDB() function that checks env, config.yaml, AND database
- Add findBeadsDB() to locate database (worktree-aware via git-common-dir)
- Add getMainRepoRoot() helper using git rev-parse
- Add getConfigFromDB() for lightweight database reads
- Update shouldDisableDaemonForWorktree() to use IsConfiguredWithDB()
- Update warnWorktreeDaemon() to use IsConfiguredWithDB()
- Add test case for database config path

* refactor: use existing beads.FindDatabasePath() instead of duplicating code

Remove duplicate getMainRepoRoot() and findBeadsDB() functions from
syncbranch.go and use the existing beads.FindDatabasePath() which is
already worktree-aware.

Changes:
- Replace custom findBeadsDB() with beads.FindDatabasePath()
- Remove duplicate getMainRepoRoot() (git.GetMainRepoRoot() exists)
- Remove unused imports (exec, strings, filepath)
- Clean up debug logging in tests

---------

Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
This commit is contained in:
Charles P. Cross
2025-12-16 03:06:19 -05:00
committed by GitHub
parent 9544558840
commit a69e94a958
7 changed files with 588 additions and 42 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 <command> ║")
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 <command> ║")
fmt.Fprintln(os.Stderr, "║ 3. Disable daemon mode: export BEADS_NO_DAEMON=1 ║")
fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════════════╝")
fmt.Fprintln(os.Stderr)
}

View File

@@ -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
}