lib/exmonorepo.ex

defmodule ExMonorepo do
  @moduledoc """
  Enable searching a directory for nested set of dependencies.

  These are free form and coverted to a local path instead of using
  hex, git or similar to fetch it. The monorepo may optionally export
  all dependencies to archives using the command `mix monorepo.export`.

  ## Example

    defmodule TestProject.MixProject do
      use Mix.Project
      use ExMonorepo
    end

  ## Detecting monorepo

  A monorepo should have the file repo.exs to define it's configuration (if any).
  the content is a set of variables like `root`, `dist` which will be set in
  the project options.

  ## Using archives

  If `dist` option is used all the non-monorepo dependencies
  are exported to a `.ez` file tagged with app, version and checksum.
  When starting a mix command all the archives are added to path and
  may be used without recompiling.

  If the archive is deleted the dependency will revert to normal
  dependency handling.
  """

  defmacro __using__(_opts) do
    mod = __MODULE__

    quote do
      repo = List.first(unquote(mod).repofiles(__DIR__))

      cond do
        nil == repo ->
          raise RuntimeError,
                "fatal: not a monorepo, can't find repo.exs (or any parent up to mount point /)"

        nil != (binding = :persistent_term.get(ExMonorepo, nil)) ->
          IO.puts(
            " => Using cached config from #{binding[:source]}:\n#{inspect(binding, pretty: true)}\n\n"
          )

          Mix.SCM.prepend(ExMonorepo.SCM)

        true ->
          binding = unquote(mod).parse_repofile(repo)

          IO.puts(" => Found config #{repo}: #{inspect(binding, pretty: true)}\n\n")

          :persistent_term.put(ExMonorepo, binding)

          Mix.SCM.prepend(ExMonorepo.SCM)
      end
    end
  end

  @doc """
  Get all possible repofiles from `path` to filesystem root (`/`)
  """
  def repofiles(path) do
    Path.split(Path.expand(path))
    |> Enum.reduce(["/monorepo.exs"], fn p, [t | acc] ->
      [Path.join([Path.dirname(t), p, "monorepo.exs"]), t | acc]
    end)
    |> Enum.uniq()
    |> Enum.filter(&File.exists?/1)
  end

  @doc """
  Extract values from a repofile
  """
  def parse_repofile(file) do
    {_, binding} = Code.eval_file(file)

    binding =
      Enum.map(binding, fn e ->
        unquote(__MODULE__).map_config_item(e, Path.dirname(file))
      end)

    Keyword.put(binding, :source, file)
  end

  @doc """
  Expand common options

  Takes some variables supporting relative path and expands them according
  to the directory (typically the dirname of monorepo.exs)
  """
  def map_config_item(e, cwd \\ File.cwd!())
  def map_config_item({:repo, path}, cwd), do: {:repo, Path.expand(path, cwd)}
  def map_config_item({:dist, path}, cwd), do: {:dist, Path.expand(path, cwd)}
  def map_config_item(e, _cwd), do: e
end