diff --git a/cmd/entire/cli/agent/claudecode/hooks.go b/cmd/entire/cli/agent/claudecode/hooks.go index ef1d31f6d..d63627edd 100644 --- a/cmd/entire/cli/agent/claudecode/hooks.go +++ b/cmd/entire/cli/agent/claudecode/hooks.go @@ -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) diff --git a/cmd/entire/cli/bench_enable_test.go b/cmd/entire/cli/bench_enable_test.go new file mode 100644 index 000000000..b66b2b1dc --- /dev/null +++ b/cmd/entire/cli/bench_enable_test.go @@ -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) + } + } + }) +} diff --git a/cmd/entire/cli/benchutil/benchutil.go b/cmd/entire/cli/benchutil/benchutil.go index 76243035a..3f249797d 100644 --- a/cmd/entire/cli/benchutil/benchutil.go +++ b/cmd/entire/cli/benchutil/benchutil.go @@ -549,6 +549,56 @@ func generateTranscriptMessage(index int, opts TranscriptOpts) map[string]any { return msg } +// SeedBranches creates N branches pointing at the current HEAD. +// The branches are named with the given prefix (e.g., "feature/bench-" → "feature/bench-000"). +// This simulates a repo with many refs, which affects go-git ref scanning performance. +func (br *BenchRepo) SeedBranches(b *testing.B, prefix string, count int) { + b.Helper() + headHash := plumbing.NewHash(br.HeadHash) + for i := range count { + name := fmt.Sprintf("%s%03d", prefix, i) + ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), headHash) + if err := br.Repo.Storer.SetReference(ref); err != nil { + b.Fatalf("create branch %s: %v", name, err) + } + } +} + +// PackRefs runs `git pack-refs --all` to simulate a real repo where most refs +// are in the packed-refs file. Large repos almost always have packed refs. +func (br *BenchRepo) PackRefs(b *testing.B) { + b.Helper() + cmd := exec.CommandContext(context.Background(), "git", "pack-refs", "--all") + cmd.Dir = br.Dir + if output, err := cmd.CombinedOutput(); err != nil { + b.Fatalf("git pack-refs: %v\n%s", err, output) + } +} + +// SeedGitObjects creates loose git objects to bloat .git/objects/. +// Each call creates N blob objects via `git hash-object -w`. +// After seeding, runs `git gc` to pack them into a packfile (realistic). +func (br *BenchRepo) SeedGitObjects(b *testing.B, count int) { + b.Helper() + + for i := range count { + content := GenerateFileContent(i, 4096) + cmd := exec.CommandContext(context.Background(), "git", "hash-object", "-w", "--stdin") + cmd.Dir = br.Dir + cmd.Stdin = strings.NewReader(content) + if output, err := cmd.CombinedOutput(); err != nil { + b.Fatalf("git hash-object %d: %v\n%s", i, err, output) + } + } + + // Pack into a packfile like a real repo + gc := exec.CommandContext(context.Background(), "git", "gc", "--quiet") + gc.Dir = br.Dir + if output, err := gc.CombinedOutput(); err != nil { + b.Fatalf("git gc: %v\n%s", err, output) + } +} + func generatePadding(prefix string, targetBytes int) string { if len(prefix) >= targetBytes { return prefix[:targetBytes] diff --git a/cmd/entire/cli/integration_test/hook_bench_test.go b/cmd/entire/cli/integration_test/hook_bench_test.go new file mode 100644 index 000000000..074de4f85 --- /dev/null +++ b/cmd/entire/cli/integration_test/hook_bench_test.go @@ -0,0 +1,250 @@ +//go:build integration + +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/benchutil" +) + +// BenchmarkHookSessionStart measures the end-to-end latency of the +// "entire hooks claude-code session-start" subprocess. +// +// Each sub-benchmark isolates a single scaling dimension that appears +// in the session-start hot path (see hook_registry.go → lifecycle.go): +// +// - Sessions: store.List() called 2x, ReadDir + ReadFile + unmarshal + repo.Reference() per file +// - SessionsXRefs: sessions × refs interaction (repo.Reference scans packed-refs per session) +// - PackedRefs: go-git PlainOpen + repo.Reference cost with many packed refs +// - GitObjects: go-git PlainOpen cost with large .git/objects packfile +// - Subprocess: isolates git rev-parse and binary spawn overhead +// +// Run all: +// +// go test -tags=integration -bench=BenchmarkHookSessionStart -benchtime=3x -run='^$' -timeout=10m ./cmd/entire/cli/integration_test/... +// +// Run one dimension: +// +// go test -tags=integration -bench=BenchmarkHookSessionStart/Subprocess -benchtime=5x -run='^$' ./cmd/entire/cli/integration_test/... +func BenchmarkHookSessionStart(b *testing.B) { + b.Run("Sessions", benchSessions) + b.Run("SessionsXRefs", benchSessionsXRefs) + b.Run("PackedRefs", benchPackedRefs) + b.Run("GitObjects", benchGitObjects) + b.Run("Subprocess", benchSubprocessOverhead) +} + +// benchSessions scales session state files in .git/entire-sessions/. +// listAllSessionStates() is called twice: once in FindMostRecentSession (logging init), +// once in CountOtherActiveSessionsWithCheckpoints. Each call does +// ReadDir + (ReadFile + JSON unmarshal + repo.Reference) per file. +func benchSessions(b *testing.B) { + for _, n := range []int{0, 1, 5, 10, 25, 50, 100, 200} { + b.Run(fmt.Sprintf("%d", n), func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: 10, + FeatureBranch: "feature/bench", + }) + for range n { + repo.CreateSessionState(b, benchutil.SessionOpts{ + StepCount: 3, + FilesTouched: []string{"src/file_000.go", "src/file_001.go"}, + }) + } + runSessionStartHook(b, repo) + }) + } +} + +// benchSessionsXRefs tests the interaction between session count and ref count. +// For each session, listAllSessionStates calls repo.Reference() which scans packed-refs. +// Cost should be O(sessions × packed-refs-size). +func benchSessionsXRefs(b *testing.B) { + type scenario struct { + sessions int + refs int + } + scenarios := []scenario{ + {5, 10}, + {5, 500}, + {50, 10}, + {50, 500}, + {100, 500}, + } + for _, sc := range scenarios { + b.Run(fmt.Sprintf("s%d_r%d", sc.sessions, sc.refs), func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: 10, + FeatureBranch: "feature/bench", + }) + for range sc.sessions { + repo.CreateSessionState(b, benchutil.SessionOpts{ + StepCount: 3, + FilesTouched: []string{"src/file_000.go"}, + }) + } + repo.SeedBranches(b, "feature/team-", sc.refs) + repo.PackRefs(b) + runSessionStartHook(b, repo) + }) + } +} + +// benchPackedRefs scales the number of packed git refs (branches). +// go-git PlainOpen reads packed-refs, and every repo.Reference() call +// scans it. Session count held constant at 5. +func benchPackedRefs(b *testing.B) { + for _, n := range []int{0, 50, 200, 500, 1000, 2000} { + b.Run(fmt.Sprintf("%d", n), func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: 10, + FeatureBranch: "feature/bench", + }) + for range 5 { + repo.CreateSessionState(b, benchutil.SessionOpts{ + StepCount: 3, + FilesTouched: []string{"src/file_000.go"}, + }) + } + if n > 0 { + repo.SeedBranches(b, "feature/team-", n) + repo.PackRefs(b) + } + runSessionStartHook(b, repo) + }) + } +} + +// benchGitObjects scales the .git/objects packfile size. +// go-git PlainOpen parses pack indexes; large packs may slow it down. +// This also affects repo.Head() and repo.Reference() indirectly. +func benchGitObjects(b *testing.B) { + for _, n := range []int{0, 1000, 5000, 10000} { + b.Run(fmt.Sprintf("%d", n), func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: 10, + FeatureBranch: "feature/bench", + }) + for range 5 { + repo.CreateSessionState(b, benchutil.SessionOpts{ + StepCount: 3, + FilesTouched: []string{"src/file_000.go"}, + }) + } + if n > 0 { + repo.SeedGitObjects(b, n) + } + runSessionStartHook(b, repo) + }) + } +} + +// benchSubprocessOverhead isolates the cost of subprocess spawns that happen +// during session-start. The hook calls git rev-parse multiple times (some cached, +// some not) plus spawns the entire binary itself. This benchmark measures each +// component so we can see what fraction of the total is subprocess overhead. +func benchSubprocessOverhead(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: 10, + FeatureBranch: "feature/bench", + }) + + // 1. Bare git rev-parse round-trip + b.Run("GitRevParse_1x", func(b *testing.B) { + b.ResetTimer() + for range b.N { + start := time.Now() + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = repo.Dir + if output, err := cmd.CombinedOutput(); err != nil { + b.Fatalf("git rev-parse failed: %v\n%s", err, output) + } + b.ReportMetric(float64(time.Since(start).Milliseconds()), "ms/op") + } + }) + + // 2. Seven sequential git rev-parse calls (pre-optimization baseline) + b.Run("GitRevParse_7x", func(b *testing.B) { + b.ResetTimer() + for range b.N { + start := time.Now() + for range 7 { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = repo.Dir + if output, err := cmd.CombinedOutput(); err != nil { + b.Fatalf("git rev-parse failed: %v\n%s", err, output) + } + } + b.ReportMetric(float64(time.Since(start).Milliseconds()), "ms/op") + } + }) + + // 3. Bare `entire` binary spawn (version command — minimal work, no git) + b.Run("EntireBinary_version", func(b *testing.B) { + binary := getTestBinary() + b.ResetTimer() + for range b.N { + start := time.Now() + cmd := exec.Command(binary, "version") + cmd.Dir = repo.Dir + if output, err := cmd.CombinedOutput(); err != nil { + b.Fatalf("entire version failed: %v\n%s", err, output) + } + b.ReportMetric(float64(time.Since(start).Milliseconds()), "ms/op") + } + }) + + // 4. Full session-start hook (for direct comparison) + b.Run("FullHook", func(b *testing.B) { + for range 5 { + repo.CreateSessionState(b, benchutil.SessionOpts{ + StepCount: 3, + FilesTouched: []string{"src/file_000.go"}, + }) + } + runSessionStartHook(b, repo) + }) +} + +// runSessionStartHook is the shared benchmark loop that invokes the session-start +// hook as a subprocess and reports latency in ms/op. +func runSessionStartHook(b *testing.B, repo *benchutil.BenchRepo) { + b.Helper() + + stdinPayload, err := json.Marshal(map[string]string{ + "session_id": "bench-session", + "transcript_path": "", + }) + if err != nil { + b.Fatalf("marshal stdin: %v", err) + } + + binary := getTestBinary() + claudeProjectDir := b.TempDir() + + b.ResetTimer() + for range b.N { + start := time.Now() + + cmd := exec.Command(binary, "hooks", "claude-code", "session-start") + cmd.Dir = repo.Dir + cmd.Stdin = bytes.NewReader(stdinPayload) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+claudeProjectDir, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + b.Fatalf("session-start hook failed: %v\nOutput: %s", err, output) + } + + b.ReportMetric(float64(time.Since(start).Milliseconds()), "ms/op") + } +} diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index c8be0541a..7728044a2 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -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" @@ -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) @@ -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 @@ -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()) @@ -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 } diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index a5c4a08b5..a83676526 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -485,7 +485,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) } diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 5ee5ced3e..0caddde0f 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -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) } } diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 11ef4cb17..ecc5b5775 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -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) diff --git a/cmd/entire/cli/strategy/hook_managers.go b/cmd/entire/cli/strategy/hook_managers.go index 44b0e8c80..cfc3f7960 100644 --- a/cmd/entire/cli/strategy/hook_managers.go +++ b/cmd/entire/cli/strategy/hook_managers.go @@ -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 @@ -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) diff --git a/cmd/entire/cli/strategy/hook_managers_test.go b/cmd/entire/cli/strategy/hook_managers_test.go index 79266ec02..60fe5461d 100644 --- a/cmd/entire/cli/strategy/hook_managers_test.go +++ b/cmd/entire/cli/strategy/hook_managers_test.go @@ -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()) @@ -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") { diff --git a/cmd/entire/cli/strategy/hooks.go b/cmd/entire/cli/strategy/hooks.go index 7863d9c59..eef870688 100644 --- a/cmd/entire/cli/strategy/hooks.go +++ b/cmd/entire/cli/strategy/hooks.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "github.com/entireio/cli/cmd/entire/cli/settings" ) @@ -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. @@ -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 @@ -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 for _, spec := range specs { @@ -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" diff --git a/cmd/entire/cli/strategy/hooks_test.go b/cmd/entire/cli/strategy/hooks_test.go index 42778e422..489e53019 100644 --- a/cmd/entire/cli/strategy/hooks_test.go +++ b/cmd/entire/cli/strategy/hooks_test.go @@ -274,7 +274,7 @@ func TestInstallGitHook_WorktreeInstallsInCommonHooks(t *testing.T) { t.Chdir(worktreeDir) paths.ClearRepoRootCache() - count, err := InstallGitHook(true) + count, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() in worktree failed: %v", err) } @@ -531,7 +531,7 @@ func TestInstallGitHook_Idempotent(t *testing.T) { _, hooksDir := initHooksTestRepo(t) // First install should install hooks - firstCount, err := InstallGitHook(true) + firstCount, err := InstallGitHook(true, false) if err != nil { t.Fatalf("First InstallGitHook() error = %v", err) } @@ -553,7 +553,7 @@ func TestInstallGitHook_Idempotent(t *testing.T) { } // Second install should return 0 (all hooks already up to date) - secondCount, err := InstallGitHook(true) + secondCount, err := InstallGitHook(true, false) if err != nil { t.Fatalf("Second InstallGitHook() error = %v", err) } @@ -573,6 +573,56 @@ func TestInstallGitHook_Idempotent(t *testing.T) { } } +func TestInstallGitHook_LocalDevCommandPrefix(t *testing.T) { + _, hooksDir := initHooksTestRepo(t) + + // Install with localDev=true + count, err := InstallGitHook(true, true) + if err != nil { + t.Fatalf("InstallGitHook(localDev=true) error = %v", err) + } + if count == 0 { + t.Fatal("InstallGitHook(localDev=true) should install hooks") + } + + for _, hook := range gitHookNames { + data, err := os.ReadFile(filepath.Join(hooksDir, hook)) + if err != nil { + t.Fatalf("hook %s should exist: %v", hook, err) + } + content := string(data) + if !strings.Contains(content, "go run ./cmd/entire/main.go") { + t.Errorf("hook %s should use 'go run' prefix when localDev=true, got:\n%s", hook, content) + } + if strings.Contains(content, "\nentire ") { + t.Errorf("hook %s should not use bare 'entire' prefix when localDev=true", hook) + } + } + + // Reinstall with localDev=false — hooks should update to use "entire" prefix + count, err = InstallGitHook(true, false) + if err != nil { + t.Fatalf("InstallGitHook(localDev=false) error = %v", err) + } + if count == 0 { + t.Fatal("InstallGitHook(localDev=false) should reinstall hooks (content changed)") + } + + for _, hook := range gitHookNames { + data, err := os.ReadFile(filepath.Join(hooksDir, hook)) + if err != nil { + t.Fatalf("hook %s should exist: %v", hook, err) + } + content := string(data) + if strings.Contains(content, "go run") { + t.Errorf("hook %s should not use 'go run' prefix when localDev=false, got:\n%s", hook, content) + } + if !strings.Contains(content, "\nentire ") { + t.Errorf("hook %s should use bare 'entire' prefix when localDev=false", hook) + } + } +} + func TestInstallGitHook_CoreHooksPathRelative(t *testing.T) { tmpDir, _ := initHooksTestRepo(t) ctx := context.Background() @@ -584,7 +634,7 @@ func TestInstallGitHook_CoreHooksPathRelative(t *testing.T) { t.Fatalf("failed to set core.hooksPath: %v", err) } - count, err := InstallGitHook(true) + count, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -628,7 +678,7 @@ func TestRemoveGitHook_CoreHooksPathRelative(t *testing.T) { t.Fatalf("failed to set core.hooksPath: %v", err) } - installCount, err := InstallGitHook(true) + installCount, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -669,7 +719,7 @@ func TestRemoveGitHook_RemovesInstalledHooks(t *testing.T) { tmpDir, _ := initHooksTestRepo(t) // Install hooks first - installCount, err := InstallGitHook(true) + installCount, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -769,7 +819,7 @@ func TestInstallGitHook_BacksUpCustomHook(t *testing.T) { t.Fatalf("failed to create custom hook: %v", err) } - count, err := InstallGitHook(true) + count, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -821,7 +871,7 @@ func TestInstallGitHook_DoesNotOverwriteExistingBackup(t *testing.T) { t.Fatalf("failed to create second custom hook: %v", err) } - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -857,7 +907,7 @@ func TestInstallGitHook_IdempotentWithChaining(t *testing.T) { t.Fatalf("failed to create custom hook: %v", err) } - firstCount, err := InstallGitHook(true) + firstCount, err := InstallGitHook(true, false) if err != nil { t.Fatalf("first InstallGitHook() error = %v", err) } @@ -866,7 +916,7 @@ func TestInstallGitHook_IdempotentWithChaining(t *testing.T) { } // Re-install should return 0 (idempotent) - secondCount, err := InstallGitHook(true) + secondCount, err := InstallGitHook(true, false) if err != nil { t.Fatalf("second InstallGitHook() error = %v", err) } @@ -878,7 +928,7 @@ func TestInstallGitHook_IdempotentWithChaining(t *testing.T) { func TestInstallGitHook_NoBackupWhenNoExistingHook(t *testing.T) { _, hooksDir := initHooksTestRepo(t) - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -916,7 +966,7 @@ func TestInstallGitHook_MixedHooks(t *testing.T) { } } - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -965,7 +1015,7 @@ func TestRemoveGitHook_RestoresBackup(t *testing.T) { t.Fatalf("failed to create custom hook: %v", err) } - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -1004,7 +1054,7 @@ func TestRemoveGitHook_RestoresBackupWhenHookAlreadyGone(t *testing.T) { t.Fatalf("failed to create custom hook: %v", err) } - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -1080,7 +1130,7 @@ func TestInstallGitHook_InstallRemoveReinstall(t *testing.T) { } // Install: should back up and chain - count, err := InstallGitHook(true) + count, err := InstallGitHook(true, false) if err != nil { t.Fatalf("first install error: %v", err) } @@ -1109,7 +1159,7 @@ func TestInstallGitHook_InstallRemoveReinstall(t *testing.T) { } // Reinstall: should back up again and chain - count, err = InstallGitHook(true) + count, err = InstallGitHook(true, false) if err != nil { t.Fatalf("reinstall error: %v", err) } @@ -1142,7 +1192,7 @@ func TestRemoveGitHook_DoesNotOverwriteReplacedHook(t *testing.T) { } // entire enable: backs up A, installs our hook with chain - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } @@ -1183,7 +1233,7 @@ func TestRemoveGitHook_PermissionDenied(t *testing.T) { tmpDir, _ := initHooksTestRepo(t) // Install hooks first - _, err := InstallGitHook(true) + _, err := InstallGitHook(true, false) if err != nil { t.Fatalf("InstallGitHook() error = %v", err) } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 499732c0b..8a8d2c3c4 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -112,7 +112,7 @@ func (s *ManualCommitStrategy) EnsureSetup() error { // Install generic hooks (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) } } diff --git a/cpu_enable.prof b/cpu_enable.prof new file mode 100644 index 000000000..2c36bb085 Binary files /dev/null and b/cpu_enable.prof differ diff --git a/mise-tasks/bench/compare b/mise-tasks/bench/compare new file mode 100755 index 000000000..c8f282cc4 --- /dev/null +++ b/mise-tasks/bench/compare @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +#MISE description="Compare benchmarks between current branch and base ref (requires benchstat: go install golang.org/x/perf/cmd/benchstat@latest)" +set -euo pipefail + +BENCH_PATTERN="${BENCH_PATTERN:-.}" +BENCH_PKG="${BENCH_PKG:-./...}" +BENCH_COUNT="${BENCH_COUNT:-6}" +BENCH_TIMEOUT="${BENCH_TIMEOUT:-10m}" +BENCH_TAGS="${BENCH_TAGS:-}" +BASE_REF="${BASE_REF:-main}" + +if ! command -v benchstat &>/dev/null; then + echo "benchstat not found. Install with: go install golang.org/x/perf/cmd/benchstat@latest" + exit 1 +fi + +current_branch=$(git rev-parse --abbrev-ref HEAD) +if [ "$current_branch" = "$BASE_REF" ]; then + echo "Already on $BASE_REF — nothing to compare. Run from a feature branch." + exit 1 +fi + +tmpdir=$(mktemp -d) +on_base_ref=false +has_changes=false + +cleanup() { + if [ "$on_base_ref" = true ]; then + git checkout "$current_branch" --quiet 2>/dev/null || true + fi + if [ "$has_changes" = true ]; then + git stash pop --quiet 2>/dev/null || true + fi + rm -rf "$tmpdir" +} +trap cleanup EXIT + +# Filter go test output to only valid benchmark lines (excludes stderr noise like "✓ Created orphan branch") +run_bench() { + local label="$1" out="$2" + echo "=== Benchmarking: $label ===" + go test ${BENCH_TAGS:+-tags="$BENCH_TAGS"} -bench="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" "$BENCH_PKG" 2>/dev/null \ + | grep -E '^(Benchmark.*[[:space:]]+[0-9]|goos:|goarch:|pkg:|cpu:)' > "$out" + local n + n=$(grep -c '^Benchmark' "$out" || true) + if [ "$n" -eq 0 ]; then + echo " ERROR: no benchmark results captured." + return 1 + fi + echo " captured $n result lines" +} + +# Use branch names as filenames so benchstat shows them as column headers +new_out="$tmpdir/$current_branch" +if ! run_bench "$current_branch" "$new_out"; then + exit 1 +fi + +# Stash, switch to base, run, switch back +if ! git diff --quiet || ! git diff --cached --quiet; then + has_changes=true + git stash push -q -m "bench:compare auto-stash" +fi + +old_out="$tmpdir/$BASE_REF" +echo "" +git checkout "$BASE_REF" --quiet 2>/dev/null +on_base_ref=true + +if ! run_bench "$BASE_REF" "$old_out"; then + echo "" + echo "Benchmark does not exist on $BASE_REF. Showing current branch only:" + git checkout "$current_branch" --quiet 2>/dev/null + on_base_ref=false + [ "$has_changes" = true ] && git stash pop --quiet && has_changes=false + echo "" + (cd "$tmpdir" && benchstat "$(basename "$new_out")") + exit 0 +fi + +git checkout "$current_branch" --quiet 2>/dev/null +on_base_ref=false +[ "$has_changes" = true ] && git stash pop --quiet && has_changes=false + +echo "" +(cd "$tmpdir" && benchstat "$(basename "$old_out")" "$(basename "$new_out")") diff --git a/mise-tasks/bench/cpu b/mise-tasks/bench/cpu new file mode 100755 index 000000000..2ba0b689b --- /dev/null +++ b/mise-tasks/bench/cpu @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +#MISE description="Run benchmarks with CPU profile (single package)" +set -euo pipefail + +PKG="${BENCH_PKG:-./cmd/entire/cli/benchutil/}" +echo "Profiling package: $PKG (override with BENCH_PKG=./path/to/pkg)" +go test -bench=. -benchmem -run='^$' -cpuprofile=cpu.prof -timeout=10m "$PKG" +echo "Profile saved to cpu.prof. View with: go tool pprof -http=:8080 cpu.prof" diff --git a/mise-tasks/bench/mem b/mise-tasks/bench/mem new file mode 100755 index 000000000..b051e467b --- /dev/null +++ b/mise-tasks/bench/mem @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +#MISE description="Run benchmarks with memory profile (single package)" +set -euo pipefail + +PKG="${BENCH_PKG:-./cmd/entire/cli/benchutil/}" +echo "Profiling package: $PKG (override with BENCH_PKG=./path/to/pkg)" +go test -bench=. -benchmem -run='^$' -memprofile=mem.prof -timeout=10m "$PKG" +echo "Profile saved to mem.prof. View with: go tool pprof -http=:8080 mem.prof" diff --git a/mise.toml b/mise.toml index c58b3e6ac..ea69f7e83 100644 --- a/mise.toml +++ b/mise.toml @@ -119,118 +119,6 @@ if [ -n "$bench_lines" ]; then fi ''' -[tasks."bench:cpu"] -description = "Run benchmarks with CPU profile (pass package as arg, default: ./cmd/entire/cli/)" -run = ''' -#!/usr/bin/env bash -set -euo pipefail - -pkg="${1:-./cmd/entire/cli/}" -output=$(go test -bench=. -benchmem -run='^$' -cpuprofile=cpu.prof -timeout=10m "$pkg" 2>&1) - -echo "$output" | grep -v '^Benchmark' >&2 - -bench_lines=$(echo "$output" | grep '^Benchmark' || true) -if [ -n "$bench_lines" ]; then - { - echo "BENCHMARK ITERS MS/OP NS/OP B/OP ALLOCS/OP" - echo "--------- ----- ----- ----- ---- ---------" - echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' \ - | awk '{printf "%s %s %.2f %s %s %s\n", $1, $2, $3/1000000, $3, $4, $5}' - } | column -t -fi - -echo "" -echo "CPU profile saved to cpu.prof. View with: go tool pprof -http=:8080 cpu.prof" -''' - -[tasks."bench:mem"] -description = "Run benchmarks with memory profile (pass package as arg, default: ./cmd/entire/cli/)" -run = ''' -#!/usr/bin/env bash -set -euo pipefail - -pkg="${1:-./cmd/entire/cli/}" -output=$(go test -bench=. -benchmem -run='^$' -memprofile=mem.prof -timeout=10m "$pkg" 2>&1) - -echo "$output" | grep -v '^Benchmark' >&2 - -bench_lines=$(echo "$output" | grep '^Benchmark' || true) -if [ -n "$bench_lines" ]; then - { - echo "BENCHMARK ITERS MS/OP NS/OP B/OP ALLOCS/OP" - echo "--------- ----- ----- ----- ---- ---------" - echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' \ - | awk '{printf "%s %s %.2f %s %s %s\n", $1, $2, $3/1000000, $3, $4, $5}' - } | column -t -fi - -echo "" -echo "Memory profile saved to mem.prof. View with: go tool pprof -http=:8080 mem.prof" -''' - -[tasks."bench:compare"] -description = "Compare benchmarks between current branch and main" -run = """ -#!/usr/bin/env bash -set -euo pipefail - -# Install benchstat if not available -if ! command -v benchstat &>/dev/null; then - echo "Installing benchstat..." - if ! go install golang.org/x/perf/cmd/benchstat@latest; then - echo "Failed to install benchstat. Please install it manually: go install golang.org/x/perf/cmd/benchstat@latest" - exit 1 - fi -fi - -BENCH_PATTERN="${BENCH_PATTERN:-.}" -BENCH_COUNT="${BENCH_COUNT:-6}" -BENCH_TIMEOUT="${BENCH_TIMEOUT:-10m}" -BASE_REF="${BASE_REF:-main}" - -current_branch=$(git rev-parse --abbrev-ref HEAD) -if [ "$current_branch" = "$BASE_REF" ]; then - echo "Already on $BASE_REF — nothing to compare. Run from a feature branch." - exit 1 -fi - -tmpdir=$(mktemp -d) -new_out="$tmpdir/new.txt" -old_out="$tmpdir/old.txt" - -has_changes=false -trap 'git checkout "$current_branch" --quiet 2>/dev/null; [ "$has_changes" = true ] && git stash pop --quiet 2>/dev/null; rm -rf "$tmpdir"' EXIT - -echo "=== Benchmarking current branch ($current_branch) ===" -go test -bench="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" ./... > "$new_out" 2>&1 || true - -# Check for uncommitted changes -has_changes=false -if ! git diff --quiet || ! git diff --cached --quiet; then - has_changes=true - echo "Stashing uncommitted changes..." - git stash push -m "bench:compare auto-stash" -fi - -echo "" -echo "=== Benchmarking base ($BASE_REF) ===" -git checkout "$BASE_REF" --quiet -go test -bench="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" ./... > "$old_out" 2>&1 || true - -echo "" -echo "=== Switching back to $current_branch ===" -git checkout "$current_branch" --quiet -if [ "$has_changes" = true ]; then - git stash pop --quiet -fi - -echo "" -echo "=== Results (base=$BASE_REF vs current=$current_branch) ===" -echo "" -benchstat "$old_out" "$new_out" -""" - [tasks."test:e2e"] description = "Run E2E tests with real agent calls (requires claude CLI)" # -count=1 disables test caching since E2E tests call real external agents