Pre-commit and post-merge git hooks were unconditionally running bd sync/import for JSONL sync, which fails with Dolt backend. Now both hooks check metadata.json for backend type and exit early if Dolt is configured. Dolt uses its own sync mechanism. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
700 lines
22 KiB
Go
700 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/git"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
)
|
|
|
|
|
|
// preCommitFrameworkPattern matches pre-commit or prek framework hooks.
|
|
// Uses same patterns as hookManagerPatterns in doctor/fix/hooks.go for consistency.
|
|
// Includes all detection patterns: pre-commit run, prek run/hook-impl, config file refs, and pre-commit env vars.
|
|
var preCommitFrameworkPattern = regexp.MustCompile(`(?i)(pre-commit\s+run|prek\s+run|prek\s+hook-impl|\.pre-commit-config|INSTALL_PYTHON|PRE_COMMIT)`)
|
|
|
|
// hooksInstalled checks if bd git hooks are installed
|
|
func hooksInstalled() bool {
|
|
hooksDir, err := git.GetGitHooksDir()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
preCommit := filepath.Join(hooksDir, "pre-commit")
|
|
postMerge := filepath.Join(hooksDir, "post-merge")
|
|
|
|
// Check if both hooks exist
|
|
_, err1 := os.Stat(preCommit)
|
|
_, err2 := os.Stat(postMerge)
|
|
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
|
|
// Verify they're bd hooks by checking for signature comment
|
|
// #nosec G304 - controlled path from git directory
|
|
preCommitContent, err := os.ReadFile(preCommit)
|
|
if err != nil || !strings.Contains(string(preCommitContent), "bd (beads) pre-commit hook") {
|
|
return false
|
|
}
|
|
|
|
// #nosec G304 - controlled path from git directory
|
|
postMergeContent, err := os.ReadFile(postMerge)
|
|
if err != nil || !strings.Contains(string(postMergeContent), "bd (beads) post-merge hook") {
|
|
return false
|
|
}
|
|
|
|
// Verify hooks are executable
|
|
preCommitInfo, err := os.Stat(preCommit)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if preCommitInfo.Mode().Perm()&0111 == 0 {
|
|
return false // Not executable
|
|
}
|
|
|
|
postMergeInfo, err := os.Stat(postMerge)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if postMergeInfo.Mode().Perm()&0111 == 0 {
|
|
return false // Not executable
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// hookInfo contains information about an existing hook
|
|
type hookInfo struct {
|
|
name string
|
|
path string
|
|
exists bool
|
|
isBdHook bool
|
|
isPreCommitFramework bool // true for pre-commit or prek
|
|
content string
|
|
}
|
|
|
|
// detectExistingHooks scans for existing git hooks
|
|
func detectExistingHooks() []hookInfo {
|
|
hooksDir, err := git.GetGitHooksDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
hooks := []hookInfo{
|
|
{name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")},
|
|
{name: "post-merge", path: filepath.Join(hooksDir, "post-merge")},
|
|
{name: "pre-push", path: filepath.Join(hooksDir, "pre-push")},
|
|
}
|
|
|
|
for i := range hooks {
|
|
content, err := os.ReadFile(hooks[i].path)
|
|
if err == nil {
|
|
hooks[i].exists = true
|
|
hooks[i].content = string(content)
|
|
hooks[i].isBdHook = strings.Contains(hooks[i].content, "bd (beads)")
|
|
// Only detect pre-commit/prek framework if not a bd hook
|
|
// Use regex for consistency with DetectActiveHookManager patterns
|
|
if !hooks[i].isBdHook {
|
|
hooks[i].isPreCommitFramework = preCommitFrameworkPattern.MatchString(hooks[i].content)
|
|
}
|
|
}
|
|
}
|
|
|
|
return hooks
|
|
}
|
|
|
|
// promptHookAction asks user what to do with existing hooks
|
|
func promptHookAction(existingHooks []hookInfo) string {
|
|
fmt.Printf("\n%s Found existing git hooks:\n", ui.RenderWarn("⚠"))
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
hookType := "custom script"
|
|
if hook.isPreCommitFramework {
|
|
hookType = "pre-commit/prek framework"
|
|
}
|
|
fmt.Printf(" - %s (%s)\n", hook.name, hookType)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nHow should bd proceed?\n")
|
|
fmt.Printf(" [1] Chain with existing hooks (recommended)\n")
|
|
fmt.Printf(" [2] Overwrite existing hooks\n")
|
|
fmt.Printf(" [3] Skip git hooks installation\n")
|
|
fmt.Printf("Choice [1-3]: ")
|
|
|
|
var response string
|
|
_, _ = fmt.Scanln(&response)
|
|
response = strings.TrimSpace(response)
|
|
|
|
return response
|
|
}
|
|
|
|
// installGitHooks installs git hooks inline (no external dependencies)
|
|
func installGitHooks() error {
|
|
hooksDir, err := git.GetGitHooksDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure hooks directory exists
|
|
if err := os.MkdirAll(hooksDir, 0750); err != nil {
|
|
return fmt.Errorf("failed to create hooks directory: %w", err)
|
|
}
|
|
|
|
// Detect existing hooks
|
|
existingHooks := detectExistingHooks()
|
|
|
|
// Check if any non-bd hooks exist
|
|
hasExistingHooks := false
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
hasExistingHooks = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Determine installation mode
|
|
chainHooks := false
|
|
if hasExistingHooks {
|
|
choice := promptHookAction(existingHooks)
|
|
switch choice {
|
|
case "1", "":
|
|
chainHooks = true
|
|
// Chain mode - rename existing hooks to .old so they can be called
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
oldPath := hook.path + ".old"
|
|
if err := os.Rename(hook.path, oldPath); err != nil {
|
|
return fmt.Errorf("failed to rename %s to .old: %w", hook.name, err)
|
|
}
|
|
fmt.Printf(" Renamed %s to %s\n", hook.name, filepath.Base(oldPath))
|
|
}
|
|
}
|
|
case "2":
|
|
// Overwrite mode - backup existing hooks
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
timestamp := time.Now().Format("20060102-150405")
|
|
backup := hook.path + ".backup-" + timestamp
|
|
if err := os.Rename(hook.path, backup); err != nil {
|
|
return fmt.Errorf("failed to backup %s: %w", hook.name, err)
|
|
}
|
|
fmt.Printf(" Backed up %s to %s\n", hook.name, filepath.Base(backup))
|
|
}
|
|
}
|
|
case "3":
|
|
fmt.Printf("Skipping git hooks installation.\n")
|
|
fmt.Printf("You can install manually later with: %s\n", ui.RenderAccent("./examples/git-hooks/install.sh"))
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid choice: %s", choice)
|
|
}
|
|
}
|
|
|
|
// pre-commit hook
|
|
preCommitPath := filepath.Join(hooksDir, "pre-commit")
|
|
preCommitContent := buildPreCommitHook(chainHooks, existingHooks)
|
|
|
|
// post-merge hook
|
|
postMergePath := filepath.Join(hooksDir, "post-merge")
|
|
postMergeContent := buildPostMergeHook(chainHooks, existingHooks)
|
|
|
|
// Write pre-commit hook (executable scripts need 0700)
|
|
// #nosec G306 - git hooks must be executable
|
|
if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0700); err != nil {
|
|
return fmt.Errorf("failed to write pre-commit hook: %w", err)
|
|
}
|
|
|
|
// Write post-merge hook (executable scripts need 0700)
|
|
// #nosec G306 - git hooks must be executable
|
|
if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0700); err != nil {
|
|
return fmt.Errorf("failed to write post-merge hook: %w", err)
|
|
}
|
|
|
|
if chainHooks {
|
|
fmt.Printf("%s Chained bd hooks with existing hooks\n", ui.RenderPass("✓"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildPreCommitHook generates the pre-commit hook content
|
|
func buildPreCommitHook(chainHooks bool, existingHooks []hookInfo) string {
|
|
if chainHooks {
|
|
// Find existing pre-commit hook (already renamed to .old by caller)
|
|
var existingPreCommit string
|
|
for _, hook := range existingHooks {
|
|
if hook.name == "pre-commit" && hook.exists && !hook.isBdHook {
|
|
existingPreCommit = hook.path + ".old"
|
|
break
|
|
}
|
|
}
|
|
|
|
return `#!/bin/sh
|
|
#
|
|
# bd (beads) pre-commit hook (chained)
|
|
#
|
|
# This hook chains bd functionality with your existing pre-commit hook.
|
|
|
|
# Run existing hook first
|
|
if [ -x "` + existingPreCommit + `" ]; then
|
|
"` + existingPreCommit + `" "$@"
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
exit $EXIT_CODE
|
|
fi
|
|
fi
|
|
|
|
` + preCommitHookBody()
|
|
}
|
|
|
|
return `#!/bin/sh
|
|
#
|
|
# bd (beads) pre-commit hook
|
|
#
|
|
# This hook ensures that any pending bd issue changes are flushed to
|
|
# .beads/issues.jsonl before the commit is created, preventing the
|
|
# race condition where daemon auto-flush fires after the commit.
|
|
|
|
` + preCommitHookBody()
|
|
}
|
|
|
|
// preCommitHookBody returns the common pre-commit hook logic
|
|
func preCommitHookBody() string {
|
|
return `# 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
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Skip for Dolt backend (uses its own sync mechanism, not JSONL)
|
|
if [ -f "$BEADS_DIR/metadata.json" ]; then
|
|
if grep -q '"backend"[[:space:]]*:[[:space:]]*"dolt"' "$BEADS_DIR/metadata.json" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# Flush pending changes to JSONL
|
|
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
|
|
|
|
# If the JSONL file was modified, stage it
|
|
# For worktrees, the JSONL is in the main repo's working tree, not the worktree,
|
|
# so we can't use git add. Skip this step for worktrees.
|
|
if [ -f "$BEADS_DIR/issues.jsonl" ]; then
|
|
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
|
|
# Regular repo: file is in the working tree, safe to add
|
|
git add "$BEADS_DIR/issues.jsonl" 2>/dev/null || true
|
|
fi
|
|
# For worktrees: .beads is in the main repo's working tree, not this worktree
|
|
# Git rejects adding files outside the worktree, so we skip it.
|
|
# The main repo will see the changes on the next pull/sync.
|
|
fi
|
|
|
|
exit 0
|
|
`
|
|
}
|
|
|
|
// buildPostMergeHook generates the post-merge hook content
|
|
func buildPostMergeHook(chainHooks bool, existingHooks []hookInfo) string {
|
|
if chainHooks {
|
|
// Find existing post-merge hook (already renamed to .old by caller)
|
|
var existingPostMerge string
|
|
for _, hook := range existingHooks {
|
|
if hook.name == "post-merge" && hook.exists && !hook.isBdHook {
|
|
existingPostMerge = hook.path + ".old"
|
|
break
|
|
}
|
|
}
|
|
|
|
return `#!/bin/sh
|
|
#
|
|
# bd (beads) post-merge hook (chained)
|
|
#
|
|
# This hook chains bd functionality with your existing post-merge hook.
|
|
|
|
# Run existing hook first
|
|
if [ -x "` + existingPostMerge + `" ]; then
|
|
"` + existingPostMerge + `" "$@"
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
exit $EXIT_CODE
|
|
fi
|
|
fi
|
|
|
|
` + postMergeHookBody()
|
|
}
|
|
|
|
return `#!/bin/sh
|
|
#
|
|
# bd (beads) post-merge hook
|
|
#
|
|
# This hook imports updated issues from .beads/issues.jsonl after a
|
|
# git pull or merge, ensuring the database stays in sync with git.
|
|
|
|
` + postMergeHookBody()
|
|
}
|
|
|
|
// postMergeHookBody returns the common post-merge hook logic
|
|
func postMergeHookBody() string {
|
|
return `# Check if bd is available
|
|
if ! command -v bd >/dev/null 2>&1; then
|
|
echo "Warning: bd command not found, skipping post-merge import" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Check if we're in a bd workspace
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Skip for Dolt backend (uses its own sync mechanism, not JSONL import)
|
|
if [ -f "$BEADS_DIR/metadata.json" ]; then
|
|
if grep -q '"backend"[[:space:]]*:[[:space:]]*"dolt"' "$BEADS_DIR/metadata.json" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# Check if issues.jsonl exists and was updated
|
|
if [ ! -f "$BEADS_DIR/issues.jsonl" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Import the updated JSONL
|
|
if ! bd import -i "$BEADS_DIR/issues.jsonl" >/dev/null 2>&1; then
|
|
echo "Warning: Failed to import bd changes after merge" >&2
|
|
echo "Run 'bd import -i $BEADS_DIR/issues.jsonl' manually to see the error" >&2
|
|
fi
|
|
|
|
exit 0
|
|
`
|
|
}
|
|
|
|
// mergeDriverInstalled checks if bd merge driver is configured correctly
|
|
// Note: This runs during bd init BEFORE .beads exists, so it runs git in CWD.
|
|
func mergeDriverInstalled() bool {
|
|
// Check git config for merge driver (runs in CWD)
|
|
cmd := exec.Command("git", "config", "merge.beads.driver")
|
|
output, err := cmd.Output()
|
|
if err != nil || len(output) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Check if using old invalid placeholders (%L/%R from versions <0.24.0)
|
|
// Git only supports %O (base), %A (current), %B (other)
|
|
driverConfig := strings.TrimSpace(string(output))
|
|
if strings.Contains(driverConfig, "%L") || strings.Contains(driverConfig, "%R") {
|
|
// Stale config with invalid placeholders - needs repair
|
|
return false
|
|
}
|
|
|
|
// Check if .gitattributes has the merge driver configured
|
|
gitattributesPath := ".gitattributes"
|
|
content, err := os.ReadFile(gitattributesPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Look for beads JSONL merge attribute (either canonical or legacy filename)
|
|
hasCanonical := strings.Contains(string(content), ".beads/issues.jsonl") &&
|
|
strings.Contains(string(content), "merge=beads")
|
|
hasLegacy := strings.Contains(string(content), ".beads/beads.jsonl") &&
|
|
strings.Contains(string(content), "merge=beads")
|
|
return hasCanonical || hasLegacy
|
|
}
|
|
|
|
// installJJHooks installs simplified git hooks for colocated jujutsu+git repos.
|
|
// jj's model is simpler: the working copy IS always a commit, so no staging needed.
|
|
// Changes flow into the current change automatically.
|
|
func installJJHooks() error {
|
|
hooksDir, err := git.GetGitHooksDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure hooks directory exists
|
|
if err := os.MkdirAll(hooksDir, 0750); err != nil {
|
|
return fmt.Errorf("failed to create hooks directory: %w", err)
|
|
}
|
|
|
|
// Detect existing hooks
|
|
existingHooks := detectExistingHooks()
|
|
|
|
// Check if any non-bd hooks exist
|
|
hasExistingHooks := false
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
hasExistingHooks = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Determine installation mode
|
|
chainHooks := false
|
|
if hasExistingHooks {
|
|
choice := promptHookAction(existingHooks)
|
|
switch choice {
|
|
case "1", "":
|
|
chainHooks = true
|
|
// Chain mode - rename existing hooks to .old so they can be called
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
oldPath := hook.path + ".old"
|
|
if err := os.Rename(hook.path, oldPath); err != nil {
|
|
return fmt.Errorf("failed to rename %s to .old: %w", hook.name, err)
|
|
}
|
|
fmt.Printf(" Renamed %s to %s\n", hook.name, filepath.Base(oldPath))
|
|
}
|
|
}
|
|
case "2":
|
|
// Overwrite mode - backup existing hooks
|
|
for _, hook := range existingHooks {
|
|
if hook.exists && !hook.isBdHook {
|
|
timestamp := time.Now().Format("20060102-150405")
|
|
backup := hook.path + ".backup-" + timestamp
|
|
if err := os.Rename(hook.path, backup); err != nil {
|
|
return fmt.Errorf("failed to backup %s: %w", hook.name, err)
|
|
}
|
|
fmt.Printf(" Backed up %s to %s\n", hook.name, filepath.Base(backup))
|
|
}
|
|
}
|
|
case "3":
|
|
fmt.Printf("Skipping git hooks installation.\n")
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid choice: %s", choice)
|
|
}
|
|
}
|
|
|
|
// pre-commit hook (simplified for jj - no staging)
|
|
preCommitPath := filepath.Join(hooksDir, "pre-commit")
|
|
preCommitContent := buildJJPreCommitHook(chainHooks, existingHooks)
|
|
|
|
// post-merge hook (same as git)
|
|
postMergePath := filepath.Join(hooksDir, "post-merge")
|
|
postMergeContent := buildPostMergeHook(chainHooks, existingHooks)
|
|
|
|
// Write pre-commit hook
|
|
// #nosec G306 - git hooks must be executable
|
|
if err := os.WriteFile(preCommitPath, []byte(preCommitContent), 0700); err != nil {
|
|
return fmt.Errorf("failed to write pre-commit hook: %w", err)
|
|
}
|
|
|
|
// Write post-merge hook
|
|
// #nosec G306 - git hooks must be executable
|
|
if err := os.WriteFile(postMergePath, []byte(postMergeContent), 0700); err != nil {
|
|
return fmt.Errorf("failed to write post-merge hook: %w", err)
|
|
}
|
|
|
|
if chainHooks {
|
|
fmt.Printf("%s Chained bd hooks with existing hooks (jj mode)\n", ui.RenderPass("✓"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildJJPreCommitHook generates the pre-commit hook content for jujutsu repos.
|
|
// jj's model is simpler: no staging needed, changes flow into the working copy automatically.
|
|
func buildJJPreCommitHook(chainHooks bool, existingHooks []hookInfo) string {
|
|
if chainHooks {
|
|
// Find existing pre-commit hook (already renamed to .old by caller)
|
|
var existingPreCommit string
|
|
for _, hook := range existingHooks {
|
|
if hook.name == "pre-commit" && hook.exists && !hook.isBdHook {
|
|
existingPreCommit = hook.path + ".old"
|
|
break
|
|
}
|
|
}
|
|
|
|
return `#!/bin/sh
|
|
#
|
|
# bd (beads) pre-commit hook (chained, jujutsu mode)
|
|
#
|
|
# This hook chains bd functionality with your existing pre-commit hook.
|
|
# Simplified for jujutsu: no staging needed, jj auto-commits working copy.
|
|
|
|
# Run existing hook first
|
|
if [ -x "` + existingPreCommit + `" ]; then
|
|
"` + existingPreCommit + `" "$@"
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
exit $EXIT_CODE
|
|
fi
|
|
fi
|
|
|
|
` + jjPreCommitHookBody()
|
|
}
|
|
|
|
return `#!/bin/sh
|
|
#
|
|
# bd (beads) pre-commit hook (jujutsu mode)
|
|
#
|
|
# This hook ensures that any pending bd issue changes are flushed to
|
|
# .beads/issues.jsonl before the commit.
|
|
#
|
|
# Simplified for jujutsu: no staging needed, jj auto-commits working copy changes.
|
|
|
|
` + jjPreCommitHookBody()
|
|
}
|
|
|
|
// jjPreCommitHookBody returns the pre-commit hook logic for jujutsu repos.
|
|
// Key difference from git: no git add needed, jj handles working copy automatically.
|
|
// Still needs worktree handling since colocated jj+git repos can use git worktrees.
|
|
func jjPreCommitHookBody() string {
|
|
return `# 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
|
|
# For worktrees, .beads is in the main repository root, not the worktree
|
|
BEADS_DIR=""
|
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
# Check if we're in a worktree
|
|
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
|
# Worktree: .beads is in main repo root
|
|
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
|
|
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
|
|
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
|
|
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
|
|
fi
|
|
else
|
|
# Regular repo: check current directory
|
|
if [ -d .beads ]; then
|
|
BEADS_DIR=".beads"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$BEADS_DIR" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Flush pending changes to JSONL
|
|
# In jujutsu, changes automatically become part of the working copy commit
|
|
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
|
|
|
|
# No git add needed - jujutsu automatically includes working copy changes
|
|
exit 0
|
|
`
|
|
}
|
|
|
|
// printJJAliasInstructions prints setup instructions for pure jujutsu repos.
|
|
// Since jj doesn't have native hooks yet, users need to set up aliases.
|
|
func printJJAliasInstructions() {
|
|
fmt.Printf("\n%s Jujutsu repository detected (not colocated with git)\n\n", ui.RenderWarn("⚠"))
|
|
fmt.Printf("Jujutsu doesn't support hooks yet. To auto-export beads on push,\n")
|
|
fmt.Printf("add this alias to your jj config (~/.config/jj/config.toml):\n\n")
|
|
fmt.Printf(" %s\n", ui.RenderAccent("[aliases]"))
|
|
fmt.Printf(" %s\n", ui.RenderAccent(`push = ["util", "exec", "--", "sh", "-c", "bd sync --flush-only && jj git push \"$@\"", ""]`))
|
|
fmt.Printf("\nThen use %s instead of %s\n\n", ui.RenderAccent("jj push"), ui.RenderAccent("jj git push"))
|
|
fmt.Printf("For more details, see: https://github.com/steveyegge/beads/blob/main/docs/JUJUTSU.md\n\n")
|
|
}
|
|
|
|
// installMergeDriver configures git to use bd merge for JSONL files
|
|
// Note: This runs during bd init BEFORE .beads exists, so it runs git in CWD.
|
|
func installMergeDriver() error {
|
|
// Configure git merge driver (runs in CWD)
|
|
cmd := exec.Command("git", "config", "merge.beads.driver", "bd merge %A %O %A %B")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to configure git merge driver: %w\n%s", err, output)
|
|
}
|
|
|
|
cmd = exec.Command("git", "config", "merge.beads.name", "bd JSONL merge driver")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
// Non-fatal, the name is just descriptive
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to set merge driver name: %v\n%s", err, output)
|
|
}
|
|
|
|
// Create or update .gitattributes
|
|
gitattributesPath := ".gitattributes"
|
|
|
|
// Read existing .gitattributes if it exists
|
|
var existingContent string
|
|
content, err := os.ReadFile(gitattributesPath)
|
|
if err == nil {
|
|
existingContent = string(content)
|
|
}
|
|
|
|
// Check if beads merge driver is already configured
|
|
// Check for either pattern (issues.jsonl is canonical, beads.jsonl is legacy)
|
|
hasBeadsMerge := (strings.Contains(existingContent, ".beads/issues.jsonl") ||
|
|
strings.Contains(existingContent, ".beads/beads.jsonl")) &&
|
|
strings.Contains(existingContent, "merge=beads")
|
|
|
|
if !hasBeadsMerge {
|
|
// Append beads merge driver configuration (issues.jsonl is canonical)
|
|
beadsMergeAttr := "\n# Use bd merge for beads JSONL files\n.beads/issues.jsonl merge=beads\n"
|
|
|
|
newContent := existingContent
|
|
if !strings.HasSuffix(newContent, "\n") && len(newContent) > 0 {
|
|
newContent += "\n"
|
|
}
|
|
newContent += beadsMergeAttr
|
|
|
|
// Write updated .gitattributes (0644 is standard for .gitattributes)
|
|
// #nosec G306 - .gitattributes needs to be readable
|
|
if err := os.WriteFile(gitattributesPath, []byte(newContent), 0644); err != nil {
|
|
return fmt.Errorf("failed to update .gitattributes: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|