Skip to main content

lib/mix/tasks/git_hoox.bench.ex

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