defmodule Mix.Tasks.GitHoox.Bench do
@shortdoc "Benchmark each hook over N runs and print percentiles"
@moduledoc """
Run the configured hooks for a stage repeatedly and report timing
statistics per hook.
mix git_hoox.bench # pre_commit, 5 runs
mix git_hoox.bench --stage pre_push # different stage
mix git_hoox.bench --runs 20 # more samples
mix git_hoox.bench -s commit-msg -n 3 # short flags
Reads the resolved configuration just like a real hook invocation, so
any hook that needs files (e.g. `Format` filtering on staged Elixir
files) needs at least one matching file present for the timing to be
meaningful. Hooks that crash count against the `errors` column but
their duration still contributes to the percentiles.
"""
use Mix.Task
alias GitHoox.Bench
alias GitHoox.Config.Schema
@switches [stage: :string, runs: :integer]
@aliases [s: :stage, n: :runs]
@cols [
{"module", 36, :left},
{"runs", 5, :right},
{"errors", 6, :right},
{"p50", 9, :right},
{"p95", 9, :right},
{"max", 9, :right},
{"mean", 9, :right},
{"total", 9, :right}
]
@impl Mix.Task
@spec run([String.t()]) :: :ok
def run(argv) do
{opts, _, _} = OptionParser.parse(argv, switches: @switches, aliases: @aliases)
stage = parse_stage!(Keyword.get(opts, :stage, "pre_commit"))
runs = Keyword.get(opts, :runs, 5)
Mix.shell().info("Benchmarking #{stage} (#{runs} runs)...")
Mix.shell().info("")
stage
|> Bench.run(runs)
|> print()
:ok
end
defp parse_stage!(stage) do
case Schema.parse_stage(stage) do
{:ok, atom} -> atom
:error -> Mix.raise("Unknown git_hoox stage: #{stage}")
end
end
defp print([]) do
Mix.shell().info("(no hooks executed)")
end
defp print(results) do
Mix.shell().info(header())
Mix.shell().info(separator())
Enum.each(results, &print_row/1)
end
defp header do
Enum.map_join(@cols, " ", fn {label, w, _} -> String.pad_trailing(label, w) end)
end
defp separator do
Enum.map_join(@cols, " ", fn {_, w, _} -> String.duplicate("-", w) end)
end
defp print_row(r) do
[
inspect(r.module),
Integer.to_string(r.runs),
Integer.to_string(r.errors),
format_ms(r.p50_ms),
format_ms(r.p95_ms),
format_ms(r.max_ms),
format_ms(r.mean_ms),
format_ms(r.total_ms)
]
|> Enum.zip(@cols)
|> Enum.map_join(" ", fn {val, {_, w, align}} -> pad(val, w, align) end)
|> Mix.shell().info()
end
defp pad(s, w, :left), do: String.pad_trailing(s, w)
defp pad(s, w, :right), do: String.pad_leading(s, w)
defp format_ms(ms) when is_integer(ms), do: "#{ms}ms"
defp format_ms(ms) when is_float(ms), do: "#{Float.round(ms, 1)}ms"
end