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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
409
cmd/bd/worktree_daemon_test.go
Normal file
409
cmd/bd/worktree_daemon_test.go
Normal 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
|
||||
}
|
||||
@@ -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 <command>` (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:
|
||||
|
||||
@@ -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 <command> ║
|
||||
║ 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user