feat(config): add BD_NO_INSTALL_HOOKS environment variable to disable git hook installation (#500)

* feat(config): add no-install-hooks config to disable git hook installation

Add `no-install-hooks` boolean config that prevents git hook installation
during `bd init`. This can be set via:
- Environment variable: BD_NO_INSTALL_HOOKS=1
- Global config: ~/.config/bd/config.yaml with `no-install-hooks: true`
- Local config: .beads/config.yaml with `no-install-hooks: true`

The existing `--skip-hooks` flag continues to work and takes precedence.
Default behavior unchanged: hooks install by default.

* docs: add no-install-hooks to configuration documentation

- Add no-install-hooks to Supported Settings table in CONFIG.md
- Add example in config file section
- Add "Disabling Hook Installation" section to GIT_INTEGRATION.md
  with examples for flag, env var, and config file methods
This commit is contained in:
Ryan Stortz
2025-12-13 09:38:26 -05:00
committed by GitHub
parent 074da998a7
commit 4254c3f2f5
5 changed files with 215 additions and 2 deletions

View File

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

View File

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