Files
beads/internal/daemon/discovery.go
matt wilkie e01b7412d9 feat: add Git worktree compatibility (PR #478)
Adds comprehensive Git worktree support for beads issue tracking:

Core changes:
- New internal/git/gitdir.go package for worktree detection
- GetGitDir() returns proper .git location (main repo, not worktree)
- Updated all hooks to use git.GetGitDir() instead of local helper
- BeadsDir() now prioritizes main repository's .beads directory

Features:
- Hooks auto-install in main repo when run from worktree
- Shared .beads directory across all worktrees
- Config option no-install-hooks to disable auto-install
- New bd worktree subcommand for diagnostics

Documentation:
- New docs/WORKTREES.md with setup instructions
- Updated CHANGELOG.md and AGENT_INSTRUCTIONS.md

Testing:
- Updated tests to use exported git.GetGitDir()
- Added worktree detection tests

Co-authored-by: Claude <noreply@anthropic.com>
Closes: #478
2025-12-13 12:50:33 -08:00

440 lines
12 KiB
Go

package daemon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/lockfile"
"github.com/steveyegge/beads/internal/rpc"
)
// walkWithDepth walks a directory tree with depth limiting
func walkWithDepth(root string, currentDepth, maxDepth int, fn func(path string, info os.FileInfo) error) error {
if currentDepth > maxDepth {
return nil
}
entries, err := os.ReadDir(root)
if err != nil {
// Skip directories we can't read
return nil
}
for _, entry := range entries {
path := filepath.Join(root, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// Skip common directories that won't have beads databases
if info.IsDir() {
name := entry.Name()
if strings.HasPrefix(name, ".") && name != ".beads" {
continue // Skip hidden dirs except .beads
}
if name == "node_modules" || name == "vendor" || name == ".git" {
continue
}
// Recurse into subdirectory
if err := walkWithDepth(path, currentDepth+1, maxDepth, fn); err != nil {
return err
}
} else {
// Process file
if err := fn(path, info); err != nil {
return err
}
}
}
return nil
}
// DaemonInfo represents metadata about a discovered daemon
type DaemonInfo struct {
WorkspacePath string
DatabasePath string
SocketPath string
PID int
Version string
UptimeSeconds float64
LastActivityTime string
ExclusiveLockActive bool
ExclusiveLockHolder string
Alive bool
Error string
}
// DiscoverDaemons discovers running bd daemons using the registry
// Falls back to filesystem scanning if searchRoots is explicitly provided (for compatibility)
func DiscoverDaemons(searchRoots []string) ([]DaemonInfo, error) {
// If searchRoots is explicitly provided, use legacy filesystem scan
// This maintains compatibility for any callers that explicitly specify paths
if len(searchRoots) > 0 {
return discoverDaemonsLegacy(searchRoots)
}
// Use registry-based discovery (instant, no filesystem scanning)
registry, err := NewRegistry()
if err != nil {
// Fall back to legacy discovery if registry unavailable
return discoverDaemonsLegacy(nil)
}
return registry.List()
}
// discoverDaemonsLegacy scans the filesystem for running bd daemons (legacy method)
// It searches common locations and uses the Status RPC endpoint to gather metadata
func discoverDaemonsLegacy(searchRoots []string) ([]DaemonInfo, error) {
var daemons []DaemonInfo
seen := make(map[string]bool)
// If no search roots provided, use common locations
if len(searchRoots) == 0 {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
searchRoots = []string{
home,
"/tmp",
}
// Also add current directory if in a git repo
if cwd, err := os.Getwd(); err == nil {
searchRoots = append(searchRoots, cwd)
}
}
// Search for .beads/bd.sock files (limit depth to avoid traversing entire filesystem)
for _, root := range searchRoots {
maxDepth := 10 // Limit recursion depth
if err := walkWithDepth(root, 0, maxDepth, func(path string, info os.FileInfo) error {
// Skip if not a socket file
if info.Name() != "bd.sock" {
return nil
}
// Skip if already seen this socket
if seen[path] {
return nil
}
seen[path] = true
// Try to connect and get status
daemon := discoverDaemon(path)
daemons = append(daemons, daemon)
return nil
}); err != nil {
// Continue searching other roots even if one fails
continue
}
}
return daemons, nil
}
// discoverDaemon attempts to connect to a daemon socket and retrieve its status
func discoverDaemon(socketPath string) DaemonInfo {
daemon := DaemonInfo{
SocketPath: socketPath,
Alive: false,
}
// Fast probe: check daemon lock before attempting RPC if socket doesn't exist
// This eliminates unnecessary connection attempts when no daemon is running
// If socket exists, we proceed with RPC for backwards compatibility
_, err := os.Stat(socketPath)
socketExists := err == nil
if !socketExists {
beadsDir := filepath.Dir(socketPath)
running, _ := lockfile.TryDaemonLock(beadsDir)
if !running {
daemon.Error = "daemon lock not held and socket missing"
// Check for daemon-error file
if errMsg := checkDaemonErrorFile(socketPath); errMsg != "" {
daemon.Error = errMsg
}
return daemon
}
}
// Try to connect with short timeout
client, err := rpc.TryConnectWithTimeout(socketPath, 500*time.Millisecond)
if err != nil {
daemon.Error = fmt.Sprintf("failed to connect: %v", err)
// Check for daemon-error file
if errMsg := checkDaemonErrorFile(socketPath); errMsg != "" {
daemon.Error = errMsg
}
return daemon
}
if client == nil {
daemon.Error = "daemon not responding or unhealthy"
// Check for daemon-error file
if errMsg := checkDaemonErrorFile(socketPath); errMsg != "" {
daemon.Error = errMsg
}
return daemon
}
defer func() { _ = client.Close() }()
// Get status
status, err := client.Status()
if err != nil {
daemon.Error = fmt.Sprintf("failed to get status: %v", err)
return daemon
}
// Populate daemon info from status
daemon.Alive = true
daemon.WorkspacePath = status.WorkspacePath
daemon.DatabasePath = status.DatabasePath
daemon.PID = status.PID
daemon.Version = status.Version
daemon.UptimeSeconds = status.UptimeSeconds
daemon.LastActivityTime = status.LastActivityTime
daemon.ExclusiveLockActive = status.ExclusiveLockActive
daemon.ExclusiveLockHolder = status.ExclusiveLockHolder
return daemon
}
// FindDaemonByWorkspace finds a daemon serving a specific workspace
func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
// Determine the correct .beads directory location
// For worktrees, .beads is in the main repository root, not the worktree
beadsDir := findBeadsDirForWorkspace(workspacePath)
// First try the socket in the determined .beads directory
socketPath := filepath.Join(beadsDir, "bd.sock")
if _, err := os.Stat(socketPath); err == nil {
daemon := discoverDaemon(socketPath)
if daemon.Alive {
return &daemon, nil
}
}
// Fall back to discovering all daemons
daemons, err := DiscoverDaemons([]string{workspacePath})
if err != nil {
return nil, err
}
for _, daemon := range daemons {
if daemon.WorkspacePath == workspacePath && daemon.Alive {
return &daemon, nil
}
}
return nil, fmt.Errorf("no daemon found for workspace: %s", workspacePath)
}
// findBeadsDirForWorkspace determines the correct .beads directory for a workspace
// For worktrees, this is the main repository root; for regular repos, it's the workspace itself
func findBeadsDirForWorkspace(workspacePath string) string {
// Change to the workspace directory to check if it's a worktree
originalDir, err := os.Getwd()
if err != nil {
return filepath.Join(workspacePath, ".beads") // fallback
}
defer func() {
_ = os.Chdir(originalDir) // restore original directory
}()
if err := os.Chdir(workspacePath); err != nil {
return filepath.Join(workspacePath, ".beads") // fallback
}
// Check if we're in a git worktree
cmd := exec.Command("git", "rev-parse", "--git-dir", "--git-common-dir")
output, err := cmd.Output()
if err != nil {
return filepath.Join(workspacePath, ".beads") // fallback
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) >= 2 {
gitDir := strings.TrimSpace(lines[0])
commonDir := strings.TrimSpace(lines[1])
// If git-dir != git-common-dir, we're in a worktree
if gitDir != commonDir {
// Worktree: .beads is in main repo root (parent of git-common-dir)
mainRepoRoot := filepath.Dir(commonDir)
return filepath.Join(mainRepoRoot, ".beads")
}
}
// Regular repository: .beads is in the workspace
return filepath.Join(workspacePath, ".beads")
}
// checkDaemonErrorFile checks for a daemon-error file in the .beads directory
func checkDaemonErrorFile(socketPath string) string {
// Socket path is typically .beads/bd.sock, so get the parent dir
beadsDir := filepath.Dir(socketPath)
errFile := filepath.Join(beadsDir, "daemon-error")
data, err := os.ReadFile(errFile)
if err != nil {
return ""
}
return string(data)
}
// CleanupStaleSockets removes socket files and PID files for dead daemons
func CleanupStaleSockets(daemons []DaemonInfo) (int, error) {
cleaned := 0
for _, daemon := range daemons {
if !daemon.Alive && daemon.SocketPath != "" {
// Remove stale socket file
if err := os.Remove(daemon.SocketPath); err != nil {
if !os.IsNotExist(err) {
return cleaned, fmt.Errorf("failed to remove stale socket %s: %w", daemon.SocketPath, err)
}
} else {
cleaned++
}
// Also remove associated PID file if it exists
socketDir := filepath.Dir(daemon.SocketPath)
pidFile := filepath.Join(socketDir, "daemon.pid")
if err := os.Remove(pidFile); err != nil {
// Ignore errors for PID file - it may not exist
if !os.IsNotExist(err) {
// Log warning but don't fail
}
}
}
}
return cleaned, nil
}
// StopDaemon gracefully stops a daemon by sending shutdown command via RPC
// Falls back to SIGTERM if RPC fails
func StopDaemon(daemon DaemonInfo) error {
if !daemon.Alive {
return fmt.Errorf("daemon is not running")
}
// Try graceful shutdown via RPC first
client, err := rpc.TryConnectWithTimeout(daemon.SocketPath, 500*time.Millisecond)
if err == nil && client != nil {
defer func() { _ = client.Close() }()
if err := client.Shutdown(); err == nil {
// Wait a bit for daemon to shut down
time.Sleep(200 * time.Millisecond)
return nil
}
}
// Fallback to SIGTERM if RPC failed
return killProcess(daemon.PID)
}
// KillAllFailure represents a failure to kill a specific daemon
type KillAllFailure struct {
Workspace string `json:"workspace"`
PID int `json:"pid"`
Error string `json:"error"`
}
// KillAllResults contains results from KillAllDaemons
type KillAllResults struct {
Stopped int `json:"stopped"`
Failed int `json:"failed"`
Failures []KillAllFailure `json:"failures,omitempty"`
}
// KillAllDaemons stops all provided daemons, using force if RPC/SIGTERM fail
func KillAllDaemons(daemons []DaemonInfo, force bool) KillAllResults {
results := KillAllResults{
Failures: []KillAllFailure{},
}
for _, daemon := range daemons {
if !daemon.Alive {
continue
}
if err := stopDaemonWithTimeout(daemon); err != nil {
if force {
// Try force kill
if err := forceKillProcess(daemon.PID); err != nil {
results.Failed++
results.Failures = append(results.Failures, KillAllFailure{
Workspace: daemon.WorkspacePath,
PID: daemon.PID,
Error: err.Error(),
})
continue
}
} else {
results.Failed++
results.Failures = append(results.Failures, KillAllFailure{
Workspace: daemon.WorkspacePath,
PID: daemon.PID,
Error: err.Error(),
})
continue
}
}
results.Stopped++
}
return results
}
// stopDaemonWithTimeout tries RPC shutdown, then SIGTERM with timeout, then SIGKILL
func stopDaemonWithTimeout(daemon DaemonInfo) error {
// Try RPC shutdown first (2 second timeout)
client, err := rpc.TryConnectWithTimeout(daemon.SocketPath, 2*time.Second)
if err == nil && client != nil {
defer func() { _ = client.Close() }()
if err := client.Shutdown(); err == nil {
// Wait and verify process died
time.Sleep(500 * time.Millisecond)
if !isProcessAlive(daemon.PID) {
return nil
}
}
}
// Try SIGTERM with 3 second timeout
if err := killProcess(daemon.PID); err != nil {
return fmt.Errorf("SIGTERM failed: %w", err)
}
// Wait up to 3 seconds for process to die
for i := 0; i < 30; i++ {
time.Sleep(100 * time.Millisecond)
if !isProcessAlive(daemon.PID) {
return nil
}
}
// SIGTERM timeout, try SIGKILL with 1 second timeout
if err := forceKillProcess(daemon.PID); err != nil {
return fmt.Errorf("SIGKILL failed: %w", err)
}
// Wait up to 1 second for process to die
for i := 0; i < 10; i++ {
time.Sleep(100 * time.Millisecond)
if !isProcessAlive(daemon.PID) {
return nil
}
}
return fmt.Errorf("process %d did not die after SIGKILL", daemon.PID)
}