feat(doctor): detect external hook managers and check bd integration (#908)

Add detection for external git hook managers (lefthook, husky, pre-commit,
overcommit, yorkie, simple-git-hooks) and check if they have bd hooks
integration configured.

- Detect manager config files in various locations/formats
- Parse lefthook YAML/TOML/JSON to check for `bd hooks run` commands
- Check husky hook scripts for bd integration
- Report which hooks have bd integration vs missing
- Use --chain flag in `bd doctor --fix` when external managers detected
- Detect active manager from git hooks when multiple present

Executed-By: mayor
Role: mayor
This commit is contained in:
Roelof Blom
2026-01-06 04:12:14 +01:00
committed by GitHub
parent e212683103
commit ffd2e2273c
4 changed files with 1364 additions and 5 deletions

View File

@@ -1,12 +1,354 @@
package fix
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
// GitHooks fixes missing or broken git hooks by calling bd hooks install
// ExternalHookManager represents a detected external hook management tool.
type ExternalHookManager struct {
Name string // e.g., "lefthook", "husky", "pre-commit"
ConfigFile string // Path to the config file that was detected
}
// hookManagerConfig pairs a manager name with its possible config files.
type hookManagerConfig struct {
name string
configFiles []string
}
// hookManagerConfigs defines external hook managers in priority order.
// See https://lefthook.dev/configuration/ for lefthook config options.
var hookManagerConfigs = []hookManagerConfig{
{"lefthook", []string{
// YAML variants
"lefthook.yml", ".lefthook.yml", ".config/lefthook.yml",
"lefthook.yaml", ".lefthook.yaml", ".config/lefthook.yaml",
// TOML variants
"lefthook.toml", ".lefthook.toml", ".config/lefthook.toml",
// JSON variants
"lefthook.json", ".lefthook.json", ".config/lefthook.json",
}},
{"husky", []string{".husky"}},
{"pre-commit", []string{".pre-commit-config.yaml", ".pre-commit-config.yml"}},
{"overcommit", []string{".overcommit.yml"}},
{"yorkie", []string{".yorkie"}},
{"simple-git-hooks", []string{
".simple-git-hooks.cjs", ".simple-git-hooks.js",
"simple-git-hooks.cjs", "simple-git-hooks.js",
}},
}
// DetectExternalHookManagers checks for presence of external hook management tools.
// Returns a list of detected managers along with their config file paths.
func DetectExternalHookManagers(path string) []ExternalHookManager {
var managers []ExternalHookManager
for _, mgr := range hookManagerConfigs {
for _, configFile := range mgr.configFiles {
configPath := filepath.Join(path, configFile)
if info, err := os.Stat(configPath); err == nil {
// For directories like .husky, check if it exists
// For files, check if it's a regular file
if info.IsDir() || info.Mode().IsRegular() {
managers = append(managers, ExternalHookManager{
Name: mgr.name,
ConfigFile: configFile,
})
break // Only report each manager once
}
}
}
}
return managers
}
// HookIntegrationStatus represents the status of bd integration in an external hook manager.
type HookIntegrationStatus struct {
Manager string // Hook manager name
HooksWithBd []string // Hooks that have bd integration (bd hooks run)
HooksWithoutBd []string // Hooks configured but without bd integration
HooksNotInConfig []string // Recommended hooks not in config at all
Configured bool // Whether any bd integration was found
}
// bdHookPattern matches the recommended bd hooks run pattern with word boundaries
var bdHookPattern = regexp.MustCompile(`\bbd\s+hooks\s+run\b`)
// hookManagerPattern pairs a manager name with its detection pattern.
type hookManagerPattern struct {
name string
pattern *regexp.Regexp
}
// hookManagerPatterns identifies which hook manager installed a git hook (in priority order).
var hookManagerPatterns = []hookManagerPattern{
{"lefthook", regexp.MustCompile(`(?i)lefthook`)},
{"husky", regexp.MustCompile(`(?i)(\.husky|husky\.sh)`)},
{"pre-commit", regexp.MustCompile(`(?i)(pre-commit\s+run|\.pre-commit-config|INSTALL_PYTHON|PRE_COMMIT)`)},
{"simple-git-hooks", regexp.MustCompile(`(?i)simple-git-hooks`)},
}
// DetectActiveHookManager reads the git hooks to determine which manager installed them.
// This is more reliable than just checking for config files when multiple managers exist.
func DetectActiveHookManager(path string) string {
// Get git dir
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
output, err := cmd.Output()
if err != nil {
return ""
}
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
// Check for custom hooks path (core.hooksPath)
hooksDir := filepath.Join(gitDir, "hooks")
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
customPath := strings.TrimSpace(string(hooksPathOutput))
if customPath != "" {
if !filepath.IsAbs(customPath) {
customPath = filepath.Join(path, customPath)
}
hooksDir = customPath
}
}
// Check common hooks for manager signatures
for _, hookName := range []string{"pre-commit", "pre-push", "post-merge"} {
hookPath := filepath.Join(hooksDir, hookName)
content, err := os.ReadFile(hookPath) // #nosec G304 - path is validated
if err != nil {
continue
}
contentStr := string(content)
// Check each manager pattern (deterministic order)
for _, mp := range hookManagerPatterns {
if mp.pattern.MatchString(contentStr) {
return mp.name
}
}
}
return ""
}
// recommendedBdHooks are the hooks that should have bd integration
var recommendedBdHooks = []string{"pre-commit", "post-merge", "pre-push"}
// lefthookConfigFiles lists lefthook config files (derived from hookManagerConfigs).
// Format is inferred from extension.
var lefthookConfigFiles = hookManagerConfigs[0].configFiles // lefthook is first
// CheckLefthookBdIntegration parses lefthook config (YAML, TOML, or JSON) and checks if bd hooks are integrated.
// See https://lefthook.dev/configuration/ for supported config file locations.
func CheckLefthookBdIntegration(path string) *HookIntegrationStatus {
// Find first existing config file
var configPath string
for _, name := range lefthookConfigFiles {
p := filepath.Join(path, name)
if _, err := os.Stat(p); err == nil {
configPath = p
break
}
}
if configPath == "" {
return nil
}
content, err := os.ReadFile(configPath) // #nosec G304 - path is validated
if err != nil {
return nil
}
// Parse config based on extension
var config map[string]interface{}
ext := filepath.Ext(configPath)
switch ext {
case ".toml":
if _, err := toml.Decode(string(content), &config); err != nil {
return nil
}
case ".json":
if err := json.Unmarshal(content, &config); err != nil {
return nil
}
default: // .yml, .yaml
if err := yaml.Unmarshal(content, &config); err != nil {
return nil
}
}
status := &HookIntegrationStatus{
Manager: "lefthook",
Configured: false,
}
// Check each recommended hook
for _, hookName := range recommendedBdHooks {
hookSection, ok := config[hookName]
if !ok {
// Hook not configured at all in lefthook
status.HooksNotInConfig = append(status.HooksNotInConfig, hookName)
continue
}
// Walk to commands.*.run to check for bd hooks run
if hasBdInCommands(hookSection) {
status.HooksWithBd = append(status.HooksWithBd, hookName)
status.Configured = true
} else {
// Hook is in config but has no bd integration
status.HooksWithoutBd = append(status.HooksWithoutBd, hookName)
}
}
return status
}
// hasBdInCommands checks if any command's "run" field contains bd hooks run.
// Walks the lefthook structure: hookSection.commands.*.run
func hasBdInCommands(hookSection interface{}) bool {
sectionMap, ok := hookSection.(map[string]interface{})
if !ok {
return false
}
commands, ok := sectionMap["commands"]
if !ok {
return false
}
commandsMap, ok := commands.(map[string]interface{})
if !ok {
return false
}
for _, cmdConfig := range commandsMap {
cmdMap, ok := cmdConfig.(map[string]interface{})
if !ok {
continue
}
runVal, ok := cmdMap["run"]
if !ok {
continue
}
runStr, ok := runVal.(string)
if !ok {
continue
}
if bdHookPattern.MatchString(runStr) {
return true
}
}
return false
}
// CheckHuskyBdIntegration checks .husky/ scripts for bd integration.
func CheckHuskyBdIntegration(path string) *HookIntegrationStatus {
huskyDir := filepath.Join(path, ".husky")
if _, err := os.Stat(huskyDir); os.IsNotExist(err) {
return nil
}
status := &HookIntegrationStatus{
Manager: "husky",
Configured: false,
}
for _, hookName := range recommendedBdHooks {
hookPath := filepath.Join(huskyDir, hookName)
content, err := os.ReadFile(hookPath) // #nosec G304 - path is validated
if err != nil {
// Hook script doesn't exist in .husky/
status.HooksNotInConfig = append(status.HooksNotInConfig, hookName)
continue
}
contentStr := string(content)
// Check for bd hooks run pattern
if bdHookPattern.MatchString(contentStr) {
status.HooksWithBd = append(status.HooksWithBd, hookName)
status.Configured = true
} else {
status.HooksWithoutBd = append(status.HooksWithoutBd, hookName)
}
}
return status
}
// checkManagerBdIntegration checks a specific manager for bd integration.
func checkManagerBdIntegration(name, path string) *HookIntegrationStatus {
switch name {
case "lefthook":
return CheckLefthookBdIntegration(path)
case "husky":
return CheckHuskyBdIntegration(path)
default:
return nil
}
}
// CheckExternalHookManagerIntegration checks if detected hook managers have bd integration.
func CheckExternalHookManagerIntegration(path string) *HookIntegrationStatus {
managers := DetectExternalHookManagers(path)
if len(managers) == 0 {
return nil
}
// First, try to detect which manager is actually active from git hooks
if activeManager := DetectActiveHookManager(path); activeManager != "" {
if status := checkManagerBdIntegration(activeManager, path); status != nil {
return status
}
}
// Fall back to checking detected managers in order
for _, m := range managers {
if status := checkManagerBdIntegration(m.Name, path); status != nil {
return status
}
}
// Return basic status for unsupported managers
return &HookIntegrationStatus{
Manager: ManagerNames(managers),
Configured: false,
}
}
// ManagerNames extracts names from a slice of ExternalHookManager as comma-separated string.
func ManagerNames(managers []ExternalHookManager) string {
names := make([]string, len(managers))
for i, m := range managers {
names[i] = m.Name
}
return strings.Join(names, ", ")
}
// GitHooks fixes missing or broken git hooks by calling bd hooks install.
// If external hook managers are detected (lefthook, husky, etc.), it uses
// --chain to preserve existing hooks instead of overwriting them.
func GitHooks(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
@@ -21,15 +363,26 @@ func GitHooks(path string) error {
return fmt.Errorf("not a git repository")
}
// Detect external hook managers
externalManagers := DetectExternalHookManagers(path)
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
return err
}
// Build command arguments
args := []string{"hooks", "install"}
// If external hook managers detected, use --chain to preserve them
if len(externalManagers) > 0 {
args = append(args, "--chain")
}
// Run bd hooks install
cmd := newBdCmd(bdBinary, "hooks", "install")
cmd.Dir = path // Set working directory without changing process dir
cmd := newBdCmd(bdBinary, args...)
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@@ -0,0 +1,894 @@
package fix
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestDetectExternalHookManagers(t *testing.T) {
tests := []struct {
name string
setup func(dir string) error
expected []string // Expected manager names
}{
{
name: "no hook managers",
setup: func(dir string) error {
return nil
},
expected: nil,
},
{
name: "lefthook.yml",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte("pre-commit:\n"), 0644)
},
expected: []string{"lefthook"},
},
{
name: "lefthook.yaml",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "lefthook.yaml"), []byte("pre-commit:\n"), 0644)
},
expected: []string{"lefthook"},
},
{
name: "lefthook.toml",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "lefthook.toml"), []byte("[pre-commit]\n"), 0644)
},
expected: []string{"lefthook"},
},
{
name: "lefthook.json",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "lefthook.json"), []byte(`{"pre-commit":{}}`), 0644)
},
expected: []string{"lefthook"},
},
{
name: ".lefthook.yml (hidden)",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, ".lefthook.yml"), []byte("pre-commit:\n"), 0644)
},
expected: []string{"lefthook"},
},
{
name: ".config/lefthook.yml",
setup: func(dir string) error {
configDir := filepath.Join(dir, ".config")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(configDir, "lefthook.yml"), []byte("pre-commit:\n"), 0644)
},
expected: []string{"lefthook"},
},
{
name: ".husky directory",
setup: func(dir string) error {
return os.MkdirAll(filepath.Join(dir, ".husky"), 0755)
},
expected: []string{"husky"},
},
{
name: ".pre-commit-config.yaml",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, ".pre-commit-config.yaml"), []byte("repos:\n"), 0644)
},
expected: []string{"pre-commit"},
},
{
name: ".overcommit.yml",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, ".overcommit.yml"), []byte("PreCommit:\n"), 0644)
},
expected: []string{"overcommit"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := tt.setup(dir); err != nil {
t.Fatalf("setup failed: %v", err)
}
managers := DetectExternalHookManagers(dir)
if len(tt.expected) == 0 {
if len(managers) != 0 {
t.Errorf("expected no managers, got %v", managers)
}
return
}
if len(managers) != len(tt.expected) {
t.Errorf("expected %d managers, got %d", len(tt.expected), len(managers))
return
}
for i, exp := range tt.expected {
if managers[i].Name != exp {
t.Errorf("expected manager %q, got %q", exp, managers[i].Name)
}
}
})
}
}
func TestCheckLefthookBdIntegration(t *testing.T) {
tests := []struct {
name string
configFile string
configContent string
expectConfigured bool
expectHooksWithBd []string
expectHooksWithoutBd []string
expectNotInConfig []string
}{
{
name: "no config",
configFile: "",
// No file created
expectConfigured: false,
},
{
name: "yaml with bd hooks run",
configFile: "lefthook.yml",
configContent: `
pre-commit:
commands:
bd:
run: bd hooks run pre-commit
post-merge:
commands:
bd:
run: bd hooks run post-merge
pre-push:
commands:
bd:
run: bd hooks run pre-push
`,
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit", "post-merge", "pre-push"},
},
{
name: "yaml with partial bd integration",
configFile: "lefthook.yml",
configContent: `
pre-commit:
commands:
bd:
run: bd hooks run pre-commit
lint:
run: eslint .
post-merge:
commands:
bd:
run: echo "no bd here"
`,
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit"},
expectHooksWithoutBd: []string{"post-merge"},
expectNotInConfig: []string{"pre-push"},
},
{
name: "yaml without bd at all",
configFile: "lefthook.yml",
configContent: `
pre-commit:
commands:
lint:
run: eslint .
`,
expectConfigured: false,
expectHooksWithoutBd: []string{"pre-commit"},
expectNotInConfig: []string{"post-merge", "pre-push"},
},
{
name: "toml with bd hooks run",
configFile: "lefthook.toml",
configContent: `
[pre-commit.commands.bd]
run = "bd hooks run pre-commit"
[post-merge.commands.bd]
run = "bd hooks run post-merge"
[pre-push.commands.bd]
run = "bd hooks run pre-push"
`,
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit", "post-merge", "pre-push"},
},
{
name: "json with bd hooks run",
configFile: "lefthook.json",
configContent: `{
"pre-commit": {
"commands": {
"bd": {
"run": "bd hooks run pre-commit"
}
}
},
"post-merge": {
"commands": {
"bd": {
"run": "bd hooks run post-merge"
}
}
},
"pre-push": {
"commands": {
"bd": {
"run": "bd hooks run pre-push"
}
}
}
}`,
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit", "post-merge", "pre-push"},
},
{
name: "hidden config .lefthook.yml",
configFile: ".lefthook.yml",
configContent: `
pre-commit:
commands:
bd:
run: bd hooks run pre-commit
`,
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit"},
expectNotInConfig: []string{"post-merge", "pre-push"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if tt.configFile != "" {
configPath := filepath.Join(dir, tt.configFile)
// Handle .config/ subdirectory
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
if err := os.WriteFile(configPath, []byte(tt.configContent), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
}
status := CheckLefthookBdIntegration(dir)
if tt.configFile == "" {
if status != nil {
t.Errorf("expected nil status for no config, got %+v", status)
}
return
}
if status == nil {
t.Fatal("expected non-nil status")
}
if status.Configured != tt.expectConfigured {
t.Errorf("Configured: expected %v, got %v", tt.expectConfigured, status.Configured)
}
if !slicesEqual(status.HooksWithBd, tt.expectHooksWithBd) {
t.Errorf("HooksWithBd: expected %v, got %v", tt.expectHooksWithBd, status.HooksWithBd)
}
if !slicesEqual(status.HooksWithoutBd, tt.expectHooksWithoutBd) {
t.Errorf("HooksWithoutBd: expected %v, got %v", tt.expectHooksWithoutBd, status.HooksWithoutBd)
}
if !slicesEqual(status.HooksNotInConfig, tt.expectNotInConfig) {
t.Errorf("HooksNotInConfig: expected %v, got %v", tt.expectNotInConfig, status.HooksNotInConfig)
}
})
}
}
func TestCheckHuskyBdIntegration(t *testing.T) {
tests := []struct {
name string
hooks map[string]string // hookName -> content
expectConfigured bool
expectHooksWithBd []string
expectHooksWithoutBd []string
expectNotInConfig []string
}{
{
name: "no .husky directory",
hooks: nil,
expectConfigured: false,
},
{
name: "all hooks with bd",
hooks: map[string]string{
"pre-commit": "#!/bin/sh\nbd hooks run pre-commit\n",
"post-merge": "#!/bin/sh\nbd hooks run post-merge\n",
"pre-push": "#!/bin/sh\nbd hooks run pre-push\n",
},
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit", "post-merge", "pre-push"},
},
{
name: "partial bd integration",
hooks: map[string]string{
"pre-commit": "#!/bin/sh\nbd hooks run pre-commit\n",
"post-merge": "#!/bin/sh\necho 'no bd'\n",
},
expectConfigured: true,
expectHooksWithBd: []string{"pre-commit"},
expectHooksWithoutBd: []string{"post-merge"},
expectNotInConfig: []string{"pre-push"},
},
{
name: "no bd at all",
hooks: map[string]string{
"pre-commit": "#!/bin/sh\neslint .\n",
},
expectConfigured: false,
expectHooksWithoutBd: []string{"pre-commit"},
expectNotInConfig: []string{"post-merge", "pre-push"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if tt.hooks != nil {
huskyDir := filepath.Join(dir, ".husky")
if err := os.MkdirAll(huskyDir, 0755); err != nil {
t.Fatalf("failed to create .husky: %v", err)
}
for hookName, content := range tt.hooks {
if err := os.WriteFile(filepath.Join(huskyDir, hookName), []byte(content), 0755); err != nil {
t.Fatalf("failed to write hook %s: %v", hookName, err)
}
}
}
status := CheckHuskyBdIntegration(dir)
if tt.hooks == nil {
if status != nil {
t.Errorf("expected nil status for no .husky, got %+v", status)
}
return
}
if status == nil {
t.Fatal("expected non-nil status")
}
if status.Configured != tt.expectConfigured {
t.Errorf("Configured: expected %v, got %v", tt.expectConfigured, status.Configured)
}
if !slicesEqual(status.HooksWithBd, tt.expectHooksWithBd) {
t.Errorf("HooksWithBd: expected %v, got %v", tt.expectHooksWithBd, status.HooksWithBd)
}
if !slicesEqual(status.HooksWithoutBd, tt.expectHooksWithoutBd) {
t.Errorf("HooksWithoutBd: expected %v, got %v", tt.expectHooksWithoutBd, status.HooksWithoutBd)
}
if !slicesEqual(status.HooksNotInConfig, tt.expectNotInConfig) {
t.Errorf("HooksNotInConfig: expected %v, got %v", tt.expectNotInConfig, status.HooksNotInConfig)
}
})
}
}
func TestBdHookPatternMatching(t *testing.T) {
tests := []struct {
content string
matches bool
}{
{"bd hooks run pre-commit", true},
{"bd hooks run pre-commit", true},
{"bd hooks run post-merge", true},
{`bd hooks run pre-push "$@"`, true},
{"if command -v bd; then bd hooks run pre-commit; fi", true},
{"# bd hooks run is recommended", true},
// Word boundary tests - should NOT match partial words
{"kbd hooks runner", false}, // 'kbd' contains 'bd' but is different word
{"bd_hooks_run", false}, // underscores make different tokens
{"bd sync", false},
{"bd export", false},
{".beads/", false},
{"eslint .", false},
{"echo hello", false},
}
for _, tt := range tests {
t.Run(tt.content, func(t *testing.T) {
if got := bdHookPattern.MatchString(tt.content); got != tt.matches {
t.Errorf("bdHookPattern.MatchString(%q) = %v, want %v", tt.content, got, tt.matches)
}
})
}
}
func TestDetectActiveHookManager(t *testing.T) {
tests := []struct {
name string
hookContent string
expected string
}{
{
name: "lefthook signature",
hookContent: "#!/bin/sh\n# lefthook\nexec lefthook run pre-commit\n",
expected: "lefthook",
},
{
name: "husky signature",
hookContent: "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\nnpm test\n",
expected: "husky",
},
{
name: "pre-commit framework signature",
hookContent: "#!/usr/bin/env bash\n# PRE_COMMIT hook\npre-commit run --all-files\n",
expected: "pre-commit",
},
{
name: "simple-git-hooks signature",
hookContent: "#!/bin/sh\n# simple-git-hooks\nnpm run lint\n",
expected: "simple-git-hooks",
},
{
name: "no manager signature",
hookContent: "#!/bin/sh\necho 'custom hook'\n",
expected: "",
},
{
name: "empty hook",
hookContent: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
// Initialize real git repo
cmd := exec.Command("git", "init")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Write hook file
if tt.hookContent != "" {
hooksDir := filepath.Join(dir, ".git", "hooks")
hookPath := filepath.Join(hooksDir, "pre-commit")
if err := os.WriteFile(hookPath, []byte(tt.hookContent), 0755); err != nil {
t.Fatalf("failed to write hook: %v", err)
}
}
result := DetectActiveHookManager(dir)
if result != tt.expected {
t.Errorf("DetectActiveHookManager() = %q, want %q", result, tt.expected)
}
})
}
}
func TestDetectActiveHookManager_CustomHooksPath(t *testing.T) {
dir := t.TempDir()
// Initialize real git repo
cmd := exec.Command("git", "init")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Create custom hooks directory outside .git
customHooksDir := filepath.Join(dir, "my-hooks")
if err := os.MkdirAll(customHooksDir, 0755); err != nil {
t.Fatalf("failed to create custom hooks dir: %v", err)
}
// Write hook in custom location with lefthook signature
hookContent := "#!/bin/sh\n# lefthook\nexec lefthook run pre-commit\n"
if err := os.WriteFile(filepath.Join(customHooksDir, "pre-commit"), []byte(hookContent), 0755); err != nil {
t.Fatalf("failed to write hook: %v", err)
}
// Set core.hooksPath via git config
configCmd := exec.Command("git", "config", "core.hooksPath", "my-hooks")
configCmd.Dir = dir
if err := configCmd.Run(); err != nil {
t.Fatalf("failed to set core.hooksPath: %v", err)
}
result := DetectActiveHookManager(dir)
if result != "lefthook" {
t.Errorf("DetectActiveHookManager() with core.hooksPath = %q, want %q", result, "lefthook")
}
}
func TestHasBdInCommands(t *testing.T) {
tests := []struct {
name string
section interface{}
expected bool
}{
{
name: "bd hooks run in commands",
section: map[string]interface{}{
"commands": map[string]interface{}{
"bd": map[string]interface{}{
"run": "bd hooks run pre-commit",
},
},
},
expected: true,
},
{
name: "no bd in commands",
section: map[string]interface{}{
"commands": map[string]interface{}{
"lint": map[string]interface{}{
"run": "eslint .",
},
},
},
expected: false,
},
{
name: "bd mentioned in comment not run field",
section: map[string]interface{}{
"commands": map[string]interface{}{
"lint": map[string]interface{}{
"run": "eslint .",
"tags": "bd hooks run should be added",
},
},
},
expected: false,
},
{
name: "nil section",
section: nil,
expected: false,
},
{
name: "non-map section",
section: "string value",
expected: false,
},
{
name: "no commands key",
section: map[string]interface{}{
"parallel": true,
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasBdInCommands(tt.section)
if result != tt.expected {
t.Errorf("hasBdInCommands() = %v, want %v", result, tt.expected)
}
})
}
}
// slicesEqual compares two string slices for equality (order-insensitive)
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]bool)
for _, v := range a {
aMap[v] = true
}
for _, v := range b {
if !aMap[v] {
return false
}
}
return true
}
func TestCheckExternalHookManagerIntegration(t *testing.T) {
tests := []struct {
name string
setup func(dir string) error
expectNil bool
expectManager string
expectConfigured bool
}{
{
name: "no managers",
setup: func(dir string) error {
return nil
},
expectNil: true,
},
{
name: "lefthook with bd integration",
setup: func(dir string) error {
config := `pre-commit:
commands:
bd:
run: bd hooks run pre-commit
`
return os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte(config), 0644)
},
expectNil: false,
expectManager: "lefthook",
expectConfigured: true,
},
{
name: "husky with bd integration",
setup: func(dir string) error {
huskyDir := filepath.Join(dir, ".husky")
if err := os.MkdirAll(huskyDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(huskyDir, "pre-commit"), []byte("#!/bin/sh\nbd hooks run pre-commit\n"), 0755)
},
expectNil: false,
expectManager: "husky",
expectConfigured: true,
},
{
name: "unsupported manager (pre-commit framework)",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, ".pre-commit-config.yaml"), []byte("repos:\n"), 0644)
},
expectNil: false,
expectManager: "pre-commit",
expectConfigured: false,
},
{
name: "lefthook without bd",
setup: func(dir string) error {
config := `pre-commit:
commands:
lint:
run: eslint .
`
return os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte(config), 0644)
},
expectNil: false,
expectManager: "lefthook",
expectConfigured: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := tt.setup(dir); err != nil {
t.Fatalf("setup failed: %v", err)
}
result := CheckExternalHookManagerIntegration(dir)
if tt.expectNil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manager != tt.expectManager {
t.Errorf("Manager: expected %q, got %q", tt.expectManager, result.Manager)
}
if result.Configured != tt.expectConfigured {
t.Errorf("Configured: expected %v, got %v", tt.expectConfigured, result.Configured)
}
})
}
}
func TestMultipleManagersDetected(t *testing.T) {
tests := []struct {
name string
setup func(dir string) error
expectManager string
}{
{
name: "lefthook and husky both present - lefthook wins by priority",
setup: func(dir string) error {
// Create lefthook config
config := `pre-commit:
commands:
bd:
run: bd hooks run pre-commit
`
if err := os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte(config), 0644); err != nil {
return err
}
// Create husky directory
huskyDir := filepath.Join(dir, ".husky")
if err := os.MkdirAll(huskyDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(huskyDir, "pre-commit"), []byte("#!/bin/sh\nbd hooks run pre-commit\n"), 0755)
},
expectManager: "lefthook",
},
{
name: "husky only",
setup: func(dir string) error {
huskyDir := filepath.Join(dir, ".husky")
if err := os.MkdirAll(huskyDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(huskyDir, "pre-commit"), []byte("#!/bin/sh\nbd hooks run pre-commit\n"), 0755)
},
expectManager: "husky",
},
{
name: "multiple unsupported managers",
setup: func(dir string) error {
// pre-commit and overcommit
if err := os.WriteFile(filepath.Join(dir, ".pre-commit-config.yaml"), []byte("repos:\n"), 0644); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, ".overcommit.yml"), []byte("PreCommit:\n"), 0644)
},
expectManager: "pre-commit, overcommit", // Falls through to basic status
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := tt.setup(dir); err != nil {
t.Fatalf("setup failed: %v", err)
}
result := CheckExternalHookManagerIntegration(dir)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manager != tt.expectManager {
t.Errorf("Manager: expected %q, got %q", tt.expectManager, result.Manager)
}
})
}
}
func TestManagerNames(t *testing.T) {
tests := []struct {
managers []ExternalHookManager
expected string
}{
{nil, ""},
{[]ExternalHookManager{}, ""},
{[]ExternalHookManager{{Name: "lefthook"}}, "lefthook"},
{[]ExternalHookManager{{Name: "lefthook"}, {Name: "husky"}}, "lefthook, husky"},
{[]ExternalHookManager{{Name: "a"}, {Name: "b"}, {Name: "c"}}, "a, b, c"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := ManagerNames(tt.managers)
if result != tt.expected {
t.Errorf("ManagerNames() = %q, want %q", result, tt.expected)
}
})
}
}
func TestCheckManagerBdIntegration(t *testing.T) {
tests := []struct {
name string
managerName string
setup func(dir string) error
expectNil bool
expectManager string
}{
{
name: "unknown manager returns nil",
managerName: "unknown-manager",
setup: func(dir string) error { return nil },
expectNil: true,
},
{
name: "lefthook integration",
managerName: "lefthook",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte("pre-commit:\n commands:\n bd:\n run: bd hooks run pre-commit\n"), 0644)
},
expectNil: false,
expectManager: "lefthook",
},
{
name: "husky integration",
managerName: "husky",
setup: func(dir string) error {
huskyDir := filepath.Join(dir, ".husky")
if err := os.MkdirAll(huskyDir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(huskyDir, "pre-commit"), []byte("bd hooks run pre-commit"), 0755)
},
expectNil: false,
expectManager: "husky",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := tt.setup(dir); err != nil {
t.Fatalf("setup failed: %v", err)
}
result := checkManagerBdIntegration(tt.managerName, dir)
if tt.expectNil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manager != tt.expectManager {
t.Errorf("Manager: expected %q, got %q", tt.expectManager, result.Manager)
}
})
}
}
func TestDetectExternalHookManagers_MultiplePresentInOrder(t *testing.T) {
dir := t.TempDir()
// Create configs for multiple managers
// lefthook (priority 0)
if err := os.WriteFile(filepath.Join(dir, "lefthook.yml"), []byte("pre-commit:\n"), 0644); err != nil {
t.Fatal(err)
}
// husky (priority 1)
if err := os.MkdirAll(filepath.Join(dir, ".husky"), 0755); err != nil {
t.Fatal(err)
}
// pre-commit (priority 2)
if err := os.WriteFile(filepath.Join(dir, ".pre-commit-config.yaml"), []byte("repos:\n"), 0644); err != nil {
t.Fatal(err)
}
managers := DetectExternalHookManagers(dir)
if len(managers) != 3 {
t.Fatalf("expected 3 managers, got %d: %v", len(managers), managers)
}
// Verify order matches priority
expectedOrder := []string{"lefthook", "husky", "pre-commit"}
for i, exp := range expectedOrder {
if managers[i].Name != exp {
t.Errorf("managers[%d]: expected %q, got %q", i, exp, managers[i].Name)
}
}
}

