From 85c33b8c75a0cbfc97c174372e7ed66b1cb13238 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 21:16:56 -0800 Subject: [PATCH 01/11] add benchmark for enable Entire-Checkpoint: 1016d272df11 --- cmd/entire/cli/bench_enable_test.go | 65 ++++++++++++++ mise.toml | 126 +++++++++++++++++++++------- 2 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 cmd/entire/cli/bench_enable_test.go diff --git a/cmd/entire/cli/bench_enable_test.go b/cmd/entire/cli/bench_enable_test.go new file mode 100644 index 000000000..c98655444 --- /dev/null +++ b/cmd/entire/cli/bench_enable_test.go @@ -0,0 +1,65 @@ +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" +) + +// 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() + 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() + + // 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() + b.StartTimer() + + w.Reset() + if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil { + b.Fatalf("setupAgentHooksNonInteractive: %v", err) + } + } + }) +} diff --git a/mise.toml b/mise.toml index c58b3e6ac..2520439b9 100644 --- a/mise.toml +++ b/mise.toml @@ -170,21 +170,13 @@ echo "Memory profile saved to mem.prof. View with: go tool pprof -http=:8080 mem ''' [tasks."bench:compare"] -description = "Compare benchmarks between current branch and main" +description = "Compare benchmarks between current branch and base ref" 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_PKG="${BENCH_PKG:-./...}" BENCH_COUNT="${BENCH_COUNT:-6}" BENCH_TIMEOUT="${BENCH_TIMEOUT:-10m}" BASE_REF="${BASE_REF:-main}" @@ -196,39 +188,109 @@ if [ "$current_branch" = "$BASE_REF" ]; then 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 +trap 'rm -rf "$tmpdir"' EXIT + +run_bench() { + local label="$1" out="$2" + echo "=== Benchmarking: $label ===" + go test -bench="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" "$BENCH_PKG" 2>/dev/null \ + | grep -E '^(Benchmark|goos:|goarch:|pkg:|cpu:)' > "$out" + local count + count=$(grep -c '^Benchmark' "$out" || true) + if [ "$count" -eq 0 ]; then + echo " ERROR: no benchmark results captured. Does the benchmark exist on this branch?" + return 1 + fi + echo " captured $count benchmark lines" +} -echo "=== Benchmarking current branch ($current_branch) ===" -go test -bench="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" ./... > "$new_out" 2>&1 || true +# Run on current branch first +new_out="$tmpdir/new.txt" +if ! run_bench "$current_branch" "$new_out"; then + exit 1 +fi -# Check for uncommitted changes +# Stash if needed, switch to base, run, switch back 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" + git stash push -q -m "bench:compare auto-stash" fi +old_out="$tmpdir/old.txt" 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 +git checkout "$BASE_REF" --quiet 2>/dev/null +if ! run_bench "$BASE_REF" "$old_out"; then + echo "" + echo "Benchmark does not exist on $BASE_REF. Showing current branch results only:" + echo "" + git checkout "$current_branch" --quiet 2>/dev/null + [ "$has_changes" = true ] && git stash pop --quiet + + # Pretty-print single-branch results + printf "%-40s %12s %12s %12s\n" "Benchmark" "ms/op" "B/op" "allocs/op" + printf "%-40s %12s %12s %12s\n" "----------------------------------------" "--------" "--------" "---------" + grep '^Benchmark' "$new_out" | while read -r name iters nsop _ bop _ aop _; do + ms=$(echo "scale=2; $nsop / 1000000" | bc) + printf "%-40s %10s ms %12s %12s\n" "$name" "$ms" "$bop" "$aop" + done + exit 0 fi +git checkout "$current_branch" --quiet 2>/dev/null +[ "$has_changes" = true ] && git stash pop --quiet + +# Both branches have results — show comparison table echo "" -echo "=== Results (base=$BASE_REF vs current=$current_branch) ===" -echo "" -benchstat "$old_out" "$new_out" +python3 - "$old_out" "$new_out" "$BASE_REF" "$current_branch" <<'PYEOF' +import sys, re + +old_file, new_file, base_name, curr_name = sys.argv[1:5] + +def parse_benchmarks(path): + results = {} + for line in open(path): + if not line.startswith("Benchmark"): + continue + parts = line.split() + if len(parts) < 8: + continue + try: + name = parts[0] + ns = float(parts[2]) + bop = int(parts[4]) + allocs = int(parts[6]) + results[name] = (ns / 1e6, bop, allocs) + except (ValueError, IndexError): + continue + return results + +old = parse_benchmarks(old_file) +new = parse_benchmarks(new_file) +all_names = list(dict.fromkeys(list(new.keys()) + list(old.keys()))) + +# Header +print(f"{'Benchmark':<45} {'ms/op':>18} {'B/op':>18} {'allocs/op':>18}") +print(f"{'':─<45}─{'':─<18}─{'':─<18}─{'':─<18}") + +for name in all_names: + short = name.replace("BenchmarkEnableCommand/", "").replace("Benchmark", "") + if name in old and name in new: + o_ms, o_b, o_a = old[name] + n_ms, n_b, n_a = new[name] + d_ms = ((n_ms - o_ms) / o_ms * 100) if o_ms > 0 else 0 + d_b = ((n_b - o_b) / o_b * 100) if o_b > 0 else 0 + d_a = ((n_a - o_a) / o_a * 100) if o_a > 0 else 0 + def fmt_delta(d): + return f"({d:+.1f}%)" + print(f" {short:<43} {o_ms:>6.1f} → {n_ms:>6.1f} {fmt_delta(d_ms):>7} {o_b:>7} → {n_b:>7} {fmt_delta(d_b):>7} {o_a:>5} → {n_a:>5} {fmt_delta(d_a):>7}") + elif name in new: + n_ms, n_b, n_a = new[name] + print(f" {short:<43} {'':>6} {n_ms:>6.1f} {'(new)':>7} {'':>7} {n_b:>7} {'(new)':>7} {'':>5} {n_a:>5} {'(new)':>7}") + +print() +print(f" base: {base_name} current: {curr_name}") +PYEOF """ [tasks."test:e2e"] From e4423fb1b0e2fbe65d1a7e81d9f2166b3359392e Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:12:42 -0800 Subject: [PATCH 02/11] pass localDev param to InstallGitHook and CheckAndWarnHookManagers Eliminates 2-3 redundant settings.Load() calls (each reading 2 JSON files + JSON parse) on the enable hot path by threading the localDev bool from callers that already have it. Cold paths (setupGitHook hidden command, EnsureSetup self-healing fallback) still call isLocalDev(). Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 13ed79ed3617 --- cmd/entire/cli/agent/claudecode/hooks.go | 2 +- cmd/entire/cli/setup.go | 25 ++++++------- cmd/entire/cli/setup_test.go | 2 +- cmd/entire/cli/strategy/auto_commit.go | 2 +- cmd/entire/cli/strategy/hook_managers.go | 5 +-- cmd/entire/cli/strategy/hook_managers_test.go | 4 +-- cmd/entire/cli/strategy/hooks.go | 9 ++--- cmd/entire/cli/strategy/hooks_test.go | 36 +++++++++---------- cmd/entire/cli/strategy/manual_commit.go | 2 +- 9 files changed, 43 insertions(+), 44 deletions(-) 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/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/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..6457aac29 100644 --- a/cmd/entire/cli/strategy/hooks.go +++ b/cmd/entire/cli/strategy/hooks.go @@ -165,8 +165,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 +177,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 +299,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..c8877d7fc 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) } @@ -584,7 +584,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 +628,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 +669,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 +769,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 +821,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 +857,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 +866,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 +878,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 +916,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 +965,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 +1004,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 +1080,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 +1109,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 +1142,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 +1183,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) } } From 192fae5040e0bb186f740cf83c816aecdeda8eaf Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:19:13 -0800 Subject: [PATCH 03/11] cache GetHooksDir() result per working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CWD-keyed caching to GetHooksDir() matching the existing paths.RepoRoot() pattern. Eliminates a redundant `git rev-parse --git-path hooks` subprocess on the enable hot path (~10ms saved). Benchmark: NewRepo 42ms → 32ms (-24%), ReEnable 38ms → 29ms (-24%) Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 173efe2e6a72 --- cmd/entire/cli/bench_enable_test.go | 4 +++ cmd/entire/cli/strategy/hooks.go | 44 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/bench_enable_test.go b/cmd/entire/cli/bench_enable_test.go index c98655444..b66b2b1dc 100644 --- a/cmd/entire/cli/bench_enable_test.go +++ b/cmd/entire/cli/bench_enable_test.go @@ -8,6 +8,7 @@ import ( "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 @@ -29,6 +30,7 @@ func BenchmarkEnableCommand(b *testing.B) { b.Fatalf("chdir: %v", err) } paths.ClearRepoRootCache() + strategy.ClearHooksDirCache() b.StartTimer() w := &bytes.Buffer{} @@ -43,6 +45,7 @@ func BenchmarkEnableCommand(b *testing.B) { repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{}) b.Chdir(repo.Dir) paths.ClearRepoRootCache() + strategy.ClearHooksDirCache() // First enable to set up everything w := &bytes.Buffer{} @@ -54,6 +57,7 @@ func BenchmarkEnableCommand(b *testing.B) { for b.Loop() { b.StopTimer() paths.ClearRepoRootCache() + strategy.ClearHooksDirCache() b.StartTimer() w.Reset() diff --git a/cmd/entire/cli/strategy/hooks.go b/cmd/entire/cli/strategy/hooks.go index 6457aac29..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. From 25067f82dc0b38e1e9249154d87fd42f636f0da3 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:22:21 -0800 Subject: [PATCH 04/11] use cached paths.RepoRoot() in OpenRepository() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRepository() called GetWorktreePath() which spawns `git rev-parse --show-toplevel` without caching. paths.RepoRoot() runs the same command but with CWD-keyed caching, and the cache is already warm by the time OpenRepository() runs on the enable path. Benchmark: NewRepo 32ms → 22ms (-31%), ReEnable 29ms → 19ms (-34%) Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: c60f1e4ff18d --- cmd/entire/cli/strategy/common.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 11ef4cb17..185386663 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -558,9 +558,8 @@ 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() + // Use cached repo root (same git command as GetWorktreePath but with CWD-keyed caching) + 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) From fb764dae59d64c1d384b12e77785fd614da10cf4 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:24:37 -0800 Subject: [PATCH 05/11] comment clean up Entire-Checkpoint: 02dce46af028 --- cmd/entire/cli/strategy/common.go | 1 - cpu_enable.prof | Bin 0 -> 10335 bytes 2 files changed, 1 deletion(-) create mode 100644 cpu_enable.prof diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 185386663..ecc5b5775 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -558,7 +558,6 @@ 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) { - // Use cached repo root (same git command as GetWorktreePath but with CWD-keyed caching) repoRoot, err := paths.RepoRoot() if err != nil { // Fallback to current directory if git command fails diff --git a/cpu_enable.prof b/cpu_enable.prof new file mode 100644 index 0000000000000000000000000000000000000000..2c36bb085ddc25f7db580a9bd2ecadb4a42bd90f GIT binary patch literal 10335 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 literal 0 HcmV?d00001 From 16e1657ae1f7493f7ae03340f77bfcbe95251db8 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:34:27 -0800 Subject: [PATCH 06/11] fix bench:compare to aggregate all runs and restore git state on interrupt Two fixes: - Python parser now collects all N runs per benchmark and reports the median instead of silently discarding N-1 data points - EXIT trap tracks git state (branch, stash) and restores on Ctrl+C or any error, preventing the user from being stranded on the wrong branch with stashed changes Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: e3d38ff20114 --- mise.toml | 104 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 75 insertions(+), 29 deletions(-) diff --git a/mise.toml b/mise.toml index 2520439b9..3cd5292c6 100644 --- a/mise.toml +++ b/mise.toml @@ -188,7 +188,19 @@ if [ "$current_branch" = "$BASE_REF" ]; then fi tmpdir=$(mktemp -d) -trap 'rm -rf "$tmpdir"' EXIT +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 run_bench() { local label="$1" out="$2" @@ -211,7 +223,6 @@ if ! run_bench "$current_branch" "$new_out"; then fi # Stash if needed, switch to base, run, switch back -has_changes=false if ! git diff --quiet || ! git diff --cached --quiet; then has_changes=true git stash push -q -m "bench:compare auto-stash" @@ -220,35 +231,62 @@ fi old_out="$tmpdir/old.txt" 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 results only:" echo "" git checkout "$current_branch" --quiet 2>/dev/null - [ "$has_changes" = true ] && git stash pop --quiet - - # Pretty-print single-branch results - printf "%-40s %12s %12s %12s\n" "Benchmark" "ms/op" "B/op" "allocs/op" - printf "%-40s %12s %12s %12s\n" "----------------------------------------" "--------" "--------" "---------" - grep '^Benchmark' "$new_out" | while read -r name iters nsop _ bop _ aop _; do - ms=$(echo "scale=2; $nsop / 1000000" | bc) - printf "%-40s %10s ms %12s %12s\n" "$name" "$ms" "$bop" "$aop" - done + on_base_ref=false + [ "$has_changes" = true ] && git stash pop --quiet && has_changes=false + + # Pretty-print single-branch results (aggregate median across runs) + python3 - "$new_out" <<'PYEOF' +import sys, statistics +from collections import defaultdict + +results = defaultdict(lambda: {"ns": [], "bop": [], "allocs": []}) +for line in open(sys.argv[1]): + if not line.startswith("Benchmark"): + continue + parts = line.split() + if len(parts) < 8: + continue + try: + name = parts[0] + results[name]["ns"].append(float(parts[2])) + results[name]["bop"].append(int(parts[4])) + results[name]["allocs"].append(int(parts[6])) + except (ValueError, IndexError): + continue + +print(f" {'Benchmark':<43} {'ms/op':>8} {'B/op':>8} {'allocs/op':>9}") +print(f" {'─'*43} {'─'*8} {'─'*8} {'─'*9}") +for name, v in results.items(): + short = name.replace("BenchmarkEnableCommand/", "").replace("Benchmark", "") + ms = statistics.median(v["ns"]) / 1e6 + bop = int(statistics.median(v["bop"])) + allocs = int(statistics.median(v["allocs"])) + n = len(v["ns"]) + print(f" {short:<43} {ms:>6.1f}ms {bop:>8} {allocs:>9} (n={n})") +PYEOF exit 0 fi git checkout "$current_branch" --quiet 2>/dev/null -[ "$has_changes" = true ] && git stash pop --quiet +on_base_ref=false +[ "$has_changes" = true ] && git stash pop --quiet && has_changes=false -# Both branches have results — show comparison table +# Both branches have results — show comparison table (median of all runs) echo "" python3 - "$old_out" "$new_out" "$BASE_REF" "$current_branch" <<'PYEOF' -import sys, re +import sys, statistics +from collections import defaultdict old_file, new_file, base_name, curr_name = sys.argv[1:5] def parse_benchmarks(path): - results = {} + raw = defaultdict(lambda: {"ns": [], "bop": [], "allocs": []}) for line in open(path): if not line.startswith("Benchmark"): continue @@ -257,39 +295,47 @@ def parse_benchmarks(path): continue try: name = parts[0] - ns = float(parts[2]) - bop = int(parts[4]) - allocs = int(parts[6]) - results[name] = (ns / 1e6, bop, allocs) + raw[name]["ns"].append(float(parts[2])) + raw[name]["bop"].append(int(parts[4])) + raw[name]["allocs"].append(int(parts[6])) except (ValueError, IndexError): continue - return results + return { + name: ( + statistics.median(v["ns"]) / 1e6, + int(statistics.median(v["bop"])), + int(statistics.median(v["allocs"])), + len(v["ns"]), + ) + for name, v in raw.items() + } old = parse_benchmarks(old_file) new = parse_benchmarks(new_file) all_names = list(dict.fromkeys(list(new.keys()) + list(old.keys()))) -# Header -print(f"{'Benchmark':<45} {'ms/op':>18} {'B/op':>18} {'allocs/op':>18}") -print(f"{'':─<45}─{'':─<18}─{'':─<18}─{'':─<18}") +print(f" {'Benchmark':<43} {'ms/op':>18} {'B/op':>18} {'allocs/op':>18}") +print(f" {'─'*43}─{'─'*18}─{'─'*18}─{'─'*18}") + +def fmt_delta(d): + return f"({d:+.1f}%)" for name in all_names: short = name.replace("BenchmarkEnableCommand/", "").replace("Benchmark", "") if name in old and name in new: - o_ms, o_b, o_a = old[name] - n_ms, n_b, n_a = new[name] + o_ms, o_b, o_a, o_n = old[name] + n_ms, n_b, n_a, n_n = new[name] d_ms = ((n_ms - o_ms) / o_ms * 100) if o_ms > 0 else 0 d_b = ((n_b - o_b) / o_b * 100) if o_b > 0 else 0 d_a = ((n_a - o_a) / o_a * 100) if o_a > 0 else 0 - def fmt_delta(d): - return f"({d:+.1f}%)" print(f" {short:<43} {o_ms:>6.1f} → {n_ms:>6.1f} {fmt_delta(d_ms):>7} {o_b:>7} → {n_b:>7} {fmt_delta(d_b):>7} {o_a:>5} → {n_a:>5} {fmt_delta(d_a):>7}") elif name in new: - n_ms, n_b, n_a = new[name] + n_ms, n_b, n_a, n_n = new[name] print(f" {short:<43} {'':>6} {n_ms:>6.1f} {'(new)':>7} {'':>7} {n_b:>7} {'(new)':>7} {'':>5} {n_a:>5} {'(new)':>7}") +n = next((v[3] for v in new.values()), 0) print() -print(f" base: {base_name} current: {curr_name}") +print(f" base: {base_name} current: {curr_name} (median of {n} runs)") PYEOF """ From e365255166c2635160815bd0e77f7cd99ff0a077 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:41:46 -0800 Subject: [PATCH 07/11] add test for InstallGitHook localDev command prefix Verifies that localDev=true generates hooks with "go run" prefix and localDev=false generates hooks with "entire" prefix. Also tests that switching from localDev=true to false correctly updates hook contents. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 4c4d31f36ce3 --- cmd/entire/cli/strategy/hooks_test.go | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cmd/entire/cli/strategy/hooks_test.go b/cmd/entire/cli/strategy/hooks_test.go index c8877d7fc..489e53019 100644 --- a/cmd/entire/cli/strategy/hooks_test.go +++ b/cmd/entire/cli/strategy/hooks_test.go @@ -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() From 23e91a9810e721152e1ae72665868fc2be355f88 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 22:53:36 -0800 Subject: [PATCH 08/11] simplify bench:compare to use benchstat instead of custom Python Replaces the 130-line bash+python bench:compare script with a clean ~50-line script that delegates to benchstat (Go's standard benchmark comparison tool). Handles proper statistical aggregation, confidence intervals, and p-values out of the box. Requires: go install golang.org/x/perf/cmd/benchstat@latest Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 8cf6084d566c --- mise.toml | 121 +++++++++--------------------------------------------- 1 file changed, 20 insertions(+), 101 deletions(-) diff --git a/mise.toml b/mise.toml index 3cd5292c6..1717ef8ce 100644 --- a/mise.toml +++ b/mise.toml @@ -170,7 +170,7 @@ echo "Memory profile saved to mem.prof. View with: go tool pprof -http=:8080 mem ''' [tasks."bench:compare"] -description = "Compare benchmarks between current branch and base ref" +description = "Compare benchmarks between current branch and base ref (requires benchstat: go install golang.org/x/perf/cmd/benchstat@latest)" run = """ #!/usr/bin/env bash set -euo pipefail @@ -181,6 +181,11 @@ BENCH_COUNT="${BENCH_COUNT:-6}" BENCH_TIMEOUT="${BENCH_TIMEOUT:-10m}" 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." @@ -202,27 +207,28 @@ cleanup() { } 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="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" "$BENCH_PKG" 2>/dev/null \ - | grep -E '^(Benchmark|goos:|goarch:|pkg:|cpu:)' > "$out" - local count - count=$(grep -c '^Benchmark' "$out" || true) - if [ "$count" -eq 0 ]; then - echo " ERROR: no benchmark results captured. Does the benchmark exist on this branch?" + | 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 $count benchmark lines" + echo " captured $n result lines" } -# Run on current branch first +# Run on current branch new_out="$tmpdir/new.txt" if ! run_bench "$current_branch" "$new_out"; then exit 1 fi -# Stash if needed, switch to base, run, switch back +# 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" @@ -232,44 +238,15 @@ old_out="$tmpdir/old.txt" 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 results only:" - 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 - - # Pretty-print single-branch results (aggregate median across runs) - python3 - "$new_out" <<'PYEOF' -import sys, statistics -from collections import defaultdict - -results = defaultdict(lambda: {"ns": [], "bop": [], "allocs": []}) -for line in open(sys.argv[1]): - if not line.startswith("Benchmark"): - continue - parts = line.split() - if len(parts) < 8: - continue - try: - name = parts[0] - results[name]["ns"].append(float(parts[2])) - results[name]["bop"].append(int(parts[4])) - results[name]["allocs"].append(int(parts[6])) - except (ValueError, IndexError): - continue - -print(f" {'Benchmark':<43} {'ms/op':>8} {'B/op':>8} {'allocs/op':>9}") -print(f" {'─'*43} {'─'*8} {'─'*8} {'─'*9}") -for name, v in results.items(): - short = name.replace("BenchmarkEnableCommand/", "").replace("Benchmark", "") - ms = statistics.median(v["ns"]) / 1e6 - bop = int(statistics.median(v["bop"])) - allocs = int(statistics.median(v["allocs"])) - n = len(v["ns"]) - print(f" {short:<43} {ms:>6.1f}ms {bop:>8} {allocs:>9} (n={n})") -PYEOF + echo "" + benchstat "$new_out" exit 0 fi @@ -277,66 +254,8 @@ git checkout "$current_branch" --quiet 2>/dev/null on_base_ref=false [ "$has_changes" = true ] && git stash pop --quiet && has_changes=false -# Both branches have results — show comparison table (median of all runs) echo "" -python3 - "$old_out" "$new_out" "$BASE_REF" "$current_branch" <<'PYEOF' -import sys, statistics -from collections import defaultdict - -old_file, new_file, base_name, curr_name = sys.argv[1:5] - -def parse_benchmarks(path): - raw = defaultdict(lambda: {"ns": [], "bop": [], "allocs": []}) - for line in open(path): - if not line.startswith("Benchmark"): - continue - parts = line.split() - if len(parts) < 8: - continue - try: - name = parts[0] - raw[name]["ns"].append(float(parts[2])) - raw[name]["bop"].append(int(parts[4])) - raw[name]["allocs"].append(int(parts[6])) - except (ValueError, IndexError): - continue - return { - name: ( - statistics.median(v["ns"]) / 1e6, - int(statistics.median(v["bop"])), - int(statistics.median(v["allocs"])), - len(v["ns"]), - ) - for name, v in raw.items() - } - -old = parse_benchmarks(old_file) -new = parse_benchmarks(new_file) -all_names = list(dict.fromkeys(list(new.keys()) + list(old.keys()))) - -print(f" {'Benchmark':<43} {'ms/op':>18} {'B/op':>18} {'allocs/op':>18}") -print(f" {'─'*43}─{'─'*18}─{'─'*18}─{'─'*18}") - -def fmt_delta(d): - return f"({d:+.1f}%)" - -for name in all_names: - short = name.replace("BenchmarkEnableCommand/", "").replace("Benchmark", "") - if name in old and name in new: - o_ms, o_b, o_a, o_n = old[name] - n_ms, n_b, n_a, n_n = new[name] - d_ms = ((n_ms - o_ms) / o_ms * 100) if o_ms > 0 else 0 - d_b = ((n_b - o_b) / o_b * 100) if o_b > 0 else 0 - d_a = ((n_a - o_a) / o_a * 100) if o_a > 0 else 0 - print(f" {short:<43} {o_ms:>6.1f} → {n_ms:>6.1f} {fmt_delta(d_ms):>7} {o_b:>7} → {n_b:>7} {fmt_delta(d_b):>7} {o_a:>5} → {n_a:>5} {fmt_delta(d_a):>7}") - elif name in new: - n_ms, n_b, n_a, n_n = new[name] - print(f" {short:<43} {'':>6} {n_ms:>6.1f} {'(new)':>7} {'':>7} {n_b:>7} {'(new)':>7} {'':>5} {n_a:>5} {'(new)':>7}") - -n = next((v[3] for v in new.values()), 0) -print() -print(f" base: {base_name} current: {curr_name} (median of {n} runs)") -PYEOF +benchstat "$old_out" "$new_out" """ [tasks."test:e2e"] From 4663719af14a2a1a061b645c52f11776a9736220 Mon Sep 17 00:00:00 2001 From: evisdren Date: Sat, 21 Feb 2026 23:02:06 -0800 Subject: [PATCH 09/11] use branch names as benchstat column headers Name output files after branches (without .txt extension) and cd into the tmpdir before calling benchstat, so columns show "main" and the branch name instead of full /var/folders/... paths. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: a0a1fae188cb --- mise.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mise.toml b/mise.toml index 1717ef8ce..30ba21511 100644 --- a/mise.toml +++ b/mise.toml @@ -222,8 +222,8 @@ run_bench() { echo " captured $n result lines" } -# Run on current branch -new_out="$tmpdir/new.txt" +# 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 @@ -234,7 +234,7 @@ if ! git diff --quiet || ! git diff --cached --quiet; then git stash push -q -m "bench:compare auto-stash" fi -old_out="$tmpdir/old.txt" +old_out="$tmpdir/$BASE_REF" echo "" git checkout "$BASE_REF" --quiet 2>/dev/null on_base_ref=true @@ -246,7 +246,7 @@ if ! run_bench "$BASE_REF" "$old_out"; then on_base_ref=false [ "$has_changes" = true ] && git stash pop --quiet && has_changes=false echo "" - benchstat "$new_out" + (cd "$tmpdir" && benchstat "$(basename "$new_out")") exit 0 fi @@ -255,7 +255,7 @@ on_base_ref=false [ "$has_changes" = true ] && git stash pop --quiet && has_changes=false echo "" -benchstat "$old_out" "$new_out" +(cd "$tmpdir" && benchstat "$(basename "$old_out")" "$(basename "$new_out")") """ [tasks."test:e2e"] From 0398639c0606791008f85cfd4a637f263bf92a67 Mon Sep 17 00:00:00 2001 From: evisdren Date: Mon, 23 Feb 2026 16:49:43 -0800 Subject: [PATCH 10/11] move tasks.bench compare to mise-tasks Entire-Checkpoint: 95411a0c4307 --- cmd/entire/cli/benchutil/benchutil.go | 50 ++++ .../cli/integration_test/hook_bench_test.go | 250 ++++++++++++++++++ mise-tasks/bench/compare | 85 ++++++ mise-tasks/bench/cpu | 8 + mise-tasks/bench/mem | 8 + mise.toml | 139 ---------- 6 files changed, 401 insertions(+), 139 deletions(-) create mode 100644 cmd/entire/cli/integration_test/hook_bench_test.go create mode 100755 mise-tasks/bench/compare create mode 100755 mise-tasks/bench/cpu create mode 100755 mise-tasks/bench/mem 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/mise-tasks/bench/compare b/mise-tasks/bench/compare new file mode 100755 index 000000000..205109734 --- /dev/null +++ b/mise-tasks/bench/compare @@ -0,0 +1,85 @@ +#!/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}" +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="$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 30ba21511..ea69f7e83 100644 --- a/mise.toml +++ b/mise.toml @@ -119,145 +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 base ref (requires benchstat: go install golang.org/x/perf/cmd/benchstat@latest)" -run = """ -#!/usr/bin/env bash -set -euo pipefail - -BENCH_PATTERN="${BENCH_PATTERN:-.}" -BENCH_PKG="${BENCH_PKG:-./...}" -BENCH_COUNT="${BENCH_COUNT:-6}" -BENCH_TIMEOUT="${BENCH_TIMEOUT:-10m}" -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="$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")") -""" - [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 From fcaf2efa055ab158b99352131d19b47d65f7a294 Mon Sep 17 00:00:00 2001 From: evisdren Date: Mon, 23 Feb 2026 16:51:59 -0800 Subject: [PATCH 11/11] add tags Entire-Checkpoint: dda52f409b9d --- mise-tasks/bench/compare | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mise-tasks/bench/compare b/mise-tasks/bench/compare index 205109734..c8f282cc4 100755 --- a/mise-tasks/bench/compare +++ b/mise-tasks/bench/compare @@ -6,6 +6,7 @@ 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 @@ -38,7 +39,7 @@ trap cleanup EXIT run_bench() { local label="$1" out="$2" echo "=== Benchmarking: $label ===" - go test -bench="$BENCH_PATTERN" -benchmem -run='^$' -count="$BENCH_COUNT" -timeout="$BENCH_TIMEOUT" "$BENCH_PKG" 2>/dev/null \ + 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)