lib/mix/task/git.test.ex

defmodule Mix.Tasks.Git.Test do
  @shortdoc "Run `mix test` using current git staged changes"
  @moduledoc """
  Runs `mix test` on the latest git commit, plus any staged changes.

  Performs the following steps:

    1. Creates a temporary directory (in the standard system location).

    2. Runs `git diff --cached` (in the local tree) to extract staged changes.

    3. Clones the local tree into the temporary directory.

    4. Applies the changes extracted in step 2.

    5. Symlinks dependencies from the local `deps` to the cloned `deps`.
      * This avoids needing to recompile dependencies, which aren't part of the check-in anyway.

    6. Runs `mix test`, capturing the output (both stdout and stderr).
      * If it passes, great!
      * If it fails, dumps the captured output and exits with a non-zero status.

    7. Cleans up the temporary directory on exit.

  This is intended to be used as part of a pre-commit hook, to help protect
  against forgetting to add new files, doing partial commits where some
  committed changes depend on changes not staged for commit, etc.

  Use `mix git.test.install` to install the default pre-commit hook.
  """

  use Mix.Task

  @doc false
  def run([]), do: git_test()

  def run(_) do
    Mix.raise("git.test does not accept arguments")
  end

  @doc false
  def git_test do
    cwd = File.cwd!()
    tmpdir = create_tmpdir()

    status("Getting local changes ...")
    diff_file = Path.join(tmpdir, "apply.patch")
    quiet_cmd("sh", ["-c", "git diff --cached --binary > \"#{diff_file}\""])

    status("Cloning tree ...")
    tree = Path.join(tmpdir, "tree")
    quiet_cmd("git", ["clone", cwd, tree])

    if (size = File.stat!(diff_file).size) > 0 do
      status("Applying changes (#{size} bytes) ...")
      quiet_cmd("git", ["apply", "../apply.patch"], cd: tree)
    end

    deps = ["deps" | dependency_build_dirs()]
    status("Linking #{Enum.count(deps)} dependencies ...")
    deps |> Enum.each(&symlink(&1, cwd, tree))

    status("Running tests ...")

    System.cmd("sh", ["-c", "exec 2>&1; MIX_ENV=test exec mix test --color"],
      cd: tree,
      into: IO.stream(:stdio, :line)
    )
    |> check_cmd_result("mix test")

    status("All tests passed.")
  end

  defp status(text) do
    IO.puts([IO.ANSI.light_green(), "* ", text, IO.ANSI.reset()])
  end

  defp create_tmpdir do
    {:ok, _started} = Application.ensure_all_started(:briefly)
    {:ok, tmpdir} = Briefly.create(directory: true)
    tmpdir
  end

  defp symlink(item, from, to) do
    source = Path.join(from, item)
    target = Path.join(to, item)

    Path.dirname(target) |> File.mkdir_p!()
    File.ln_s!(source, target)
  end

  defp dependency_build_dirs do
    lib = Path.join(Mix.Project.build_path(), "lib")

    lib
    |> File.ls!()
    |> Enum.map(&Path.join(lib, &1))
    |> Enum.filter(&File.dir?/1)
    |> List.delete(Mix.Project.app_path())
    |> Enum.map(&Path.relative_to_cwd/1)
  end

  defp quiet_cmd(bin, args, opts \\ []) do
    opts = Keyword.merge([stderr_to_stdout: true], opts)

    System.cmd(bin, args, opts)
    |> check_cmd_result([bin | args] |> Enum.join(" "))
  end

  defp check_cmd_result({_output, 0}, _cmd), do: :ok

  defp check_cmd_result({%IO.Stream{}, code}, cmd) do
    Mix.raise("Command #{inspect(cmd)} exited with status #{code}")
  end

  defp check_cmd_result({output, code}, cmd) do
    IO.puts([
      IO.ANSI.light_red(),
      "---OUTPUT---\n",
      output |> String.trim(),
      "\n---OUTPUT---",
      IO.ANSI.reset()
    ])

    Mix.raise("Command #{inspect(cmd)} exited with status #{code}")
  end
end