Skip to content
2 changes: 1 addition & 1 deletion cmd/entire/cli/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (c *ClaudeCodeAgent) InstallHooks(localDev bool, force bool) (int, error) {
// rawPermissions preserves unknown permission fields (e.g., "ask")
var rawPermissions map[string]json.RawMessage

existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from cwd + fixed path
existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + settings file name
if readErr == nil {
if err := json.Unmarshal(existingData, &rawSettings); err != nil {
return 0, fmt.Errorf("failed to parse existing settings.json: %w", err)
Expand Down
69 changes: 69 additions & 0 deletions cmd/entire/cli/bench_enable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cli

import (
"bytes"
"os"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/benchutil"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/strategy"
)

// BenchmarkEnableCommand benchmarks the non-interactive enable path
// (setupAgentHooksNonInteractive) which is the hot path for `entire enable --agent claude-code`.
//
// Cannot use t.Parallel() because os.Chdir is process-global state.
func BenchmarkEnableCommand(b *testing.B) {
ag, err := agent.Get(agent.AgentNameClaudeCode)
if err != nil {
b.Fatalf("get agent: %v", err)
}

b.Run("NewRepo_ClaudeCode", func(b *testing.B) {
for b.Loop() {
b.StopTimer()
repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{})
//nolint:usetesting // b.Chdir() restores only once at cleanup; we need a fresh dir each iteration
if err := os.Chdir(repo.Dir); err != nil {
b.Fatalf("chdir: %v", err)
}
paths.ClearRepoRootCache()
strategy.ClearHooksDirCache()
b.StartTimer()

w := &bytes.Buffer{}
if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil {
b.Fatalf("setupAgentHooksNonInteractive: %v", err)
}
}
})

b.Run("ReEnable_ClaudeCode", func(b *testing.B) {
b.StopTimer()
repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{})
b.Chdir(repo.Dir)
paths.ClearRepoRootCache()
strategy.ClearHooksDirCache()

// First enable to set up everything
w := &bytes.Buffer{}
if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil {
b.Fatalf("initial enable: %v", err)
}
b.StartTimer()

for b.Loop() {
b.StopTimer()
paths.ClearRepoRootCache()
strategy.ClearHooksDirCache()
b.StartTimer()

w.Reset()
if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil {
b.Fatalf("setupAgentHooksNonInteractive: %v", err)
}
}
})
}
25 changes: 11 additions & 14 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/strategy"

"github.com/charmbracelet/huh"
Expand Down Expand Up @@ -265,11 +266,10 @@ func runEnableWithStrategy(w io.Writer, agents []agent.Agent, selectedStrategy s
}
}