View File

@@ -16,6 +16,11 @@ import (
"github.com/steveyegge/beads/internal/syncbranch"
)
const (
hooksExamplesURL = "https://github.com/steveyegge/beads/tree/main/examples/git-hooks"
hooksUpgradeURL = "https://github.com/steveyegge/beads/issues/615"
)
// CheckGitHooks verifies that recommended git hooks are installed.
func CheckGitHooks() DoctorCheck {
// Check if we're in a git repository using worktree-aware detection
@@ -48,6 +53,51 @@ func CheckGitHooks() DoctorCheck {
}
}
// Get repo root for external manager detection
repoRoot := filepath.Dir(gitDir)
if filepath.Base(gitDir) != ".git" {
// Worktree case - gitDir might be .git file content
if cwd, err := os.Getwd(); err == nil {
repoRoot = cwd
}
}
// Check for external hook managers (lefthook, husky, etc.)
externalManagers := fix.DetectExternalHookManagers(repoRoot)
if len(externalManagers) > 0 {
// External manager detected - check if it's configured to call bd
integration := fix.CheckExternalHookManagerIntegration(repoRoot)
if integration != nil && integration.Configured {
// Check if any hooks are missing bd integration
if len(integration.HooksWithoutBd) > 0 {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusWarning,
Message: fmt.Sprintf("%s hooks not calling bd", integration.Manager),
Detail: fmt.Sprintf("Missing bd: %s", strings.Join(integration.HooksWithoutBd, ", ")),
Fix: "Add or upgrade to 'bd hooks run <hook>'. See " + hooksUpgradeURL,
}
}
// All hooks calling bd - success
return DoctorCheck{
Name: "Git Hooks",
Status: StatusOK,
Message: fmt.Sprintf("All hooks via %s", integration.Manager),
Detail: fmt.Sprintf("bd hooks run: %s", strings.Join(integration.HooksWithBd, ", ")),
}
} else {
// External manager exists but doesn't call bd at all
return DoctorCheck{
Name: "Git Hooks",
Status: StatusWarning,
Message: fmt.Sprintf("%s not calling bd", fix.ManagerNames(externalManagers)),
Detail: "Configure hooks to call bd commands",
Fix: "Add or upgrade to 'bd hooks run <hook>'. See " + hooksUpgradeURL,
}
}
}
if len(missingHooks) == 0 {
return DoctorCheck{
Name: "Git Hooks",
@@ -57,7 +107,7 @@ func CheckGitHooks() DoctorCheck {
}
}
hookInstallMsg := "Install hooks with 'bd hooks install'. See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions"
hookInstallMsg := "Install hooks with 'bd hooks install'. See " + hooksExamplesURL
if len(installedHooks) > 0 {
return DoctorCheck{
@@ -293,7 +343,58 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
// Check if this is a bd hook and extract version
hookStr := string(hookContent)
if !strings.Contains(hookStr, "bd-hooks-version:") {
// Not a bd hook - can't determine compatibility
// Not a bd hook - check if it's an external hook manager
externalManagers := fix.DetectExternalHookManagers(path)
if len(externalManagers) > 0 {
names := fix.ManagerNames(externalManagers)
// Check if external manager has bd integration
integration := fix.CheckExternalHookManagerIntegration(path)
if integration != nil && integration.Configured {
// Has bd integration - check if pre-push is covered
hasPrepush := false
for _, h := range integration.HooksWithBd {
if h == "pre-push" {
hasPrepush = true
break
}
}
if hasPrepush {
var detail string
// Only report hooks that ARE in config but lack bd integration
if len(integration.HooksWithoutBd) > 0 {
detail = fmt.Sprintf("Hooks without bd: %s", strings.Join(integration.HooksWithoutBd, ", "))
}
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: fmt.Sprintf("Managed by %s with bd integration", integration.Manager),
Detail: detail,
}
}
// Has bd integration but missing pre-push
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,
Message: fmt.Sprintf("Managed by %s (missing pre-push bd integration)", integration.Manager),
Detail: "pre-push hook needs 'bd hooks run pre-push' for sync-branch",
Fix: fmt.Sprintf("Add or upgrade to 'bd hooks run pre-push' in %s. See %s", integration.Manager, hooksExamplesURL),
}
}
// External manager detected but no bd integration found
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,
Message: fmt.Sprintf("Managed by %s (no bd integration detected)", names),
Detail: fmt.Sprintf("Pre-push hook managed by %s but no 'bd hooks run' found", names),
Fix: fmt.Sprintf("Add or upgrade to 'bd hooks run <hook>' in %s. See %s", names, hooksExamplesURL),
}
}
// No external manager - truly custom hook
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,