feat(hooks): Add git hook infrastructure for Dolt backend

This commit implements the git hook infrastructure for Dolt storage backend
as specified in the design document.

Changes:
- Add `bd hook` command (singular) for git hooks to call directly
- Implement per-worktree export state tracking in .beads/export-state/
- Add post-checkout guard to only import if JSONL changed
- Add hook chaining configuration (chain_strategy, chain_timeout_ms)
- Support hooks in .beads/hooks/ directory with git config core.hooksPath
- Implement branch-then-merge import pattern for Dolt storage
- Update bd init to install hooks to .beads/hooks/ for Dolt backend
- Add --beads flag to `bd hooks install` command

The new `bd hook` command supports:
- 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)

For Dolt backend, uses 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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
quartz
2026-01-16 12:24:09 -08:00
committed by gastown/crew/dennis
parent 3a7971d2b6
commit 15d74a9a49
3 changed files with 834 additions and 12 deletions

View File

@@ -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)