package main import ( "bufio" "embed" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/git" ) //go:embed templates/hooks/* var hooksFS embed.FS func getEmbeddedHooks() (map[string]string, error) { hooks := make(map[string]string) hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} for _, name := range hookNames { content, err := hooksFS.ReadFile("templates/hooks/" + name) if err != nil { return nil, fmt.Errorf("failed to read embedded hook %s: %w", name, err) } hooks[name] = string(content) } return hooks, nil } const hookVersionPrefix = "# bd-hooks-version: " const shimVersionPrefix = "# bd-shim " // HookStatus represents the status of a single git hook type HookStatus struct { Name string Installed bool Version string IsShim bool // true if this is a thin shim (version-agnostic) Outdated bool } // CheckGitHooks checks the status of bd git hooks in .git/hooks/ func CheckGitHooks() []HookStatus { hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} statuses := make([]HookStatus, 0, len(hooks)) // Get actual git directory (handles worktrees) gitDir, err := git.GetGitDir() if err != nil { // Not a git repo - return all hooks as not installed for _, hookName := range hooks { statuses = append(statuses, HookStatus{Name: hookName, Installed: false}) } return statuses } for _, hookName := range hooks { status := HookStatus{ Name: hookName, } // Check if hook exists hookPath := filepath.Join(gitDir, "hooks", hookName) versionInfo, err := getHookVersion(hookPath) if err != nil { // Hook doesn't exist or couldn't be read status.Installed = false } else { status.Installed = true status.Version = versionInfo.Version status.IsShim = versionInfo.IsShim // Thin shims are never outdated (they delegate to bd) // Legacy hooks are outdated if version differs from current bd version if !versionInfo.IsShim && versionInfo.Version != "" && versionInfo.Version != Version { status.Outdated = true } } statuses = append(statuses, status) } return statuses } // hookVersionInfo contains version information extracted from a hook file type hookVersionInfo struct { Version string // bd version (for legacy hooks) or shim version IsShim bool // true if this is a thin shim } // getHookVersion extracts the version from a hook file func getHookVersion(path string) (hookVersionInfo, error) { // #nosec G304 -- hook path constrained to .git/hooks directory file, err := os.Open(path) if err != nil { return hookVersionInfo{}, err } defer file.Close() scanner := bufio.NewScanner(file) // Read first few lines looking for version marker lineCount := 0 for scanner.Scan() && lineCount < 10 { line := scanner.Text() // Check for thin shim marker first if strings.HasPrefix(line, shimVersionPrefix) { version := strings.TrimSpace(strings.TrimPrefix(line, shimVersionPrefix)) return hookVersionInfo{Version: version, IsShim: true}, nil } // Check for legacy version marker if strings.HasPrefix(line, hookVersionPrefix) { version := strings.TrimSpace(strings.TrimPrefix(line, hookVersionPrefix)) return hookVersionInfo{Version: version, IsShim: false}, nil } lineCount++ } // No version found (old hook) return hookVersionInfo{}, nil } // FormatHookWarnings returns a formatted warning message if hooks are outdated func FormatHookWarnings(statuses []HookStatus) string { var warnings []string missingCount := 0 outdatedCount := 0 for _, status := range statuses { if !status.Installed { missingCount++ } else if status.Outdated { outdatedCount++ } } if missingCount > 0 { warnings = append(warnings, fmt.Sprintf("⚠️ Git hooks not installed (%d missing)", missingCount)) warnings = append(warnings, " Run: bd hooks install") } if outdatedCount > 0 { warnings = append(warnings, fmt.Sprintf("⚠️ Git hooks are outdated (%d hooks)", outdatedCount)) warnings = append(warnings, " Run: bd hooks install") } if len(warnings) > 0 { return strings.Join(warnings, "\n") } return "" } // Cobra commands var hooksCmd = &cobra.Command{ Use: "hooks", Short: "Manage git hooks for bd auto-sync", Long: `Install, uninstall, or list git hooks that provide automatic bd sync. The hooks ensure that: - pre-commit: Flushes pending changes to JSONL before commit - post-merge: Imports updated JSONL after pull/merge - pre-push: Prevents pushing stale JSONL - post-checkout: Imports JSONL after branch checkout`, } var hooksInstallCmd = &cobra.Command{ Use: "install", Short: "Install bd git hooks", Long: `Install git hooks for automatic bd sync. By default, hooks are installed to .git/hooks/ in the current repository. Use --shared to install to a versioned directory (.beads-hooks/) that can be committed to git and shared with team members. Installed hooks: - pre-commit: Flush changes to JSONL before commit - post-merge: Import JSONL after pull/merge - pre-push: Prevent pushing stale JSONL - post-checkout: Import JSONL after branch checkout`, Run: func(cmd *cobra.Command, args []string) { force, _ := cmd.Flags().GetBool("force") shared, _ := cmd.Flags().GetBool("shared") embeddedHooks, err := getEmbeddedHooks() if err != nil { if jsonOutput { output := map[string]interface{}{ "error": err.Error(), } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) } else { fmt.Fprintf(os.Stderr, "Error loading hooks: %v\n", err) } os.Exit(1) } if err := installHooks(embeddedHooks, force, shared); err != nil { if jsonOutput { output := map[string]interface{}{ "error": err.Error(), } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) } else { fmt.Fprintf(os.Stderr, "Error installing hooks: %v\n", err) } os.Exit(1) } if jsonOutput { output := map[string]interface{}{ "success": true, "message": "Git hooks installed successfully", "shared": shared, } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) } else { fmt.Println("✓ Git hooks installed successfully") fmt.Println() if shared { fmt.Println("Hooks installed to: .beads-hooks/") fmt.Println("Git config set: core.hooksPath=.beads-hooks") fmt.Println() fmt.Println("⚠️ Remember to commit .beads-hooks/ to share with your team!") fmt.Println() } fmt.Println("Installed hooks:") for hookName := range embeddedHooks { fmt.Printf(" - %s\n", hookName) } } }, } var hooksUninstallCmd = &cobra.Command{ Use: "uninstall", Short: "Uninstall bd git hooks", Long: `Remove bd git hooks from .git/hooks/ directory.`, Run: func(cmd *cobra.Command, args []string) { if err := uninstallHooks(); err != nil { if jsonOutput { output := map[string]interface{}{ "error": err.Error(), } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) } else { fmt.Fprintf(os.Stderr, "Error uninstalling hooks: %v\n", err) } os.Exit(1) } if jsonOutput { output := map[string]interface{}{ "success": true, "message": "Git hooks uninstalled successfully", } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) } else { fmt.Println("✓ Git hooks uninstalled successfully") } }, } var hooksListCmd = &cobra.Command{ Use: "list", Short: "List installed git hooks status", Long: `Show the status of bd git hooks (installed, outdated, missing).`, Run: func(cmd *cobra.Command, args []string) { statuses := CheckGitHooks() if jsonOutput { output := map[string]interface{}{ "hooks": statuses, } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) } else { fmt.Println("Git hooks status:") for _, status := range statuses { if !status.Installed { fmt.Printf(" ✗ %s: not installed\n", status.Name) } else if status.IsShim { fmt.Printf(" ✓ %s: installed (shim %s)\n", status.Name, status.Version) } else if status.Outdated { fmt.Printf(" ⚠ %s: installed (version %s, current: %s) - outdated\n", status.Name, status.Version, Version) } else { fmt.Printf(" ✓ %s: installed (version %s)\n", status.Name, status.Version) } } } }, } func installHooks(embeddedHooks map[string]string, force bool, shared bool) error { // Get actual git directory (handles worktrees where .git is a file) gitDir, err := git.GetGitDir() if err != nil { return err } var hooksDir string if shared { // Use versioned directory for shared hooks hooksDir = ".beads-hooks" } else { // Use standard .git/hooks directory hooksDir = filepath.Join(gitDir, "hooks") } // Create hooks directory if it doesn't exist if err := os.MkdirAll(hooksDir, 0755); err != nil { return fmt.Errorf("failed to create hooks directory: %w", err) } // Install each hook for hookName, hookContent := range embeddedHooks { hookPath := filepath.Join(hooksDir, hookName) // Check if hook already exists if _, err := os.Stat(hookPath); err == nil { // Hook exists - back it up unless force is set if !force { backupPath := hookPath + ".backup" if err := os.Rename(hookPath, backupPath); err != nil { return fmt.Errorf("failed to backup %s: %w", hookName, err) } } } // Write hook file // #nosec G306 -- git hooks must be executable for Git to run them if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { return fmt.Errorf("failed to write %s: %w", hookName, err) } } // If shared mode, configure git to use the shared hooks directory if shared { if err := configureSharedHooksPath(); err != nil { return fmt.Errorf("failed to configure git hooks path: %w", err) } } return nil } func configureSharedHooksPath() error { // Set git config core.hooksPath to .beads-hooks cmd := exec.Command("git", "config", "core.hooksPath", ".beads-hooks") if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("git config failed: %w (output: %s)", err, string(output)) } return nil } func uninstallHooks() error { // Get actual git directory (handles worktrees) gitDir, err := git.GetGitDir() if err != nil { return err } hooksDir := filepath.Join(gitDir, "hooks") hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} for _, hookName := range hookNames { hookPath := filepath.Join(hooksDir, hookName) // Check if hook exists if _, err := os.Stat(hookPath); os.IsNotExist(err) { continue } // Remove hook if err := os.Remove(hookPath); err != nil { return fmt.Errorf("failed to remove %s: %w", hookName, err) } // Restore backup if exists backupPath := hookPath + ".backup" if _, err := os.Stat(backupPath); err == nil { if err := os.Rename(backupPath, hookPath); err != nil { // Non-fatal - just warn fmt.Fprintf(os.Stderr, "Warning: failed to restore backup for %s: %v\n", hookName, err) } } } return nil } // ============================================================================= // Hook Implementation Functions (called by thin shims via 'bd hooks run') // ============================================================================= // runPreCommitHook flushes pending changes to JSONL before commit. // Returns 0 on success (or if not applicable), non-zero on error. func runPreCommitHook() int { // Check if we're in a bd workspace if _, err := os.Stat(".beads"); os.IsNotExist(err) { return 0 // Not a bd workspace, nothing to do } // Check if sync-branch is configured (changes go to separate branch) if hookGetSyncBranch() != "" { return 0 // Skip - changes synced to separate branch } // Flush pending changes to JSONL // Use --flush-only to skip git operations (we're already in a git hook) cmd := exec.Command("bd", "sync", "--flush-only") if err := cmd.Run(); err != nil { fmt.Fprintln(os.Stderr, "Warning: Failed to flush bd changes to JSONL") fmt.Fprintln(os.Stderr, "Run 'bd sync --flush-only' manually to diagnose") // Don't block the commit - user may have removed beads or have other issues } // Stage all tracked JSONL files for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} { if _, err := os.Stat(f); err == nil { gitAdd := exec.Command("git", "add", f) _ = gitAdd.Run() // Ignore errors - file may not exist } } return 0 } // runPostMergeHook imports JSONL after pull/merge. // Returns 0 on success (or if not applicable), non-zero on error. func runPostMergeHook() int { // Skip during rebase if isRebaseInProgress() { return 0 } // Check if we're in a bd workspace if _, err := os.Stat(".beads"); os.IsNotExist(err) { return 0 } // Check if any JSONL file exists if !hasBeadsJSONL() { return 0 } // Run bd sync --import-only --no-git-history cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history") output, err := cmd.CombinedOutput() if err != nil { fmt.Fprintln(os.Stderr, "Warning: Failed to sync bd changes after merge") fmt.Fprintln(os.Stderr, string(output)) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Run 'bd doctor --fix' to diagnose and repair") // Don't fail the merge, just warn } // Run quick health check healthCmd := exec.Command("bd", "doctor", "--check-health") _ = healthCmd.Run() // Ignore errors return 0 } // runPrePushHook prevents pushing stale JSONL. // Returns 0 to allow push, non-zero to block. func runPrePushHook() int { // Check if we're in a bd workspace if _, err := os.Stat(".beads"); os.IsNotExist(err) { return 0 } // Skip if bd sync is already in progress (prevents circular error) if os.Getenv("BD_SYNC_IN_PROGRESS") != "" { return 0 } // Check if sync-branch is configured if hookGetSyncBranch() != "" { return 0 // Skip - changes synced to separate branch } // Flush pending bd changes flushCmd := exec.Command("bd", "sync", "--flush-only") _ = flushCmd.Run() // Ignore errors // Check for uncommitted JSONL changes files := []string{} for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} { // Check if file exists or is tracked if _, err := os.Stat(f); err == nil { files = append(files, f) } else { // Check if tracked by git checkCmd := exec.Command("git", "ls-files", "--error-unmatch", f) if checkCmd.Run() == nil { files = append(files, f) } } } if len(files) == 0 { return 0 } // Check for uncommitted changes using git status args := append([]string{"status", "--porcelain", "--"}, files...) statusCmd := exec.Command("git", args...) output, _ := statusCmd.Output() if len(output) > 0 { fmt.Fprintln(os.Stderr, "❌ Error: Uncommitted changes detected") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Before pushing, ensure all changes are committed. This includes:") fmt.Fprintln(os.Stderr, " • bd JSONL updates (run 'bd sync')") fmt.Fprintln(os.Stderr, " • any other modified files (run 'git status' to review)") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Run 'bd sync' to commit these changes:") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " bd sync") fmt.Fprintln(os.Stderr, "") return 1 } return 0 } // runPostCheckoutHook imports JSONL after branch checkout. // args: [previous-HEAD, new-HEAD, flag] where flag=1 for branch checkout // Returns 0 on success (or if not applicable), non-zero on error. func runPostCheckoutHook(args []string) int { // Only run on branch checkouts (flag=1) if len(args) >= 3 && args[2] != "1" { return 0 } // Skip during rebase if isRebaseInProgress() { return 0 } // Check if we're in a bd workspace if _, err := os.Stat(".beads"); os.IsNotExist(err) { return 0 } // Detect git worktree and show warning if isGitWorktree() { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "╔══════════════════════════════════════════════════════════════════════════╗") fmt.Fprintln(os.Stderr, "║ Welcome to beads in git worktree! ║") fmt.Fprintln(os.Stderr, "╠══════════════════════════════════════════════════════════════════════════╣") fmt.Fprintln(os.Stderr, "║ Note: Daemon mode is not recommended with git worktrees. ║") fmt.Fprintln(os.Stderr, "║ Worktrees share the same database, and the daemon may commit changes ║") fmt.Fprintln(os.Stderr, "║ to the wrong branch. ║") fmt.Fprintln(os.Stderr, "║ ║") fmt.Fprintln(os.Stderr, "║ RECOMMENDED: Disable daemon for this session: ║") fmt.Fprintln(os.Stderr, "║ export BEADS_NO_DAEMON=1 ║") fmt.Fprintln(os.Stderr, "║ ║") fmt.Fprintln(os.Stderr, "║ For more information: ║") fmt.Fprintln(os.Stderr, "║ - Run: bd doctor ║") fmt.Fprintln(os.Stderr, "║ - Read: docs/GIT_INTEGRATION.md (lines 10-53) ║") fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════════════╝") fmt.Fprintln(os.Stderr, "") } // Check if any JSONL file exists if !hasBeadsJSONL() { return 0 } // Run bd sync --import-only --no-git-history cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history") output, err := cmd.CombinedOutput() if err != nil { fmt.Fprintln(os.Stderr, "Warning: Failed to sync bd changes after checkout") fmt.Fprintln(os.Stderr, string(output)) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Run 'bd doctor --fix' to diagnose and repair") // Don't fail the checkout, just warn } // Run quick health check healthCmd := exec.Command("bd", "doctor", "--check-health") _ = healthCmd.Run() // Ignore errors return 0 } // ============================================================================= // Hook Helper Functions // ============================================================================= // hookGetSyncBranch returns the configured sync branch for hook context. // This is a simplified version that doesn't require context. func hookGetSyncBranch() string { // Check environment variable first if branch := os.Getenv("BEADS_SYNC_BRANCH"); branch != "" { return branch } // Check config.yaml configPath := ".beads/config.yaml" data, err := os.ReadFile(configPath) // #nosec G304 -- config path is hardcoded if err != nil { return "" } // Simple YAML parsing for sync-branch for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "sync-branch:") { value := strings.TrimPrefix(line, "sync-branch:") value = strings.TrimSpace(value) value = strings.Trim(value, `"'`) return value } } return "" } // isRebaseInProgress checks if a rebase is in progress. func isRebaseInProgress() bool { if _, err := os.Stat(".git/rebase-merge"); err == nil { return true } if _, err := os.Stat(".git/rebase-apply"); err == nil { return true } return false } // hasBeadsJSONL checks if any JSONL file exists in .beads/. func hasBeadsJSONL() bool { for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} { if _, err := os.Stat(f); err == nil { return true } } return false } var hooksRunCmd = &cobra.Command{ Use: "run [args...]", Short: "Execute a git hook (called by thin shims)", Long: `Execute the logic for a git hook. This command is typically called by thin shim scripts installed in .git/hooks/. Supported hooks: - pre-commit: Flush pending changes to JSONL before commit - post-merge: Import JSONL after pull/merge - pre-push: Prevent pushing stale JSONL - post-checkout: Import JSONL after branch checkout The thin shim pattern ensures hook logic is always in sync with the installed bd version - upgrading bd automatically updates hook behavior.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { hookName := args[0] hookArgs := args[1:] var exitCode int switch hookName { case "pre-commit": exitCode = runPreCommitHook() case "post-merge": exitCode = runPostMergeHook() case "pre-push": exitCode = runPrePushHook() case "post-checkout": exitCode = runPostCheckoutHook(hookArgs) default: fmt.Fprintf(os.Stderr, "Unknown hook: %s\n", hookName) os.Exit(1) } os.Exit(exitCode) }, } func init() { hooksInstallCmd.Flags().Bool("force", false, "Overwrite existing hooks without backup") hooksInstallCmd.Flags().Bool("shared", false, "Install hooks to .beads-hooks/ (versioned) instead of .git/hooks/") hooksCmd.AddCommand(hooksInstallCmd) hooksCmd.AddCommand(hooksUninstallCmd) hooksCmd.AddCommand(hooksListCmd) hooksCmd.AddCommand(hooksRunCmd) rootCmd.AddCommand(hooksCmd) }