defmodule GitGudWeb.RepoLive.Compare do
@moduledoc """
Compare two refs (same-repo or cross-repo) and preview what a PR
between them would contain.
URL shape: `/r/:owner/:name/compare?base=:base_ref&head=:head_spec`
`head_spec` is either:
* `branch` — same-repo head
* `owner/name:branch` — cross-repo head (e.g., from a fork)
"""
use GitGudWeb, :live_view
alias GitGud.Git
alias GitGud.Repositories
alias GitGud.Repositories.Storage
@impl true
def mount(%{"owner" => owner, "name" => name} = params, _session, socket) do
base_repo =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
base_ref = params["base"] || base_repo.default_branch
head_spec = params["head"] || base_repo.default_branch
{head_repo, head_ref, head_label} = resolve_head(base_repo, head_spec)
socket =
socket
|> assign(:base_repo, base_repo)
|> assign(:handle, Storage.repo_handle(base_repo))
|> GitGudWeb.RepoLive.Header.assign_chrome(base_repo)
|> assign(:base_ref, base_ref)
|> assign(:head_repo, head_repo)
|> assign(:head_ref, head_ref)
|> assign(:head_label, head_label)
|> assign(:cross_repo?, head_repo.id != base_repo.id)
|> assign(:page_title, "Compare #{base_ref}...#{head_spec}")
|> assign_comparison()
{:ok, socket}
end
defp resolve_head(base_repo, spec) do
case String.split(spec, ":", parts: 2) do
[path, branch] ->
case String.split(path, "/", parts: 2) do
[owner, name] ->
try do
head =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
{head, branch, Storage.repo_handle(head) <> "/" <> head.name <> ":" <> branch}
rescue
Ecto.NoResultsError -> {base_repo, branch, branch}
end
_ ->
{base_repo, spec, spec}
end
[branch] ->
{base_repo, branch, branch}
end
end
defp assign_comparison(socket) do
case Repositories.compare(
socket.assigns.base_repo,
socket.assigns.base_ref,
socket.assigns.head_repo,
socket.assigns.head_ref
) do
{:ok, cmp} ->
diff_files =
case Repositories.get_diff(socket.assigns.base_repo, cmp.base_sha, cmp.head_sha) do
{:ok, files} -> files
_ -> []
end
socket
|> assign(:comparison, cmp)
|> assign(:diff_files, diff_files)
|> assign(:loaded_diff_idx, GitGudWeb.DiffComponents.default_loaded(diff_files))
|> assign(:error, nil)
{:error, reason} ->
socket
|> assign(:comparison, nil)
|> assign(:diff_files, [])
|> assign(:loaded_diff_idx, MapSet.new())
|> assign(:error, reason)
end
end
@impl true
def handle_event("expand_diff_file", params, socket),
do: {:noreply, GitGudWeb.DiffComponents.expand(socket, params)}
@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={@base_repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
/>
<header class="space-y-1">
<h1 class="text-xl font-semibold">Compare changes</h1>
<p class="text-xs opacity-70 font-mono">
base: {@base_ref}
<span class="mx-1">←</span> head: {@head_label}
<span :if={@cross_repo?} class="badge badge-xs badge-info ml-1">cross-repo</span>
</p>
</header>
<%= if @error do %>
<div class="rounded-box border border-error/40 bg-error/10 p-4 text-sm">
<p class="font-semibold">Couldn't compare.</p>
<pre class="mt-2 text-xs opacity-75" phx-no-curly-interpolation><%= inspect(@error) %></pre>
</div>
<% else %>
<section class="flex flex-wrap items-center gap-4 text-sm">
<span>
<strong>{@comparison.ahead}</strong> commit(s) ahead
</span>
<span>
<strong>{@comparison.behind}</strong> behind
</span>
<span><strong>{@comparison.diff_stat.files_changed}</strong> files</span>
<span class="text-success">+{@comparison.diff_stat.insertions}</span>
<span class="text-error">-{@comparison.diff_stat.deletions}</span>
<span class="text-xs opacity-60 font-mono ml-auto">
base {Git.short(@comparison.base_sha)} · head {Git.short(@comparison.head_sha)}
</span>
</section>
<section :if={(@comparison.ahead > 0 and @current_scope) && @current_scope.user}>
<.link
navigate={create_pr_link(assigns)}
class="btn btn-primary btn-sm"
>
<.icon name="hero-arrow-right-circle" class="size-4" /> Create pull request
</.link>
</section>
<section :if={@comparison.commits != []}>
<h2 class="font-semibold text-sm mb-2">Commits</h2>
<ul class="divide-y divide-base-300 text-sm">
<li :for={c <- @comparison.commits} class="py-2">
<.link
navigate={~p"/r/#{@handle}/#{@base_repo.name}/commit/#{Git.to_hex(c.sha)}"}
class="link link-hover font-medium"
>
{commit_title(c)}
</.link>
<p class="text-xs opacity-60 font-mono">
{Git.short(c.sha)} · {c.author_name} · {format_dt(c.committed_at)}
</p>
</li>
</ul>
</section>
<section :if={@comparison.ahead == 0} class="text-sm opacity-70">
No new commits to compare.
</section>
<section :if={@diff_files != []}>
<h2 class="font-semibold text-sm mb-2">Files changed</h2>
<GitGudWeb.DiffComponents.diff
files={@diff_files}
theme={@editor_theme}
loaded_idx={@loaded_diff_idx}
/>
</section>
<% end %>
</div>
</Layouts.app>
"""
end
defp create_pr_link(assigns) do
base_path = "/r/" <> assigns.handle <> "/" <> assigns.base_repo.name <> "/pulls/new"
qs =
URI.encode_query(%{
"source" => assigns.head_ref,
"target" => assigns.base_ref,
"source_repository_id" => Integer.to_string(assigns.head_repo.id)
})
base_path <> "?" <> qs
end
defp commit_title(c), do: c.message |> String.split("\n", parts: 2) |> hd()
defp format_dt(nil), do: "?"
defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
end
6.8 KiB · text
5af55d9