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 build directories from the local tree to cloned tree.
      * 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.

  This task **must** be run in the `test` environment, i.e. with
  `MIX_ENV=test`.  In order to perform step 5, it needs an accurate list of
  dependency build directories for the environment it will be testing in.
  (Also, running the task itself in the `test` environment ensures that all
  dependencies will have been built beforehand.)

  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
    ensure_test_env()
    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

  defp ensure_test_env() do
    case Mix.env() do
      :test ->
        :ok

      env ->
        Mix.raise(
          "Must be run in the `test` env (not `#{env}`).  " <>
            "Dependency caching will not work otherwise."
        )
    end
  end
end