diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 51e9b619..7150b3a7 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -388,8 +388,8 @@ With --stealth: configures global 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 - if !skipHooks && isGitRepo() && !hooksInstalled() { + // Install by default unless --skip-hooks is passed or no-install-hooks config is set + if !skipHooks && !config.GetBool("no-install-hooks") && isGitRepo() && !hooksInstalled() { if err := installGitHooks(); err != nil && !quiet { yellow := color.New(color.FgYellow).SprintFunc() fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", yellow("⚠"), err) diff --git a/cmd/bd/init_test.go b/cmd/bd/init_test.go index c91ceabf..daa3ddb4 100644 --- a/cmd/bd/init_test.go +++ b/cmd/bd/init_test.go @@ -1041,3 +1041,188 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) { t.Error("File should contain bd onboard prompt") } } + +// TestInitSkipHooksWithEnvVar verifies BD_NO_INSTALL_HOOKS=1 prevents hook installation +func TestInitSkipHooksWithEnvVar(t *testing.T) { + // Reset global state + origDBPath := dbPath + defer func() { dbPath = origDBPath }() + dbPath = "" + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Initialize git repo first (hooks only install in git repos) + if err := runCommandInDir(tmpDir, "git", "init"); err != nil { + t.Fatalf("Failed to init git: %v", err) + } + + // Set environment variable to skip hooks + os.Setenv("BD_NO_INSTALL_HOOKS", "1") + defer os.Unsetenv("BD_NO_INSTALL_HOOKS") + + // Run bd init + rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Verify .beads directory was created + beadsDir := filepath.Join(tmpDir, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Error(".beads directory was not created") + } + + // Verify git hooks were NOT installed + preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit") + if _, err := os.Stat(preCommitHook); err == nil { + t.Error("pre-commit hook should NOT be installed when BD_NO_INSTALL_HOOKS=1") + } + + postMergeHook := filepath.Join(tmpDir, ".git", "hooks", "post-merge") + if _, err := os.Stat(postMergeHook); err == nil { + t.Error("post-merge hook should NOT be installed when BD_NO_INSTALL_HOOKS=1") + } +} + +// TestInitSkipHooksWithConfigFile verifies no-install-hooks: true in config prevents hook installation +func TestInitSkipHooksWithConfigFile(t *testing.T) { + // Reset global state + origDBPath := dbPath + defer func() { dbPath = origDBPath }() + dbPath = "" + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Initialize git repo first + if err := runCommandInDir(tmpDir, "git", "init"); err != nil { + t.Fatalf("Failed to init git: %v", err) + } + + // Create global config directory with no-install-hooks: true + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("Failed to get user config dir: %v", err) + } + bdConfigDir := filepath.Join(configDir, "bd") + if err := os.MkdirAll(bdConfigDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + configPath := filepath.Join(bdConfigDir, "config.yaml") + + // Backup existing config if present + existingConfig, existingErr := os.ReadFile(configPath) + defer func() { + if existingErr == nil { + os.WriteFile(configPath, existingConfig, 0644) + } else { + os.Remove(configPath) + } + }() + + // Write test config + if err := os.WriteFile(configPath, []byte("no-install-hooks: true\n"), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Run bd init + rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Verify .beads directory was created + beadsDir := filepath.Join(tmpDir, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Error(".beads directory was not created") + } + + // Verify git hooks were NOT installed + preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit") + if _, err := os.Stat(preCommitHook); err == nil { + t.Error("pre-commit hook should NOT be installed when no-install-hooks: true in config") + } +} + +// TestInitSkipHooksFlag verifies --skip-hooks flag still works (backward compat) +func TestInitSkipHooksFlag(t *testing.T) { + // Reset global state + origDBPath := dbPath + defer func() { dbPath = origDBPath }() + dbPath = "" + + // Reset Cobra flags + initCmd.Flags().Set("skip-hooks", "false") + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Initialize git repo first + if err := runCommandInDir(tmpDir, "git", "init"); err != nil { + t.Fatalf("Failed to init git: %v", err) + } + + // Run bd init with --skip-hooks + rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet", "--skip-hooks"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Verify .beads directory was created + beadsDir := filepath.Join(tmpDir, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Error(".beads directory was not created") + } + + // Verify git hooks were NOT installed + preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit") + if _, err := os.Stat(preCommitHook); err == nil { + t.Error("pre-commit hook should NOT be installed when --skip-hooks is passed") + } +} + +// TestInitDefaultInstallsHooks verifies default behavior installs hooks +func TestInitDefaultInstallsHooks(t *testing.T) { + // Reset global state + origDBPath := dbPath + defer func() { dbPath = origDBPath }() + dbPath = "" + + // Reset Cobra flags + initCmd.Flags().Set("skip-hooks", "false") + + // Clear any env var that might affect hooks + os.Unsetenv("BD_NO_INSTALL_HOOKS") + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Initialize git repo first + if err := runCommandInDir(tmpDir, "git", "init"); err != nil { + t.Fatalf("Failed to init git: %v", err) + } + + // Run bd init without any hook-skipping options + rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Verify .beads directory was created + beadsDir := filepath.Join(tmpDir, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Error(".beads directory was not created") + } + + // Verify git hooks WERE installed (default behavior) + preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit") + if _, err := os.Stat(preCommitHook); os.IsNotExist(err) { + t.Error("pre-commit hook SHOULD be installed by default") + } + + postMergeHook := filepath.Join(tmpDir, ".git", "hooks", "post-merge") + if _, err := os.Stat(postMergeHook); os.IsNotExist(err) { + t.Error("post-merge hook SHOULD be installed by default") + } +} diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 4898fc33..631cdfaa 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -34,6 +34,7 @@ Tool-level settings you can configure: | `no-daemon` | `--no-daemon` | `BD_NO_DAEMON` | `false` | Force direct mode, bypass daemon | | `no-auto-flush` | `--no-auto-flush` | `BD_NO_AUTO_FLUSH` | `false` | Disable auto JSONL export | | `no-auto-import` | `--no-auto-import` | `BD_NO_AUTO_IMPORT` | `false` | Disable auto JSONL import | +| `no-install-hooks` | `--skip-hooks` | `BD_NO_INSTALL_HOOKS` | `false` | Skip git hook installation during `bd init` | | `db` | `--db` | `BD_DB` | (auto-discover) | Database path | | `actor` | `--actor` | `BD_ACTOR` | `$USER` | Actor name for audit trail | | `flush-debounce` | - | `BEADS_FLUSH_DEBOUNCE` | `5s` | Debounce time for auto-flush | @@ -59,6 +60,10 @@ flush-debounce: 10s # Auto-start daemon (default true) auto-start-daemon: true +# Disable git hook installation during bd init +# (useful if you manage hooks with pre-commit, husky, etc.) +no-install-hooks: true + # Daemon log rotation settings daemon-log-max-size: 50 # MB per file (default 50) daemon-log-max-backups: 7 # Number of old logs to keep (default 7) diff --git a/docs/GIT_INTEGRATION.md b/docs/GIT_INTEGRATION.md index 3926a5f0..6332439b 100644 --- a/docs/GIT_INTEGRATION.md +++ b/docs/GIT_INTEGRATION.md @@ -251,11 +251,33 @@ See [PROTECTED_BRANCHES.md](PROTECTED_BRANCHES.md) for complete setup guide, tro ### Installation +Git hooks are installed automatically during `bd init`. To install manually: + ```bash # One-time setup in each beads workspace ./examples/git-hooks/install.sh ``` +### Disabling Hook Installation + +If you prefer to manage git hooks separately (e.g., using pre-commit, husky, or your own hook manager), you can disable automatic hook installation: + +```bash +# Via command-line flag +bd init --skip-hooks + +# Via environment variable (useful for CI/scripts) +BD_NO_INSTALL_HOOKS=1 bd init + +# Via global config (~/.config/bd/config.yaml) +no-install-hooks: true + +# Via project config (.beads/config.yaml) +no-install-hooks: true +``` + +**Precedence:** `--skip-hooks` flag > `BD_NO_INSTALL_HOOKS` env var > config file > default (install hooks) + ### What Gets Installed **pre-commit hook:** diff --git a/internal/config/config.go b/internal/config/config.go index bb82264b..fbb86491 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,7 @@ func Initialize() error { v.SetDefault("db", "") v.SetDefault("actor", "") v.SetDefault("issue-prefix", "") + v.SetDefault("no-install-hooks", false) // Additional environment variables (not prefixed with BD_) // These are bound explicitly for backward compatibility