15.9 KiB · text History b868907
defmodule GitGudWeb.RepoLive.Merge do
  @moduledoc """
  Resolve a conflicted three-way merge in the browser.

      /r/:owner/:name/merge?into=<ref>&from=<ref or sha>[&pr=<number>]

  Its own route rather than a tab on the pull request, because the other
  caller has no pull request: a branch that moved both here and
  upstream, which `GitGud.Repositories.sync_upstream/3` deliberately
  refuses to resolve on its own. Both arrive with the same two commits
  and leave with the same merge commit; only the back-link differs.

  The editing model is git's own. `into` is ours, `from` is theirs, and
  each conflicted file opens as the marked-up merge git produced —
  edit it down to what it should be, or take one side wholesale. A file
  is resolved when the operator says so, not when a heuristic says so,
  and the merge button stays disabled until every one of them is.
  """
  use GitGudWeb, :live_view

  alias GitGud.Git
  alias GitGud.Merges
  alias GitGud.Organizations
  alias GitGud.PullRequests
  alias GitGud.Repo
  alias GitGud.Repositories
  alias GitGud.Repositories.Storage
  alias GitGudWeb.ErrorMessage

  @impl true
  def mount(%{"owner" => owner, "name" => name} = params, _session, socket) do
    repo =
      Repositories.get_repository_by_path!(owner, name)
      |> GitGud.Repo.preload([:owner, :organization])

    user = socket.assigns.current_scope && socket.assigns.current_scope.user

    if can_merge?(user, repo) do
      {:ok,
       socket
       |> assign(:repo, repo)
       |> assign(:handle, Storage.repo_handle(repo))
       |> assign(:into, params["into"])
       |> assign(:from, params["from"])
       |> assign(:pr, pull_request(repo, params["pr"]))
       |> assign(:analysis, nil)
       |> assign(:error, nil)
       |> assign(:resolutions, %{})
       |> assign(:selected, nil)
       |> assign(:message, nil)
       |> assign(:merging, false)
       |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
       |> assign(:page_title, "Resolve conflicts — #{owner}/#{name}")
       |> analyze()}
    else
      {:ok,
       socket
       |> put_flash(:error, "You don't have permission to merge into this repository.")
       |> redirect(to: ~p"/r/#{owner}/#{name}")}
    end
  end

  # Writing to a branch is the same bar as administering the repo,
  # matching `sync_fork/4` and the PR merge button.
  defp can_merge?(nil, _repo), do: false
  defp can_merge?(user, %{owner_id: uid, organization_id: nil}), do: user.id == uid

  defp can_merge?(user, %{organization: %{} = org}), do: Organizations.org_admin?(org, user)
  defp can_merge?(_, _), do: false

  defp pull_request(_repo, nil), do: nil

  # A bad `pr` is context we simply don't show; the merge itself doesn't
  # depend on it.
  defp pull_request(repo, number) do
    with {n, ""} <- Integer.parse(number),
         %{} = pr <- Repo.get_by(PullRequests.PullRequest, repository_id: repo.id, number: n) do
      pr
    else
      _ -> nil
    end
  end

  # The merge itself is git work on request-path timescales for a big
  # repo, so it runs off the LiveView process like the upstream fetch
  # does.
  defp analyze(socket) do
    %{repo: repo, into: into, from: from} = socket.assigns

    with {:ok, ours} <- resolve_side(repo, into),
         {:ok, theirs} <- resolve_side(repo, from) do
      start_async(socket, :analyze, fn -> Merges.analyze(repo, ours, theirs) end)
    else
      {:error, _} ->
        assign(socket, :error, "Couldn't resolve #{inspect(into)} and #{inspect(from)}.")
    end
  end

  defp resolve_side(_repo, nil), do: {:error, :missing}
  defp resolve_side(repo, ref), do: Repositories.resolve(repo, ref)

  @impl true
  def handle_async(:analyze, {:ok, {:ok, analysis}}, socket) do
    {:noreply,
     socket
     |> assign(:analysis, analysis)
     |> assign(:selected, analysis.files |> List.first() |> then(&(&1 && &1.path)))}
  end

  def handle_async(:analyze, {:ok, {:error, reason}}, socket),
    do: {:noreply, assign(socket, :error, describe(reason))}

  def handle_async(:analyze, {:exit, reason}, socket),
    do:
      {:noreply,
       assign(socket, :error, "The merge check crashed: #{ErrorMessage.describe(reason)}")}

  def handle_async(:merge, {:ok, {:ok, %{commit: commit}}}, socket) do
    {:noreply,
     socket
     |> assign(:merging, false)
     |> put_flash(:info, "Merged as #{Git.short(commit)}.")
     |> push_navigate(to: done_path(socket))}
  end

  def handle_async(:merge, {:ok, {:error, reason}}, socket) do
    {:noreply,
     socket
     |> assign(:merging, false)
     |> put_flash(:error, describe(reason))}
  end

  def handle_async(:merge, {:exit, reason}, socket) do
    {:noreply,
     socket
     |> assign(:merging, false)
     |> put_flash(:error, "The merge crashed: #{ErrorMessage.describe(reason)}")}
  end

  defp done_path(%{assigns: %{pr: %{number: number}, handle: handle, repo: repo}}),
    do: ~p"/r/#{handle}/#{repo.name}/pulls/#{number}"

  defp done_path(%{assigns: %{handle: handle, repo: repo}}),
    do: ~p"/r/#{handle}/#{repo.name}"

  @impl true
  def handle_event("select", %{"path" => path}, socket),
    do: {:noreply, assign(socket, :selected, path)}

  def handle_event("take", %{"path" => path, "side" => side}, socket)
      when side in ~w(ours theirs) do
    {:noreply, put_resolution(socket, path, String.to_existing_atom(side))}
  end

  def handle_event("edit", %{"path" => path, "text" => text}, socket),
    do: {:noreply, put_resolution(socket, path, {:text, text})}

  def handle_event("reset", %{"path" => path}, socket) do
    {:noreply, assign(socket, :resolutions, Map.delete(socket.assigns.resolutions, path))}
  end

  def handle_event("set_message", %{"message" => message}, socket),
    do: {:noreply, assign(socket, :message, message)}

  def handle_event(event, params, socket) when event in ~w(merge),
    do:
      GitGudWeb.RepoAdmin.with_admin(socket, fn -> handle_admin_event(event, params, socket) end)

  defp handle_admin_event("merge", _params, socket) do
    %{repo: repo, analysis: analysis, resolutions: resolutions, into: into} = socket.assigns

    opts =
      [
        ref: "refs/heads/" <> into,
        message: blank_to_nil(socket.assigns.message),
        author: author(socket),
        actor_id: socket.assigns.current_scope && socket.assigns.current_scope.user.id
      ]
      |> maybe_put_pr(socket.assigns.pr)

    {:noreply,
     socket
     |> assign(:merging, true)
     |> start_async(:merge, fn -> Merges.resolve(repo, analysis, resolutions, opts) end)}
  end

  defp maybe_put_pr(opts, %{id: id}), do: Keyword.put(opts, :pull_request_id, id)
  defp maybe_put_pr(opts, _), do: opts

  defp author(%{assigns: %{current_scope: %{user: %{email: email}}}}) do
    {email |> String.split("@") |> hd(), email, DateTime.utc_now(:second)}
  end

  defp author(_), do: nil

  defp put_resolution(socket, path, resolution),
    do: assign(socket, :resolutions, Map.put(socket.assigns.resolutions, path, resolution))

  defp blank_to_nil(nil), do: nil

  defp blank_to_nil(s) do
    case String.trim(s) do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp describe({:unresolved, paths}),
    do: "Still unresolved: #{Enum.join(paths, ", ")}."

  defp describe({:conflict_markers, paths}),
    do: "Conflict markers left in #{Enum.join(paths, ", ")} — resolve them before merging."

  defp describe(:concurrent_update),
    do: "The branch moved while you were resolving. Reload to start from where it is now."

  defp describe(:no_merge_base),
    do: "These two have no common history, so there is nothing to merge."

  defp describe({:status_checks, missing, failing}),
    do:
      "Required status checks haven't passed: " <>
        Enum.join(
          Enum.map(missing, &"#{&1} missing") ++ Enum.map(failing, &"#{&1} not passing"),
          ", "
        ) <>
        "."

  defp describe({:protected, reason}),
    do: "Branch protection refused this: #{ErrorMessage.describe(reason)}"

  defp describe(reason), do: "Merge failed: #{ErrorMessage.describe(reason)}"

  # ── rendering ────────────────────────────────────────────────────────

  defp resolved?(resolutions, path), do: Map.has_key?(resolutions, path)

  defp all_resolved?(%{files: files}, resolutions),
    do: Enum.all?(files, &resolved?(resolutions, &1.path))

  defp file_for(analysis, path), do: Enum.find(analysis.files, &(&1.path == path))

  # What the editor shows: the operator's own edit if they made one, the
  # chosen side's text if they took one, otherwise git's marked-up merge.
  defp editor_text(file, resolutions) do
    case Map.get(resolutions, file.path) do
      {:text, text} -> text
      :ours -> side_text(file.ours)
      :theirs -> side_text(file.theirs)
      nil -> (file.merged && file.merged.text) || ""
    end
  end

  defp side_text(nil), do: ""
  defp side_text(%{text: nil}), do: ""
  defp side_text(%{text: text}), do: text

  defp resolution_label(resolutions, path) do
    case Map.get(resolutions, path) do
      :ours -> "ours"
      :theirs -> "theirs"
      {:text, _} -> "edited"
      nil -> nil
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="space-y-4">
        <GitGudWeb.RepoLive.Header.header
          repo={@repo}
          handle={@handle}
          current_scope={@current_scope}
          has_packages?={@has_packages?}
          can_admin?={@can_admin?}
          latest_run={@latest_run}
          open_pulls={@open_pulls}
          security_alerts={assigns[:security_alerts]}
        />

        <div class="flex flex-wrap items-baseline gap-2">
          <h1 class="text-xl font-semibold">Resolve conflicts</h1>
          <p class="text-sm opacity-70 font-mono">{@from} → {@into}</p>
          <.link
            :if={@pr}
            navigate={~p"/r/#{@handle}/#{@repo.name}/pulls/#{@pr.number}"}
            class="link link-hover text-sm ml-auto"
          >
            back to #{@pr.number}
          </.link>
        </div>

        <div :if={@error} class="alert alert-error text-sm">
          <.icon name="hero-exclamation-triangle" class="w-5 h-5" />
          <span>{@error}</span>
        </div>

        <div :if={is_nil(@analysis) and is_nil(@error)} class="text-sm opacity-70">
          Working out what conflicts…
        </div>

        <div :if={@analysis && @analysis.status == :clean} class="alert text-sm">
          <.icon name="hero-check-circle" class="w-5 h-5" />
          <span>
            These merge cleanly — nothing to resolve here.
          </span>
        </div>

        <div
          :if={@analysis && @analysis.status == :conflict}
          class="grid gap-4 lg:grid-cols-[16rem_1fr]"
        >
          <aside class="space-y-1">
            <button
              :for={file <- @analysis.files}
              type="button"
              phx-click="select"
              phx-value-path={file.path}
              class={[
                "w-full text-left px-2 py-1 rounded text-sm font-mono truncate",
                @selected == file.path && "bg-base-300",
                @selected != file.path && "hover:bg-base-200"
              ]}
            >
              <span class={[
                "inline-block w-4",
                resolved?(@resolutions, file.path) && "text-success"
              ]}>
                {if resolved?(@resolutions, file.path), do: "✓", else: "•"}
              </span>
              {file.path}
              <span :if={label = resolution_label(@resolutions, file.path)} class="opacity-50">
                ({label})
              </span>
            </button>
          </aside>

          <section :if={file = @selected && file_for(@analysis, @selected)} class="space-y-3">
            <div class="flex flex-wrap items-center gap-2">
              <span class="font-mono text-sm">{file.path}</span>
              <span class="badge badge-sm badge-ghost">{file.type}</span>
              <div class="ml-auto flex gap-2">
                <button
                  type="button"
                  phx-click="take"
                  phx-value-path={file.path}
                  phx-value-side="ours"
                  class="btn btn-xs btn-outline"
                >
                  Take {@into}
                </button>
                <button
                  type="button"
                  phx-click="take"
                  phx-value-path={file.path}
                  phx-value-side="theirs"
                  class="btn btn-xs btn-outline"
                >
                  Take {@from}
                </button>
                <button
                  type="button"
                  phx-click="reset"
                  phx-value-path={file.path}
                  class="btn btn-xs btn-ghost"
                >
                  Reset
                </button>
              </div>
            </div>

            <p :if={is_nil(file.base)} class="text-xs opacity-70">
              Both sides added this file, so there is no common version to merge from.
            </p>
            <p :if={is_nil(file.ours) or is_nil(file.theirs)} class="text-xs opacity-70">
              One side deleted this file and the other changed it. Taking the deleting side
              removes it; taking the other keeps its version.
            </p>

            <div :if={file.binary?} class="alert alert-warning text-sm">
              <.icon name="hero-exclamation-triangle" class="w-5 h-5" />
              <span>
                This file isn't text, so it can't be edited here — take one side or the other.
              </span>
            </div>

            <form :if={not file.binary?} phx-change="edit" phx-debounce="300">
              <input type="hidden" name="path" value={file.path} />
              <textarea
                name="text"
                rows="24"
                spellcheck="false"
                class="textarea textarea-bordered w-full font-mono text-xs leading-relaxed"
              >{editor_text(file, @resolutions)}</textarea>
            </form>

            <details :if={not file.binary?} class="text-xs">
              <summary class="cursor-pointer opacity-70">Each side on its own</summary>
              <div class="grid gap-3 md:grid-cols-2 mt-2">
                <div>
                  <div class="opacity-60 mb-1">{@into} (ours)</div>
                  <pre class="p-2 bg-base-200 rounded overflow-x-auto" phx-no-curly-interpolation><%= side_text(file.ours) %></pre>
                </div>
                <div>
                  <div class="opacity-60 mb-1">{@from} (theirs)</div>
                  <pre class="p-2 bg-base-200 rounded overflow-x-auto" phx-no-curly-interpolation><%= side_text(file.theirs) %></pre>
                </div>
              </div>
            </details>
          </section>
        </div>

        <div
          :if={@analysis && @analysis.status == :conflict}
          class="border-t border-base-300 pt-4 space-y-3"
        >
          <form phx-change="set_message" class="max-w-2xl">
            <label class="flex flex-col gap-1">
              <span class="text-sm opacity-70">
                Commit message <span class="opacity-60">(optional)</span>
              </span>
              <input
                type="text"
                name="message"
                value={@message}
                placeholder={"Merge #{@from} into #{@into}"}
                class="input input-bordered input-sm"
              />
            </label>
          </form>

          <div class="flex items-center gap-3">
            <button
              type="button"
              phx-click="merge"
              disabled={@merging or not all_resolved?(@analysis, @resolutions)}
              class="btn btn-primary btn-sm"
            >
              {if @merging, do: "Merging…", else: "Merge"}
            </button>
            <span :if={not all_resolved?(@analysis, @resolutions)} class="text-xs opacity-60">
              {Enum.count(@analysis.files, &(not resolved?(@resolutions, &1.path)))} still to resolve
            </span>
          </div>
        </div>
      </div>
    </Layouts.app>
    """
  end
end