Current section
Files
Jump to
Current section
Files
lib/check/modified_tests.ex
defmodule CheckEscript.ModifiedTests do
@moduledoc """
Detects modified tests on the current branch vs base branch.
- If setup/setup_all/describe changed → runs the whole file
- Otherwise → runs only the specific modified test lines
"""
def run(test_opts \\ %{}) do
config = load_config()
base_branch = CheckEscript.Config.base_branch(config)
modified_files = get_modified_test_files(base_branch)
if Enum.empty?(modified_files) do
IO.puts("No modified test files on this branch")
{0, ""}
else
test_targets = Enum.flat_map(modified_files, &targets_for_file(&1, base_branch))
run_tests(test_targets, test_opts)
end
end
defp load_config do
case CheckEscript.Config.load() do
{:ok, config} -> config
_ -> %{}
end
end
defp get_modified_test_files(base_branch) do
case System.cmd("git", ["diff", "--name-only", "--diff-filter=d", "#{base_branch}...", "--", "test/**/*_test.exs"],
stderr_to_stdout: true
) do
{output, 0} ->
output |> String.split("\n", trim: true) |> Enum.filter(&File.exists?/1)
{error, _} ->
IO.puts([IO.ANSI.format([:red, "git diff failed: #{String.trim(error)}"])])
[]
end
end
defp targets_for_file(file, base_branch) do
changed_lines = get_changed_lines(file, base_branch)
if Enum.empty?(changed_lines) do
[]
else
if setup_or_describe_changed?(file, changed_lines) do
IO.puts([IO.ANSI.format([:yellow, " #{file} (setup/describe changed, running whole file)"])])
[file]
else
find_test_lines(file, changed_lines)
end
end
end
defp get_changed_lines(file, base_branch) do
{output, _status} = System.cmd("git", ["diff", "-U0", "#{base_branch}...", "--", file], stderr_to_stdout: true)
# Parse @@ hunk headers: @@ -old,count +new,count @@
~r/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|> Regex.scan(output)
|> Enum.flat_map(fn
[_, start, ""] -> [String.to_integer(start)]
[_, start, count] ->
s = String.to_integer(start)
c = String.to_integer(count)
if c == 0, do: [], else: Enum.to_list(s..(s + c - 1))
[_, start] -> [String.to_integer(start)]
end)
end
def setup_or_describe_changed?(file, changed_lines) do
lines = File.read!(file) |> String.split("\n")
Enum.any?(changed_lines, fn line_num ->
line = Enum.at(lines, line_num - 1, "")
String.match?(line, ~r/^\s*(setup|setup_all|describe)\b/)
end)
end
def find_test_lines(file, changed_lines) do
lines = File.read!(file) |> String.split("\n")
# For each changed line, walk up to find the nearest `test "` definition
test_line_numbers =
changed_lines
|> Enum.map(fn line_num -> find_enclosing_test(lines, line_num) end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
if Enum.empty?(test_line_numbers) do
# changed lines are outside any test block (e.g. module-level code)
IO.puts([IO.ANSI.format([:yellow, " #{file} (module-level change, running whole file)"])])
[file]
else
targets = Enum.map(test_line_numbers, fn line -> "#{file}:#{line}" end)
Enum.each(targets, fn t -> IO.puts([IO.ANSI.format([:cyan, " #{t}"])]) end)
targets
end
end
# Walk backwards from changed_line to find the nearest `test "` or `test(`
def find_enclosing_test(lines, line_num) do
line_num..1//-1
|> Enum.find(fn num ->
line = Enum.at(lines, num - 1, "")
String.match?(line, ~r/^\s*test\s+["(]/)
end)
end
defp run_tests(targets, test_opts) do
extra = extra_args(test_opts)
args = ["test" | targets] ++ extra
extra_str = Enum.join(extra, " ")
IO.puts([IO.ANSI.format([:cyan, "\nTest command: mix test [#{length(targets)} tests] #{extra_str}\n"])])
port = CheckEscript.Port.open("mix", args)
status = CheckEscript.Runner.stream_port_output(port)
{status, ""}
end
defp extra_args(opts) do
test_args = if opts[:test_args], do: String.split(opts[:test_args]), else: []
repeat = if opts[:repeat], do: ["--repeat-until-failure", to_string(opts[:repeat])], else: []
test_args ++ repeat
end
end