3.3 KiB · text 5af55d9
defmodule GitGud.Repositories.HookReceiver do
@moduledoc """
Internal API the `post-receive` hook calls into.
The hook reads `<old_sha> <new_sha> <ref>` triples on stdin, one per
line — the standard git post-receive format. For each triple we
enqueue a `CommitBackfill` job.
This module is intentionally Plug-free; the transport (unix socket
or local HTTP) is wired separately. It just needs the repo id and
the lines.
"""
import Ecto.Query, warn: false
alias GitGud.Repositories.Workers.CommitBackfill
alias GitGud.Repositories.Repository
@doc """
Process raw post-receive stdin. Returns the list of job results.
`pusher_id` (optional) is the id of the user who pushed, threaded
from the SSH/HTTP transport via the `gitgud-hook` bridge so workflow
runs can be attributed to them. nil when unattributable.
"""
def receive(repository_id, stdin, pusher_id \\ nil) when is_binary(stdin) do
triples =
stdin
|> String.split("\n", trim: true)
|> Enum.map(&parse_line/1)
|> Enum.reject(&is_nil/1)
sync_default_branch_if_unset(repository_id, triples)
mark_pushed_now(repository_id, triples)
Enum.map(triples, fn {old_hex, new_hex, ref} ->
%{
repository_id: repository_id,
ref: ref,
old_sha_hex: old_hex,
new_sha_hex: new_hex,
pusher_id: pusher_id
}
|> CommitBackfill.new_job()
|> Oban.insert()
end)
end
# If the repo's recorded default_branch no longer resolves on disk
# (typical: empty repo created with default "main", first push lands
# on "master"), align default_branch with HEAD so the UI doesn't
# render "This repository is empty" against a populated repo.
defp sync_default_branch_if_unset(repository_id, triples) do
repo = GitGud.Repo.get(Repository, repository_id)
if repo do
configured = "refs/heads/" <> (repo.default_branch || "")
configured_pushed? =
Enum.any?(triples, fn {_, _, ref} -> ref == configured end)
unless configured_pushed? do
case read_head_branch(repo.disk_path) do
{:ok, branch} when branch != repo.default_branch ->
repo
|> Ecto.Changeset.change(default_branch: branch)
|> GitGud.Repo.update()
_ ->
:ok
end
end
end
end
# Stamp pushed_at so repo listings can show "pushed N ago". Skip
# delete-only pushes (all triples have a zero new SHA) since they
# aren't really a push of content.
defp mark_pushed_now(_repository_id, []), do: :ok
defp mark_pushed_now(repository_id, triples) do
has_content_update? =
Enum.any?(triples, fn {_, new_hex, _} -> new_hex != String.duplicate("0", 40) end)
if has_content_update? do
from(r in Repository, where: r.id == ^repository_id)
|> GitGud.Repo.update_all(set: [pushed_at: DateTime.utc_now(:second)])
end
:ok
end
defp read_head_branch(disk_path) do
case File.read(Path.join(disk_path, "HEAD")) do
{:ok, "ref: refs/heads/" <> rest} ->
{:ok, String.trim(rest)}
_ ->
:error
end
end
defp parse_line(line) do
case String.split(line, " ", parts: 3) do
[old, new, ref] when byte_size(old) == 40 and byte_size(new) == 40 ->
{old, new, ref}
_ ->
nil
end
end
end