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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
15.9 KiB · text
History
b868907