Skip to main content

lib/mix/tasks/verify.workspace_clean.ex

defmodule Mix.Tasks.Verify.WorkspaceClean do
  @moduledoc false

  use Mix.Task

  @shortdoc "Fails when publishable rulestead surfaces are dirty"
  @switches []

  @impl Mix.Task
  def run(args) do
    {_opts, _argv} = parse_args!(args)
    paths = scoped_paths()

    case verify_paths(paths) do
      :ok ->
        Mix.shell().info("workspace clean")

      {:dirty, dirty_paths} ->
        Mix.raise(
          "publishable workspace is dirty:\n" <>
            Enum.map_join(dirty_paths, "\n", &"  - #{&1}")
        )
    end
  end

  def scoped_paths(project_config \\ Mix.Project.config()) do
    project_config
    |> Keyword.get(:package, [])
    |> Keyword.get(:files, [])
    |> Kernel.++(["test"])
    |> Enum.uniq()
    |> Enum.sort()
  end

  def verify_paths(paths, cmd_runner \\ &default_status_cmd/1) do
    case cmd_runner.(paths) do
      {output, 0} ->
        verify_status(output)

      {output, status} ->
        Mix.raise("git status failed with exit #{status}: #{String.trim(output)}")
    end
  end

  def verify_status(output) do
    output
    |> String.split("\n", trim: true)
    |> Enum.map(&parse_status_line/1)
    |> Enum.reject(&is_nil/1)
    |> case do
      [] -> :ok
      dirty_paths -> {:dirty, dirty_paths}
    end
  end

  defp parse_args!(args) do
    {opts, argv, invalid} = OptionParser.parse(args, strict: @switches)

    case invalid do
      [] -> {opts, argv}
      [{flag, _value} | _rest] -> Mix.raise("unknown option: #{flag}")
    end
  end

  defp parse_status_line(line) do
    case Regex.run(~r/^(?:..)\s+(.+)$/, line, capture: :all_but_first) do
      [path] -> path
      _other -> nil
    end
  end

  defp default_status_cmd(paths) do
    System.cmd(
      "git",
      ["status", "--porcelain", "--untracked-files=all", "--"] ++ paths,
      cd: File.cwd!(),
      stderr_to_stdout: true
    )
  end
end