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