diff --git a/.beads-hooks/post-checkout b/.beads-hooks/post-checkout new file mode 100755 index 00000000..65a87d7d --- /dev/null +++ b/.beads-hooks/post-checkout @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# bd-hooks-version: 0.24.2 +# +# Beads post-checkout hook +# Automatically imports JSONL to SQLite database after checking out branches +# +# Install: cp examples/git-hooks/post-checkout .git/hooks/post-checkout && chmod +x .git/hooks/post-checkout + +# Arguments provided by git: +# $1 = ref of previous HEAD +# $2 = ref of new HEAD +# $3 = flag (1 if branch checkout, 0 if file checkout) + +# Only run on branch checkouts +if [[ "$3" != "1" ]]; then + exit 0 +fi + +set -e + +# Check if bd is installed +if ! command -v bd &> /dev/null; then + exit 0 +fi + +# Check if issues.jsonl exists +if [[ ! -f .beads/issues.jsonl ]]; then + exit 0 +fi + +# Import issues from JSONL +echo "🔗 Importing beads issues from JSONL..." + +if bd import -i .beads/issues.jsonl 2>/dev/null; then + echo "✓ Beads issues imported successfully" +else + echo "Warning: bd import failed" +fi + +exit 0 diff --git a/.beads-hooks/post-merge b/.beads-hooks/post-merge new file mode 100755 index 00000000..65e87792 --- /dev/null +++ b/.beads-hooks/post-merge @@ -0,0 +1,45 @@ +#!/bin/sh +# bd-hooks-version: 0.24.2 +# +# bd (beads) post-merge hook +# +# This hook syncs the bd database after a git pull or merge: +# 1. Checks if any .beads/*.jsonl file was updated +# 2. Runs 'bd sync --import-only' to import changes +# +# Installation: +# cp examples/git-hooks/post-merge .git/hooks/post-merge +# chmod +x .git/hooks/post-merge +# +# Or use the install script: +# examples/git-hooks/install.sh + +# Check if bd is available +if ! command -v bd >/dev/null 2>&1; then + echo "Warning: bd command not found, skipping post-merge sync" >&2 + exit 0 +fi + +# Check if we're in a bd workspace +if [ ! -d .beads ]; then + # Not a bd workspace, nothing to do + exit 0 +fi + +# Check if any JSONL file exists in .beads/ +if ! ls .beads/*.jsonl >/dev/null 2>&1; then + exit 0 +fi + +# Run bd sync --import-only to import the updated JSONL +# This is more robust than direct import as it handles all edge cases +# Capture both stdout and stderr to show user what went wrong +if ! output=$(bd sync --import-only 2>&1); then + echo "Warning: Failed to sync bd changes after merge" >&2 + echo "$output" >&2 + echo "" >&2 + echo "Run 'bd sync --import-only' manually to resolve" >&2 + # Don't fail the merge, just warn +fi + +exit 0 diff --git a/.beads-hooks/pre-commit b/.beads-hooks/pre-commit new file mode 100755 index 00000000..b1a64945 --- /dev/null +++ b/.beads-hooks/pre-commit @@ -0,0 +1,44 @@ +#!/bin/sh +# bd-hooks-version: 0.24.2 +# +# bd (beads) pre-commit hook +# +# This hook ensures that any pending bd issue changes are flushed to +# .beads/beads.jsonl before the commit is created, preventing the +# race condition where daemon auto-flush fires after the commit. +# +# Installation: +# cp examples/git-hooks/pre-commit .git/hooks/pre-commit +# chmod +x .git/hooks/pre-commit +# +# Or use the install script: +# examples/git-hooks/install.sh + +# Check if bd is available +if ! command -v bd >/dev/null 2>&1; then + echo "Warning: bd command not found, skipping pre-commit flush" >&2 + exit 0 +fi + +# Check if we're in a bd workspace +if [ ! -d .beads ]; then + # Not a bd workspace, nothing to do + exit 0 +fi + +# Flush pending changes to JSONL +# Use --flush-only to skip git operations (we're already in a git hook) +# Suppress output unless there's an error +if ! bd sync --flush-only >/dev/null 2>&1; then + echo "Error: Failed to flush bd changes to JSONL" >&2 + echo "Run 'bd sync --flush-only' manually to diagnose" >&2 + exit 1 +fi + +# Stage both possible JSONL files (backward compatibility) +# git add is harmless if file doesn't exist +for f in .beads/beads.jsonl .beads/issues.jsonl; do + [ -f "$f" ] && git add "$f" 2>/dev/null || true +done + +exit 0 diff --git a/.beads-hooks/pre-push b/.beads-hooks/pre-push new file mode 100755 index 00000000..05aa7a54 --- /dev/null +++ b/.beads-hooks/pre-push @@ -0,0 +1,62 @@ +#!/bin/sh +# bd-hooks-version: 0.24.2 +# +# bd (beads) pre-push hook +# +# This hook prevents pushing stale JSONL by: +# 1. Flushing any pending in-memory changes to JSONL (if bd available) +# 2. Checking for uncommitted changes (staged, unstaged, untracked, deleted) +# 3. Failing the push with clear instructions if changes found +# +# The pre-commit hook already exports changes, but this catches: +# - Changes made between commit and push +# - Pending debounced flushes (5s daemon delay) +# +# Installation: +# cp examples/git-hooks/pre-push .git/hooks/pre-push +# chmod +x .git/hooks/pre-push +# +# Or use the install script: +# examples/git-hooks/install.sh + +# Check if we're in a bd workspace +if [ ! -d .beads ]; then + # Not a bd workspace, nothing to do + exit 0 +fi + +# Optionally flush pending bd changes so they surface in JSONL +# This prevents the race where a debounced flush lands after the check +if command -v bd >/dev/null 2>&1; then + bd sync --flush-only >/dev/null 2>&1 || true +fi + +# Collect all tracked or existing JSONL files (supports both old and new names) +FILES="" +for f in .beads/beads.jsonl .beads/issues.jsonl; do + # Include file if it exists in working tree OR is tracked by git (even if deleted) + if git ls-files --error-unmatch "$f" >/dev/null 2>&1 || [ -f "$f" ]; then + FILES="$FILES $f" + fi +done + +# Check for any uncommitted changes using porcelain status +# This catches: staged, unstaged, untracked, deleted, renamed, and conflicts +if [ -n "$FILES" ]; then + # shellcheck disable=SC2086 + if [ -n "$(git status --porcelain -- $FILES 2>/dev/null)" ]; then + echo "❌ Error: Beads JSONL has uncommitted changes" >&2 + echo "" >&2 + echo "You made changes to bd issues between your last commit and this push." >&2 + echo "Please commit the updated JSONL before pushing:" >&2 + echo "" >&2 + # shellcheck disable=SC2086 + echo " git add $FILES" >&2 + echo ' git commit -m "Update bd JSONL"' >&2 + echo " git push" >&2 + echo "" >&2 + exit 1 + fi +fi + +exit 0 diff --git a/cmd/bd/hooks.go b/cmd/bd/hooks.go index f6cda494..32b6853c 100644 --- a/cmd/bd/hooks.go +++ b/cmd/bd/hooks.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -148,8 +149,9 @@ var hooksInstallCmd = &cobra.Command{ Short: "Install bd git hooks", Long: `Install git hooks for automatic bd sync. -Hooks are installed to .git/hooks/ in the current repository. -Existing hooks are backed up with a .backup suffix. +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 @@ -158,6 +160,7 @@ Installed hooks: - 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 { @@ -173,7 +176,7 @@ Installed hooks: os.Exit(1) } - if err := installHooks(embeddedHooks, force); err != nil { + if err := installHooks(embeddedHooks, force, shared); err != nil { if jsonOutput { output := map[string]interface{}{ "error": err.Error(), @@ -190,12 +193,20 @@ Installed hooks: 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) @@ -264,14 +275,21 @@ var hooksListCmd = &cobra.Command{ }, } -func installHooks(embeddedHooks map[string]string, force bool) error { +func installHooks(embeddedHooks map[string]string, force bool, shared bool) error { // Check if .git directory exists gitDir := ".git" if _, err := os.Stat(gitDir); os.IsNotExist(err) { return fmt.Errorf("not a git repository (no .git directory found)") } - hooksDir := filepath.Join(gitDir, "hooks") + 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 { @@ -300,6 +318,22 @@ func installHooks(embeddedHooks map[string]string, force bool) error { } } + // 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 } @@ -335,6 +369,7 @@ func uninstallHooks() error { 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)