lib/mix/task/monorepo.changes.ex

defmodule Mix.Tasks.Monorepo.Changes do
  @moduledoc """
  Shows the known changes that should lead to recompilation of a application.

  A change is detected by looking at `elixirc_paths` in each project,
  finding all elixir files matching and then lstating them.
  The results is compared to the values in `./.mix/monorepo.changes` (for each build).
  If any of the files changed then it's shown.

  The manifests for each compiler in the project can help in giving a
  dependency graph which can be used to decide which deps and apps needs
  recompilation.
  """

  @shortdoc "Show all the changes in monorepo, like `git status`"

  use Mix.Task

  @impl true
  def run([]), do: run([File.cwd!()])

  def run([path]) do
    repo = ExMonorepo.parse_repofile(List.first(ExMonorepo.repofiles(path)))
    applications = ExMonorepo.SCM.scan(exmonorepo: repo)

    projects = Enum.filter(scan_for_changes(repo), fn {_, changes} -> [] != changes end)

    IO.puts("Found #{length(projects)} applications with changes. ")

    for {app, [_ | _] = changes} <- projects do
      IO.puts("\n  :#{app} (path=#{Path.relative_to(applications[app], Path.expand(path))})")

      for file <- changes do
        IO.puts("      - #{Path.relative_to(file, Path.expand(path))}")
      end
    end

    :ok
  end

  @doc """
  Retrieve a keyword list of applications in monorepo with changes.
  """
  def scan_for_changes(repo) do
    applications = ExMonorepo.SCM.scan(exmonorepo: repo)
    # Get a map of app => {module, project}
    for {app, path} <- applications do
      Mix.Project.in_project(app, path, fn module ->
        project = module.project()
        # `["lib"]` is default as of elixir 1.15
        elixirc_paths = project[:elixirc_paths] || ["lib"]
        pattern = "{" <> Enum.join(elixirc_paths, ",") <> "}"
        sources = Path.wildcard(Path.join([path, pattern, "**/*.ex"]))
        all_manifests = manifests(project)

        # Checking manifests mtime. Assume the oldest mtime is the last
        # successfull compilation. This may not be correct, the compiler
        # may not have the manifest because it has not run or it may not
        # exist since there are no source files for the compiler.
        mtime = Enum.flat_map(all_manifests, &List.wrap(get_mtime(&1)))
        oldest = List.first(Enum.sort(mtime, DateTime))

        changes =
          if nil == oldest do
            sources
          else
            Enum.filter(sources, fn file ->
              mtime = get_mtime(file)
              nil == mtime or :gt != DateTime.compare(oldest, mtime)
            end)
          end

        {app, changes}
      end)
    end
  end

  defp get_mtime(file) do
    case File.lstat(file) do
      {:ok, %{mtime: mtime}} ->
        mtime =
          mtime
          |> :calendar.datetime_to_gregorian_seconds()
          |> DateTime.from_gregorian_seconds()

        mtime

      {:error, _} ->
        nil
    end
  end

  defp manifests(project) do
    compilers = Mix.Tasks.Compile.compilers(project)

    Enum.flat_map(compilers, fn compiler ->
      module = Mix.Task.get("compile.#{compiler}")

      if module && function_exported?(module, :manifests, 0) do
        module.manifests
      else
        []
      end
    end)
  end
end