// Install git hooks AFTER saving settings (InstallGitHook reads local_dev from settings)
if _, err := strategy.InstallGitHook(true); err != nil {
if _, err := strategy.InstallGitHook(true, localDev); err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
strategy.CheckAndWarnHookManagers(w)
strategy.CheckAndWarnHookManagers(w, localDev)
fmt.Fprintln(w, "✓ Hooks installed")
fmt.Fprintf(w, "✓ Project configured (%s)\n", configDisplay)

Expand Down Expand Up @@ -351,11 +351,10 @@ func runEnableInteractive(w io.Writer, agents []agent.Agent, localDev, useLocalS
return fmt.Errorf("failed to save settings: %w", err)
}

// Install git hooks AFTER saving settings (InstallGitHook reads local_dev from settings)
if _, err := strategy.InstallGitHook(true); err != nil {
if _, err := strategy.InstallGitHook(true, localDev); err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
strategy.CheckAndWarnHookManagers(w)
strategy.CheckAndWarnHookManagers(w, localDev)
fmt.Fprintln(w, "✓ Hooks installed")

configDisplay := configDisplayProject
Expand Down Expand Up @@ -769,11 +768,10 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str
return fmt.Errorf("failed to save settings: %w", err)
}

// Install git hooks AFTER saving settings (InstallGitHook reads local_dev from settings)
if _, err := strategy.InstallGitHook(true); err != nil {
if _, err := strategy.InstallGitHook(true, localDev); err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
strategy.CheckAndWarnHookManagers(w)
strategy.CheckAndWarnHookManagers(w, localDev)

if installedHooks == 0 {
msg := fmt.Sprintf("Hooks for %s already installed", ag.Description())
Expand Down Expand Up @@ -870,13 +868,12 @@ func setupEntireDirectory() (bool, error) { //nolint:unparam // already present

// setupGitHook installs the prepare-commit-msg hook for context trailers.
func setupGitHook() error {
// Use shared implementation from strategy package
// The localDev setting is read from settings.json
_, err := strategy.InstallGitHook(false) // not silent - show output during setup
if err != nil {
s, err := settings.Load()
localDev := err == nil && s.LocalDev
if _, err := strategy.InstallGitHook(false, localDev); err != nil {
return fmt.Errorf("failed to install git hook: %w", err)
}
strategy.CheckAndWarnHookManagers(os.Stderr)
strategy.CheckAndWarnHookManagers(os.Stderr, localDev)
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ func TestRunUninstall_Force_RemovesGitHooks(t *testing.T) {
writeSettings(t, testSettingsEnabled)

// Install git hooks
if _, err := strategy.InstallGitHook(true); err != nil {
if _, err := strategy.InstallGitHook(true, false); err != nil {
t.Fatalf("InstallGitHook() error = %v", err)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ func (s *AutoCommitStrategy) EnsureSetup() error {

// Install generic hooks if missing (they delegate to strategy at runtime)
if !IsGitHookInstalled() {
if _, err := InstallGitHook(true); err != nil {
if _, err := InstallGitHook(true, isLocalDev()); err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
}
Expand Down
4 changes: 1 addition & 3 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,9 +558,7 @@ func GetRemoteMetadataBranchTree(repo *git.Repository) (*object.Tree, error) {
// The function first uses 'git rev-parse --show-toplevel' to find the repository
// root, which works correctly even when called from a subdirectory within the repo.
func OpenRepository() (*git.Repository, error) {
// First, find the repository root using git rev-parse --show-toplevel
// This works correctly from any subdirectory within the repository
repoRoot, err := GetWorktreePath()
repoRoot, err := paths.RepoRoot()
if err != nil {
// Fallback to current directory if git command fails
// (e.g., if git is not installed or we're not in a repo)
Expand Down
5 changes: 3 additions & 2 deletions cmd/entire/cli/strategy/hook_managers.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ func extractCommandLine(hookContent string) string {

// CheckAndWarnHookManagers detects external hook managers and writes a warning
// to w if any are found.
func CheckAndWarnHookManagers(w io.Writer) {
// localDev controls whether the warning references "go run" or the "entire" binary.
func CheckAndWarnHookManagers(w io.Writer, localDev bool) {
repoRoot, err := paths.RepoRoot()
if err != nil {
return
Expand All @@ -122,7 +123,7 @@ func CheckAndWarnHookManagers(w io.Writer) {
return
}

warning := hookManagerWarning(managers, hookCmdPrefix())
warning := hookManagerWarning(managers, hookCmdPrefix(localDev))
if warning != "" {
fmt.Fprintln(w)
fmt.Fprint(w, warning)
Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/strategy/hook_managers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ func TestCheckAndWarnHookManagers_NoManagers(t *testing.T) {
initHooksTestRepo(t)

var buf bytes.Buffer
CheckAndWarnHookManagers(&buf)
CheckAndWarnHookManagers(&buf, false)

if buf.Len() != 0 {
t.Errorf("expected no output, got %q", buf.String())
Expand All @@ -397,7 +397,7 @@ func TestCheckAndWarnHookManagers_WithHusky(t *testing.T) {
}

var buf bytes.Buffer
CheckAndWarnHookManagers(&buf)
CheckAndWarnHookManagers(&buf, false)

output := buf.String()
if !strings.Contains(output, "Warning: Husky detected") {
Expand Down
53 changes: 48 additions & 5 deletions cmd/entire/cli/strategy/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"

"github.com/entireio/cli/cmd/entire/cli/settings"
)
Expand Down Expand Up @@ -40,11 +41,52 @@ func GetGitDir() (string, error) {
return getGitDirInPath(".")
}

// hooksDirCache caches the hooks directory to avoid repeated git subprocess spawns.
// Keyed by current working directory to handle directory changes.
var (
hooksDirMu sync.RWMutex
hooksDirCache string
hooksDirCacheDir string
)

// GetHooksDir returns the active hooks directory path.
// This respects core.hooksPath and correctly resolves to the common hooks
// directory when called from a linked worktree.
// The result is cached per working directory.
func GetHooksDir() (string, error) {
return getHooksDirInPath(".")
cwd, err := os.Getwd() //nolint:forbidigo // cache key for hooks dir, same pattern as paths.RepoRoot()
if err != nil {
cwd = ""
}

hooksDirMu.RLock()
if hooksDirCache != "" && hooksDirCacheDir == cwd {
cached := hooksDirCache
hooksDirMu.RUnlock()
return cached, nil
}
hooksDirMu.RUnlock()

result, err := getHooksDirInPath(".")
if err != nil {
return "", err
}

hooksDirMu.Lock()
hooksDirCache = result
hooksDirCacheDir = cwd
hooksDirMu.Unlock()

return result, nil
}

// ClearHooksDirCache clears the cached hooks directory.
// This is primarily useful for testing when changing directories.
func ClearHooksDirCache() {
hooksDirMu.Lock()
hooksDirCache = ""
hooksDirCacheDir = ""
hooksDirMu.Unlock()
}

// getGitDirInPath returns the git directory for a repository at the given path.
Expand Down Expand Up @@ -165,8 +207,9 @@ func buildHookSpecs(cmdPrefix string) []hookSpec {
// InstallGitHook installs generic git hooks that delegate to `entire hook` commands.
// These hooks work with any strategy - the strategy is determined at runtime.
// If silent is true, no output is printed (except backup notifications, which always print).
// localDev controls whether hooks use "go run" (true) or the "entire" binary (false).
// Returns the number of hooks that were installed (0 if all already up to date).
func InstallGitHook(silent bool) (int, error) {
func InstallGitHook(silent bool, localDev bool) (int, error) {
hooksDir, err := GetHooksDir()
if err != nil {
return 0, err
Expand All @@ -176,7 +219,7 @@ func InstallGitHook(silent bool) (int, error) {
return 0, fmt.Errorf("failed to create hooks directory: %w", err)
}

specs := buildHookSpecs(hookCmdPrefix())
specs := buildHookSpecs(hookCmdPrefix(localDev))
installedCount := 0
Comment on lines 207 to 223
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InstallGitHook behavior now differs based on the new localDev parameter (writing go run ... vs entire into hook scripts), but the updated tests only exercise localDev=false. Add a test case that installs hooks with localDev=true and asserts the generated hook contents use the expected command prefix, to prevent regressions in local-dev setups.

Copilot generated this review using guidance from repository custom instructions.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved


for _, spec := range specs {
Expand Down Expand Up @@ -298,8 +341,8 @@ fi

// hookCmdPrefix returns the command prefix for hook scripts and warning messages.
// Returns "go run ./cmd/entire/main.go" when local_dev is enabled, "entire" otherwise.
func hookCmdPrefix() string {
if isLocalDev() {
func hookCmdPrefix(localDev bool) string {
if localDev {
return "go run ./cmd/entire/main.go"
}
return "entire"
Expand Down
Loading