diff --git a/cmd/bd/hook.go b/cmd/bd/hook.go new file mode 100644 index 00000000..37acdf61 --- /dev/null +++ b/cmd/bd/hook.go @@ -0,0 +1,768 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/git" + "github.com/steveyegge/beads/internal/storage/factory" +) + +// hookCmd is the main "bd hook" command that git hooks call into. +// This is distinct from "bd hooks" (plural) which manages hook installation. +var hookCmd = &cobra.Command{ + Use: "hook [args...]", + Short: "Execute a git hook (called by hook scripts)", + Long: `Execute the logic for a git hook. This command is called by +hook scripts installed in .beads/hooks/ (or .git/hooks/). + +Supported hooks: + - pre-commit: Export database to JSONL, stage changes + - post-merge: Import JSONL to database after pull/merge + - post-checkout: Import JSONL after branch checkout (with guard) + +The hook scripts delegate to this command so hook behavior is always +in sync with the installed bd version. + +Configuration (.beads/config.yaml): + hooks: + chain_strategy: before # before | after | replace + chain_timeout_ms: 5000 # Timeout for chained hooks`, + 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 = hookPreCommit() + case "post-merge": + exitCode = hookPostMerge(hookArgs) + case "post-checkout": + exitCode = hookPostCheckout(hookArgs) + default: + fmt.Fprintf(os.Stderr, "Unknown hook: %s\n", hookName) + os.Exit(1) + } + + os.Exit(exitCode) + }, +} + +// ============================================================================= +// Export State Tracking (per-worktree) +// ============================================================================= + +// ExportState tracks the export state for a specific worktree. +// This prevents polecats sharing a Dolt DB from exporting uncommitted +// work from other polecats. +type ExportState struct { + WorktreeRoot string `json:"worktree_root"` + LastExportCommit string `json:"last_export_commit"` // Git commit when last exported + LastExportTime time.Time `json:"last_export_time"` + JSONLHash string `json:"jsonl_hash"` // Hash of JSONL at last export +} + +// getWorktreeHash returns a hash of the worktree root for use in filenames. +func getWorktreeHash(worktreeRoot string) string { + h := sha256.Sum256([]byte(worktreeRoot)) + return hex.EncodeToString(h[:8]) // Use first 8 bytes (16 hex chars) +} + +// getExportStateDir returns the path to the export state directory. +func getExportStateDir(beadsDir string) string { + return filepath.Join(beadsDir, "export-state") +} + +// getExportStatePath returns the path to the export state file for this worktree. +func getExportStatePath(beadsDir, worktreeRoot string) string { + return filepath.Join(getExportStateDir(beadsDir), getWorktreeHash(worktreeRoot)+".json") +} + +// loadExportState loads the export state for the current worktree. +func loadExportState(beadsDir, worktreeRoot string) (*ExportState, error) { + path := getExportStatePath(beadsDir, worktreeRoot) + data, err := os.ReadFile(path) // #nosec G304 -- path is constructed from beadsDir + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No state yet + } + return nil, err + } + + var state ExportState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +// saveExportState saves the export state for the current worktree. +func saveExportState(beadsDir, worktreeRoot string, state *ExportState) error { + dir := getExportStateDir(beadsDir) + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("creating export-state directory: %w", err) + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + path := getExportStatePath(beadsDir, worktreeRoot) + // #nosec G306 -- state file in .beads directory + return os.WriteFile(path, data, 0644) +} + +// computeJSONLHashForHook computes a hash of the JSONL file contents for hook state tracking. +// This is a wrapper around computeJSONLHash that handles missing files gracefully. +func computeJSONLHashForHook(jsonlPath string) (string, error) { + hash, err := computeJSONLHashForHook(jsonlPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return hash, nil +} + +// getCurrentGitCommit returns the current git HEAD commit hash. +func getCurrentGitCommit() (string, error) { + cmd := exec.Command("git", "rev-parse", "HEAD") + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// getWorktreeRoot returns the root of the current worktree. +func getWorktreeRoot() (string, error) { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// ============================================================================= +// Hook Chaining Configuration +// ============================================================================= + +// HookChainStrategy defines how to chain with existing hooks. +type HookChainStrategy string + +const ( + ChainBefore HookChainStrategy = "before" // Run existing hook before bd hook + ChainAfter HookChainStrategy = "after" // Run existing hook after bd hook + ChainReplace HookChainStrategy = "replace" // Replace existing hook entirely +) + +// HookConfig holds hook-related configuration from config.yaml. +type HookConfig struct { + ChainStrategy HookChainStrategy `yaml:"chain_strategy" json:"chain_strategy"` + ChainTimeoutMs int `yaml:"chain_timeout_ms" json:"chain_timeout_ms"` +} + +// DefaultHookConfig returns the default hook configuration. +func DefaultHookConfig() *HookConfig { + return &HookConfig{ + ChainStrategy: ChainBefore, + ChainTimeoutMs: 5000, + } +} + +// loadHookConfig loads hook configuration from config.yaml. +func loadHookConfig(beadsDir string) *HookConfig { + cfg := DefaultHookConfig() + + configPath := filepath.Join(beadsDir, "config.yaml") + data, err := os.ReadFile(configPath) // #nosec G304 -- config path is trusted + if err != nil { + return cfg + } + + // Simple YAML parsing for hooks config + lines := strings.Split(string(data), "\n") + inHooks := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "hooks:" { + inHooks = true + continue + } + if inHooks { + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && trimmed != "" { + inHooks = false + continue + } + if strings.HasPrefix(trimmed, "chain_strategy:") { + value := strings.TrimPrefix(trimmed, "chain_strategy:") + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + switch value { + case "before": + cfg.ChainStrategy = ChainBefore + case "after": + cfg.ChainStrategy = ChainAfter + case "replace": + cfg.ChainStrategy = ChainReplace + } + } + if strings.HasPrefix(trimmed, "chain_timeout_ms:") { + value := strings.TrimPrefix(trimmed, "chain_timeout_ms:") + value = strings.TrimSpace(value) + var timeout int + if _, err := fmt.Sscanf(value, "%d", &timeout); err == nil && timeout > 0 { + cfg.ChainTimeoutMs = timeout + } + } + } + } + + return cfg +} + +// runChainedHookWithConfig runs a chained hook with timeout from config. +func runChainedHookWithConfig(hookName string, args []string, cfg *HookConfig) int { + if cfg.ChainStrategy == ChainReplace { + return 0 // Skip chained hook + } + + // Get the hooks directory + hooksDir, err := getHooksDir() + if err != nil { + return 0 + } + + oldHookPath := filepath.Join(hooksDir, hookName+".old") + + // Check if the .old hook exists and is executable + info, err := os.Stat(oldHookPath) + if err != nil { + return 0 // No chained hook + } + if info.Mode().Perm()&0111 == 0 { + return 0 // Not executable + } + + // Check if .old is itself a bd shim - skip to prevent infinite recursion + versionInfo, err := getHookVersion(oldHookPath) + if err == nil && versionInfo.IsShim { + return 0 + } + + // Create context with timeout + timeout := time.Duration(cfg.ChainTimeoutMs) * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Run the chained hook + // #nosec G204 -- hookName is from controlled list, path is from hooks directory + cmd := exec.CommandContext(ctx, oldHookPath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + fmt.Fprintf(os.Stderr, "Warning: chained hook %s timed out after %dms\n", hookName, cfg.ChainTimeoutMs) + return 0 // Don't block on timeout, just warn + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + fmt.Fprintf(os.Stderr, "Warning: chained hook %s failed: %v\n", hookName, err) + return 1 + } + + return 0 +} + +// getHooksDir returns the hooks directory (.beads/hooks/ or .git/hooks/). +func getHooksDir() (string, error) { + // First check for .beads/hooks/ (preferred location) + beadsDir := beads.FindBeadsDir() + if beadsDir != "" { + beadsHooksDir := filepath.Join(beadsDir, "hooks") + if _, err := os.Stat(beadsHooksDir); err == nil { + return beadsHooksDir, nil + } + } + + // Fall back to git hooks directory + return git.GetGitHooksDir() +} + +// ============================================================================= +// Hook Implementations +// ============================================================================= + +// hookPreCommit implements the pre-commit hook: Export database to JSONL. +func hookPreCommit() int { + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + return 0 // Not a beads workspace + } + + cfg := loadHookConfig(beadsDir) + + // Run chained hook based on strategy + if cfg.ChainStrategy == ChainBefore { + if exitCode := runChainedHookWithConfig("pre-commit", nil, cfg); exitCode != 0 { + return exitCode + } + } + + // Check if sync-branch is configured (changes go to separate branch) + if hookGetSyncBranch() != "" { + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("pre-commit", nil, cfg) + } + return 0 + } + + // Get worktree root for per-worktree state tracking + worktreeRoot, err := getWorktreeRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not determine worktree root: %v\n", err) + worktreeRoot = beadsDir // Fallback + } + + // Check if we're using Dolt backend - use branch-then-merge pattern + backend := factory.GetBackendFromConfig(beadsDir) + if backend == configfile.BackendDolt { + exitCode := hookPreCommitDolt(beadsDir, worktreeRoot) + if cfg.ChainStrategy == ChainAfter && exitCode == 0 { + return runChainedHookWithConfig("pre-commit", nil, cfg) + } + return exitCode + } + + // SQLite backend: Use existing sync --flush-only + cmd := exec.Command("bd", "sync", "--flush-only", "--no-daemon") + 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") + } + + // Stage JSONL files + jsonlFiles := []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} + if os.Getenv("BEADS_NO_AUTO_STAGE") == "" { + rc, rcErr := beads.GetRepoContext() + ctx := context.Background() + for _, f := range jsonlFiles { + if _, err := os.Stat(f); err == nil { + var gitAdd *exec.Cmd + if rcErr == nil { + gitAdd = rc.GitCmdCWD(ctx, "add", f) + } else { + // #nosec G204 -- f comes from jsonlFiles (controlled, hardcoded paths) + gitAdd = exec.Command("git", "add", f) + } + _ = gitAdd.Run() + } + } + } + + // Update export state + currentCommit, _ := getCurrentGitCommit() + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + jsonlHash, _ := computeJSONLHashForHook(jsonlPath) + + state := &ExportState{ + WorktreeRoot: worktreeRoot, + LastExportCommit: currentCommit, + LastExportTime: time.Now(), + JSONLHash: jsonlHash, + } + if err := saveExportState(beadsDir, worktreeRoot, state); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not save export state: %v\n", err) + } + + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("pre-commit", nil, cfg) + } + return 0 +} + +// hookPreCommitDolt implements pre-commit for Dolt backend. +// Export Dolt → JSONL with per-worktree state tracking. +func hookPreCommitDolt(beadsDir, worktreeRoot string) int { + ctx := context.Background() + + // Load previous export state + prevState, _ := loadExportState(beadsDir, worktreeRoot) + + // Get current commit + currentCommit, err := getCurrentGitCommit() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not get git commit: %v\n", err) + } + + // Check if we've already exported for this commit (idempotency) + if prevState != nil && prevState.LastExportCommit == currentCommit { + // Already exported for this commit, skip + return 0 + } + + // Create storage from config + store, err := factory.NewFromConfig(ctx, beadsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not open database: %v\n", err) + return 0 + } + defer store.Close() + + // Export to JSONL + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if err := exportToJSONLFromStore(ctx, store, jsonlPath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not export to JSONL: %v\n", err) + return 0 + } + + // Stage JSONL files + if os.Getenv("BEADS_NO_AUTO_STAGE") == "" { + rc, rcErr := beads.GetRepoContext() + jsonlFiles := []string{".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} + for _, f := range jsonlFiles { + if _, err := os.Stat(f); err == nil { + var gitAdd *exec.Cmd + if rcErr == nil { + gitAdd = rc.GitCmdCWD(ctx, "add", f) + } else { + // #nosec G204 -- f comes from jsonlFiles + gitAdd = exec.Command("git", "add", f) + } + _ = gitAdd.Run() + } + } + } + + // Update export state + jsonlHash, _ := computeJSONLHashForHook(jsonlPath) + state := &ExportState{ + WorktreeRoot: worktreeRoot, + LastExportCommit: currentCommit, + LastExportTime: time.Now(), + JSONLHash: jsonlHash, + } + if err := saveExportState(beadsDir, worktreeRoot, state); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not save export state: %v\n", err) + } + + return 0 +} + +// hookPostMerge implements the post-merge hook: Import JSONL to database. +func hookPostMerge(args []string) int { + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + return 0 // Not a beads workspace + } + + cfg := loadHookConfig(beadsDir) + + // Run chained hook based on strategy + if cfg.ChainStrategy == ChainBefore { + if exitCode := runChainedHookWithConfig("post-merge", args, cfg); exitCode != 0 { + return exitCode + } + } + + // Skip during rebase + if isRebaseInProgress() { + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-merge", args, cfg) + } + return 0 + } + + // Check if any JSONL file exists + if !hasBeadsJSONL() { + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-merge", args, cfg) + } + return 0 + } + + // Check if we're using Dolt backend - use branch-then-merge pattern + backend := factory.GetBackendFromConfig(beadsDir) + if backend == configfile.BackendDolt { + exitCode := hookPostMergeDolt(beadsDir) + if cfg.ChainStrategy == ChainAfter && exitCode == 0 { + return runChainedHookWithConfig("post-merge", args, cfg) + } + return exitCode + } + + // SQLite backend: Use existing sync --import-only + cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon") + 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, "Run 'bd doctor --fix' to diagnose and repair") + } + + // Run quick health check + healthCmd := exec.Command("bd", "doctor", "--check-health") + _ = healthCmd.Run() + + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-merge", args, cfg) + } + return 0 +} + +// hookPostMergeDolt implements post-merge for Dolt backend. +// Import JSONL → Dolt using branch-then-merge pattern: +// 1. Create jsonl-import branch +// 2. Import JSONL data to branch +// 3. Merge branch to main (cell-level conflict resolution) +// 4. Delete branch on success +func hookPostMergeDolt(beadsDir string) int { + ctx := context.Background() + + // Create storage from config + store, err := factory.NewFromConfig(ctx, beadsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not open database: %v\n", err) + return 0 + } + defer store.Close() + + // Check if Dolt store supports version control operations + doltStore, ok := store.(interface { + Branch(ctx context.Context, name string) error + Checkout(ctx context.Context, branch string) error + Merge(ctx context.Context, branch string) error + Commit(ctx context.Context, message string) error + CurrentBranch(ctx context.Context) (string, error) + }) + if !ok { + // Not a Dolt store with version control, use regular import + cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon") + _ = cmd.Run() + return 0 + } + + // Get current branch + currentBranch, err := doltStore.CurrentBranch(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not get current branch: %v\n", err) + return 0 + } + + // Create import branch + importBranch := "jsonl-import-" + time.Now().Format("20060102-150405") + if err := doltStore.Branch(ctx, importBranch); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not create import branch: %v\n", err) + return 0 + } + + // Checkout import branch + if err := doltStore.Checkout(ctx, importBranch); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not checkout import branch: %v\n", err) + return 0 + } + + // Import JSONL to the import branch + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if err := importFromJSONLToStore(ctx, store, jsonlPath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not import JSONL: %v\n", err) + // Checkout back to original branch + _ = doltStore.Checkout(ctx, currentBranch) + return 0 + } + + // Commit changes on import branch + if err := doltStore.Commit(ctx, "Import from JSONL"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not commit import: %v\n", err) + } + + // Checkout back to original branch + if err := doltStore.Checkout(ctx, currentBranch); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not checkout original branch: %v\n", err) + return 0 + } + + // Merge import branch (Dolt provides cell-level merge) + if err := doltStore.Merge(ctx, importBranch); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not merge import branch: %v\n", err) + return 0 + } + + // Commit the merge + if err := doltStore.Commit(ctx, "Merge JSONL import"); err != nil { + // May fail if nothing to commit (fast-forward merge) + // This is expected, not an error + } + + // TODO: Delete import branch (need to add DeleteBranch method to DoltStore) + + return 0 +} + +// hookPostCheckout implements the post-checkout hook with guard. +// Only imports if JSONL actually changed since last import. +func hookPostCheckout(args []string) int { + // Only run on branch checkouts (flag=1) + if len(args) >= 3 && args[2] != "1" { + return 0 + } + + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + return 0 // Not a beads workspace + } + + cfg := loadHookConfig(beadsDir) + + // Run chained hook based on strategy + if cfg.ChainStrategy == ChainBefore { + if exitCode := runChainedHookWithConfig("post-checkout", args, cfg); exitCode != 0 { + return exitCode + } + } + + // Skip during rebase + if isRebaseInProgress() { + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-checkout", args, cfg) + } + return 0 + } + + // Check if any JSONL file exists + if !hasBeadsJSONL() { + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-checkout", args, cfg) + } + return 0 + } + + // Guard: Only import if JSONL actually changed + worktreeRoot, err := getWorktreeRoot() + if err != nil { + worktreeRoot = beadsDir // Fallback + } + + prevState, _ := loadExportState(beadsDir, worktreeRoot) + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + currentHash, _ := computeJSONLHashForHook(jsonlPath) + + if prevState != nil && prevState.JSONLHash == currentHash { + // JSONL hasn't changed, skip redundant import + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-checkout", args, cfg) + } + 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, "") + } + + // Check if we're using Dolt backend + backend := factory.GetBackendFromConfig(beadsDir) + if backend == configfile.BackendDolt { + exitCode := hookPostMergeDolt(beadsDir) // Same as post-merge for Dolt + // Update state after import + newHash, _ := computeJSONLHashForHook(jsonlPath) + currentCommit, _ := getCurrentGitCommit() + state := &ExportState{ + WorktreeRoot: worktreeRoot, + LastExportCommit: currentCommit, + LastExportTime: time.Now(), + JSONLHash: newHash, + } + _ = saveExportState(beadsDir, worktreeRoot, state) + + if cfg.ChainStrategy == ChainAfter && exitCode == 0 { + return runChainedHookWithConfig("post-checkout", args, cfg) + } + return exitCode + } + + // SQLite backend: Use existing sync --import-only + cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon") + 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, "Run 'bd doctor --fix' to diagnose and repair") + } + + // Update state after import + newHash, _ := computeJSONLHashForHook(jsonlPath) + currentCommit, _ := getCurrentGitCommit() + state := &ExportState{ + WorktreeRoot: worktreeRoot, + LastExportCommit: currentCommit, + LastExportTime: time.Now(), + JSONLHash: newHash, + } + _ = saveExportState(beadsDir, worktreeRoot, state) + + // Run quick health check + healthCmd := exec.Command("bd", "doctor", "--check-health") + _ = healthCmd.Run() + + if cfg.ChainStrategy == ChainAfter { + return runChainedHookWithConfig("post-checkout", args, cfg) + } + return 0 +} + +// ============================================================================= +// Helper Functions for Dolt Import/Export +// ============================================================================= + +// exportToJSONLFromStore exports issues from a store to JSONL. +// This is a placeholder - the actual implementation should use the store's methods. +func exportToJSONLFromStore(ctx context.Context, store interface{}, jsonlPath string) error { + // Use bd sync --flush-only for now + // TODO: Implement direct store export + cmd := exec.Command("bd", "sync", "--flush-only", "--no-daemon") + return cmd.Run() +} + +// importFromJSONLToStore imports issues from JSONL to a store. +// This is a placeholder - the actual implementation should use the store's methods. +func importFromJSONLToStore(ctx context.Context, store interface{}, jsonlPath string) error { + // Use bd sync --import-only for now + // TODO: Implement direct store import + cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon") + return cmd.Run() +} + +func init() { + rootCmd.AddCommand(hookCmd) +} diff --git a/cmd/bd/hooks.go b/cmd/bd/hooks.go index aab45899..6b5f25c7 100644 --- a/cmd/bd/hooks.go +++ b/cmd/bd/hooks.go @@ -181,6 +181,7 @@ var hooksInstallCmd = &cobra.Command{ Long: `Install git hooks for automatic bd sync. By default, hooks are installed to .git/hooks/ in the current repository. +Use --beads to install to .beads/hooks/ (recommended for Dolt backend). Use --shared to install to a versioned directory (.beads-hooks/) that can be committed to git and shared with team members. @@ -197,6 +198,7 @@ Installed hooks: force, _ := cmd.Flags().GetBool("force") shared, _ := cmd.Flags().GetBool("shared") chain, _ := cmd.Flags().GetBool("chain") + beadsHooks, _ := cmd.Flags().GetBool("beads") embeddedHooks, err := getEmbeddedHooks() if err != nil { @@ -212,7 +214,7 @@ Installed hooks: os.Exit(1) } - if err := installHooks(embeddedHooks, force, shared, chain); err != nil { + if err := installHooksWithOptions(embeddedHooks, force, shared, chain, beadsHooks); err != nil { if jsonOutput { output := map[string]interface{}{ "error": err.Error(), @@ -227,10 +229,11 @@ Installed hooks: if jsonOutput { output := map[string]interface{}{ - "success": true, - "message": "Git hooks installed successfully", - "shared": shared, - "chained": chain, + "success": true, + "message": "Git hooks installed successfully", + "shared": shared, + "chained": chain, + "beadsHooks": beadsHooks, } jsonBytes, _ := json.MarshalIndent(output, "", " ") fmt.Println(string(jsonBytes)) @@ -241,7 +244,11 @@ Installed hooks: fmt.Println("Mode: chained (existing hooks renamed to .old and will run first)") fmt.Println() } - if shared { + if beadsHooks { + fmt.Println("Hooks installed to: .beads/hooks/") + fmt.Println("Git config set: core.hooksPath=.beads/hooks") + fmt.Println() + } else if shared { fmt.Println("Hooks installed to: .beads-hooks/") fmt.Println("Git config set: core.hooksPath=.beads-hooks") fmt.Println() @@ -319,8 +326,19 @@ var hooksListCmd = &cobra.Command{ } func installHooks(embeddedHooks map[string]string, force bool, shared bool, chain bool) error { + return installHooksWithOptions(embeddedHooks, force, shared, chain, false) +} + +func installHooksWithOptions(embeddedHooks map[string]string, force bool, shared bool, chain bool, beadsHooks bool) error { var hooksDir string - if shared { + if beadsHooks { + // Use .beads/hooks/ directory (preferred for Dolt backend) + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + return fmt.Errorf("not in a beads workspace (no .beads directory found)") + } + hooksDir = filepath.Join(beadsDir, "hooks") + } else if shared { // Use versioned directory for shared hooks hooksDir = ".beads-hooks" } else { @@ -373,8 +391,12 @@ func installHooks(embeddedHooks map[string]string, force bool, shared bool, chai } } - // If shared mode, configure git to use the shared hooks directory - if shared { + // Configure git to use the hooks directory + if beadsHooks { + if err := configureBeadsHooksPath(); err != nil { + return fmt.Errorf("failed to configure git hooks path: %w", err) + } + } else if shared { if err := configureSharedHooksPath(); err != nil { return fmt.Errorf("failed to configure git hooks path: %w", err) } @@ -398,6 +420,20 @@ func configureSharedHooksPath() error { return nil } +func configureBeadsHooksPath() error { + // Set git config core.hooksPath to .beads/hooks + repoRoot := git.GetRepoRoot() + if repoRoot == "" { + return fmt.Errorf("not in a git repository") + } + cmd := exec.Command("git", "config", "core.hooksPath", ".beads/hooks") + cmd.Dir = repoRoot + 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 hooks directory from common git dir (hooks are shared across worktrees) hooksDir, err := git.GetGitHooksDir() @@ -1165,6 +1201,7 @@ 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/") hooksInstallCmd.Flags().Bool("chain", false, "Chain with existing hooks (run them before bd hooks)") + hooksInstallCmd.Flags().Bool("beads", false, "Install hooks to .beads/hooks/ (recommended for Dolt backend)") hooksCmd.AddCommand(hooksInstallCmd) hooksCmd.AddCommand(hooksUninstallCmd) diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 2cc6abd5..4c5fce0a 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -509,10 +509,27 @@ With --stealth: configures per-repository git settings for invisible beads usage // Check if we're in a git repo and hooks aren't installed // Install by default unless --skip-hooks is passed + // For Dolt backend, install hooks to .beads/hooks/ (uses git config core.hooksPath) if !skipHooks && isGitRepo() && !hooksInstalled() { - if err := installGitHooks(); err != nil && !quiet { - fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", ui.RenderWarn("⚠"), err) - fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix")) + if backend == configfile.BackendDolt { + // Dolt backend: install hooks to .beads/hooks/ + embeddedHooks, err := getEmbeddedHooks() + if err == nil { + if err := installHooksWithOptions(embeddedHooks, false, false, false, true); err != nil && !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks to .beads/hooks/: %v\n", ui.RenderWarn("⚠"), err) + fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd hooks install --beads")) + } else if !quiet { + fmt.Printf(" Hooks installed to: .beads/hooks/\n") + } + } else if !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to load embedded hooks: %v\n", ui.RenderWarn("⚠"), err) + } + } else { + // SQLite backend: use traditional hook installation + if err := installGitHooks(); err != nil && !quiet { + fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", ui.RenderWarn("⚠"), err) + fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix")) + } } }