lib/graft/add.ex

defmodule Graft.Add do
  @moduledoc """
  Clone repositories into the workspace and optionally register them in
  `graft.exs`.

  Accepts a list of `owner/repo` strings, clones each to a directory named
  after the repo, verifies the clone, and optionally appends the new repo to
  the workspace manifest.
  """

  alias Graft.{Error, GitRemote, Manifest, Safety}

  @type result :: :ok | {:error, Error.t()}

  @doc """
  Clone `owner/repo` repositories into the workspace rooted at `root`.

  Returns a map of `owner/repo => result` for each requested repository.

  Options:
    * `:to_manifest` — append successfully-cloned repos to `graft.exs`
    * `:link` — suggest `mix graft.link.on` for any existing sibling that
      depends on the newly-added repo (not yet implemented)
  """
  @spec clone([String.t()], Path.t(), keyword()) :: %{String.t() => result}
  def clone(owner_repos, root, opts \\ []) do
    owner_repos
    |> Enum.map(&String.trim/1)
    |> Enum.reject(&(&1 == ""))
    |> Enum.map(&parse_owner_repo/1)
    |> Enum.reject(&is_nil/1)
    |> Enum.map(fn {owner_repo, repo_name} ->
      dest = Path.join(root, repo_name)

      result =
        with :ok <- Safety.valid_repo_name?(repo_name),
             :ok <- Safety.within_root?(dest, root),
             :ok <- verify_symlink_target(dest, root),
             :ok <- clone_repo("https://github.com/#{owner_repo}.git", dest),
             :ok <- verify_repo(dest),
             {:ok, manifest} <- maybe_update_manifest(owner_repo, repo_name, root, dest, opts) do
          if manifest do
            :ok
          else
            :ok
          end
        end

      {owner_repo, result}
    end)
    |> Map.new()
  end

  ## ─── Parse ────────────────────────────────────────────────────────

  defp parse_owner_repo(string) do
    case String.split(string, "/", parts: 2, trim: true) do
      [owner, repo] -> {"#{owner}/#{repo}", repo}
      _ -> nil
    end
  end

  ## ─── Clone ────────────────────────────────────────────────────────

  defp clone_repo(url, dest) do
    if File.dir?(dest) do
      verify_existing_remote(dest, url)
    else
      case System.cmd("git", ["clone", "--depth", "1", url, dest], stderr_to_stdout: true) do
        {_output, 0} ->
          :ok

        {output, status} ->
          {:error,
           Error.new(
             :clone_failed,
             "git clone exited with status #{status}: #{String.trim(output)}",
             %{url: url, dest: dest}
           )}
      end
    end
  end

  defp verify_existing_remote(dest, expected_url) do
    case System.cmd("git", ["-C", dest, "remote", "get-url", "origin"], stderr_to_stdout: true) do
      {output, 0} ->
        actual = String.trim(output)

        if GitRemote.same?(actual, expected_url) do
          :ok
        else
          {:error,
           Error.new(
             :clone_destination_exists,
             "Directory #{dest} already exists with different remote origin (#{actual})",
             %{dest: dest, expected: expected_url, actual: actual}
           )}
        end

      {output, status} ->
        {:error,
         Error.new(
           :clone_destination_exists,
           "Directory #{dest} exists but is not a valid git repository (status #{status}): #{String.trim(output)}",
           %{dest: dest}
         )}
    end
  end

  defp verify_symlink_target(dest, root) do
    case File.lstat(dest) do
      {:ok, %{type: :symlink}} ->
        case Safety.real_path(dest) do
          {:ok, resolved} ->
            Safety.within_root?(resolved, root)

          {:error, reason} ->
            {:error,
             Error.new(
               :runner_fence_violation,
               "Cannot resolve symlink #{dest}: #{inspect(reason)}"
             )}
        end

      _ ->
        :ok
    end
  end

  ## ─── Verify ───────────────────────────────────────────────────────

  defp verify_repo(dest) do
    mix_exs = Path.join(dest, "mix.exs")

    if File.regular?(mix_exs) do
      :ok
    else
      {:error,
       Error.new(
         :repo_not_elixir,
         "Cloned #{dest} but no mix.exs found — is this an Elixir project?",
         %{dest: dest}
       )}
    end
  end

  ## ─── Manifest ─────────────────────────────────────────────────────

  defp maybe_update_manifest(owner_repo, repo_name, root, _dest, opts) do
    if opts[:to_manifest] do
      name = String.to_atom(repo_name)
      origin = "https://github.com/#{owner_repo}.git"
      new_entry = %{name: name, path: repo_name, origin: origin}

      case Manifest.load(root) do
        {:ok, manifest} ->
          if Enum.any?(manifest.siblings, &(&1.name == name)) do
            {:ok, false}
          else
            write_manifest(
              manifest.source_path,
              manifest.root_declared,
              manifest.siblings ++ [new_entry]
            )
          end

        {:error, %{kind: :manifest_not_found}} ->
          path = Path.join(root, Manifest.filename())
          write_manifest(path, ".", [new_entry])

        {:error, err} ->
          {:error, err}
      end
    else
      {:ok, false}
    end
  end

  defp write_manifest(path, root_declared, siblings) do
    case Manifest.write(path, root_declared, siblings) do
      :ok -> {:ok, true}
      {:error, %Error{} = err} -> {:error, err}
    end
  end
end