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
This commit is contained in:
matt wilkie
2025-12-13 10:40:40 -08:00
committed by Steve Yegge
parent de7b511765
commit e01b7412d9
64 changed files with 1895 additions and 3708 deletions

View File

@@ -22,6 +22,7 @@ import (
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/syncbranch"
)
@@ -432,7 +433,7 @@ func runCheckHealth(path string) {
// Check if database exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
// No database - only check hooks
if issue := checkHooksQuick(path); issue != "" {
if issue := checkHooksQuick(); issue != "" {
printCheckHealthHint([]string{issue})
}
return
@@ -442,7 +443,7 @@ func runCheckHealth(path string) {
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
if err != nil {
// Can't open DB - only check hooks
if issue := checkHooksQuick(path); issue != "" {
if issue := checkHooksQuick(); issue != "" {
printCheckHealthHint([]string{issue})
}
return
@@ -462,8 +463,13 @@ func runCheckHealth(path string) {
issues = append(issues, issue)
}
// Check 2: Outdated git hooks
if issue := checkHooksQuick(path); issue != "" {
// Check 2: Sync branch not configured (now reads from config.yaml, not DB)
if issue := checkSyncBranchQuick(); issue != "" {
issues = append(issues, issue)
}
// Check 3: Outdated git hooks
if issue := checkHooksQuick(); issue != "" {
issues = append(issues, issue)
}
@@ -521,21 +527,23 @@ func checkVersionMismatchDB(db *sql.DB) string {
return ""
}
// checkSyncBranchQuick checks if sync-branch is configured in config.yaml.
// Fast check that doesn't require database access.
func checkSyncBranchQuick() string {
if syncbranch.IsConfigured() {
return ""
}
return "sync-branch not configured in config.yaml"
}
// checkHooksQuick does a fast check for outdated git hooks.
// Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout (bd-2em).
func checkHooksQuick(path string) string {
func checkHooksQuick() string {
// Get actual git directory (handles worktrees where .git is a file)
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
output, err := cmd.Output()
gitDir, err := git.GetGitDir()
if err != nil {
return "" // Not a git repo, skip
}
gitDir := strings.TrimSpace(string(output))
// Make absolute if relative
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
hooksDir := filepath.Join(gitDir, "hooks")
// Check if hooks dir exists
@@ -607,7 +615,7 @@ func runDiagnostics(path string) doctorResult {
}
// Check Git Hooks early (even if .beads/ doesn't exist yet)
hooksCheck := checkGitHooks(path)
hooksCheck := checkGitHooks()
result.Checks = append(result.Checks, hooksCheck)
// Don't fail overall check for missing hooks, just warn
@@ -1323,7 +1331,6 @@ func printDiagnostics(result doctorResult) {
// Print warnings/errors with fixes
hasIssues := false
unfixableErrors := 0
for _, check := range result.Checks {
if check.Status != statusOK && check.Fix != "" {
if !hasIssues {
@@ -1338,27 +1345,12 @@ func printDiagnostics(result doctorResult) {
}
fmt.Printf(" Fix: %s\n\n", check.Fix)
} else if check.Status == statusError && check.Fix == "" {
// Count unfixable errors
unfixableErrors++
}
}
if !hasIssues {
color.Green("✓ All checks passed\n")
}
// Suggest reset if there are multiple unfixable errors
if unfixableErrors >= 3 {
fmt.Println()
color.Yellow("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
color.Yellow("⚠ Found %d unfixable errors\n", unfixableErrors)
fmt.Println()
fmt.Println(" Your beads state may be too corrupted to repair automatically.")
fmt.Println(" Consider running 'bd reset' to start fresh.")
fmt.Println(" (Use 'bd reset --backup' to save current state first)")
color.Yellow("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
}
}
func checkMultipleDatabases(path string) doctorCheck {
@@ -1881,10 +1873,10 @@ func checkDependencyCycles(path string) doctorCheck {
}
}
func checkGitHooks(path string) doctorCheck {
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
func checkGitHooks() doctorCheck {
// Check if we're in a git repository using worktree-aware detection
gitDir, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Git Hooks",
Status: statusOK,
@@ -2105,9 +2097,9 @@ func checkDatabaseIntegrity(path string) doctorCheck {
}
func checkMergeDriver(path string) doctorCheck {
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Git Merge Driver",
Status: statusOK,
@@ -2306,9 +2298,9 @@ func checkSyncBranchConfig(path string) doctorCheck {
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Sync Branch Config",
Status: statusOK,
@@ -2350,20 +2342,26 @@ func checkSyncBranchConfig(path string) doctorCheck {
}
}
// Not configured - check if repo has a remote to provide appropriate message
// sync-branch is optional, only needed for protected branches or multi-clone workflows
// See GitHub issue #498
// Not configured - this is optional but recommended for multi-clone setups
// Check if this looks like a multi-clone setup (has remote)
hasRemote := false
cmd = exec.Command("git", "remote")
cmd.Dir = path
if output, err := cmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
hasRemote = true
}
if hasRemote {
return doctorCheck{
Name: "Sync Branch Config",
Status: statusOK,
Message: "Not configured (optional)",
Detail: "Only needed for protected branches or multi-clone workflows",
Status: statusWarning,
Message: "sync-branch not configured",
Detail: "Multi-clone setups should configure sync-branch in config.yaml",
Fix: "Add 'sync-branch: beads-sync' to .beads/config.yaml",
}
}
// No remote - probably a local-only repo, sync-branch not needed
return doctorCheck{
Name: "Sync Branch Config",
Status: statusOK,
@@ -2375,9 +2373,9 @@ func checkSyncBranchConfig(path string) doctorCheck {
// or from the remote sync branch (after a force-push reset).
// bd-6rf: Detect and fix stale beads-sync branch
func checkSyncBranchHealth(path string) doctorCheck {
// Skip if not in a git repo
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Skip if not in a git repo using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Sync Branch Health",
Status: statusOK,
@@ -2542,9 +2540,9 @@ func checkDeletionsManifest(path string) doctorCheck {
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
@@ -2738,9 +2736,9 @@ func checkUntrackedBeadsFiles(path string) doctorCheck {
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Untracked Files",
Status: statusOK,