defmodule GitGud.Repositories do
@moduledoc """
Repositories context: owns the lifecycle of git repos on disk and
the Postgres caches that mirror their content.
All UI reads go through this module so the cache stays in front of
`GitGud.Git`. Cache misses transparently fall through to the backend
and write-through to PG.
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.Scope
alias GitGud.Accounts.User
alias GitGud.Git
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories.GitBlobMeta
alias GitGud.Repositories.GitCommit
alias GitGud.Repositories.GitCommitDiffstat
alias GitGud.Repositories.GitCommitLanguageStats
alias GitGud.Repositories.GitRef
alias GitGud.Repositories.GitTree
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
# ── repository lifecycle ─────────────────────────────────────────────
@doc """
List repositories visible to the given scope. Filters out same-name
profile-README repos (`alice/alice`, `acme/acme`) so the global
listing doesn't get cluttered with them — they're still reachable
via the owner's profile page.
"""
def list_repositories(%Scope{user: %User{id: uid}}) do
Repository
|> where([r], r.owner_id == ^uid or r.visibility in ["public", "internal"])
|> without_profile_repos()
|> order_by([r], desc: r.pushed_at, asc: r.name)
|> Repo.all()
end
def list_repositories(nil) do
Repository
|> where([r], r.visibility == "public")
|> without_profile_repos()
|> order_by([r], asc: r.name)
|> Repo.all()
end
# A repo is the same-name profile repo when its `name` matches its
# namespace's handle. For org-owned repos that's the org's handle;
# for user-owned (no org) that's the user's handle. The COALESCE
# picks whichever is non-null.
defp without_profile_repos(query) do
from r in query,
left_join: u in User,
on: u.id == r.owner_id and is_nil(r.organization_id),
left_join: o in GitGud.Organizations.Organization,
on: o.id == r.organization_id,
where: r.name != coalesce(o.handle, u.handle)
end
def get_repository!(id), do: Repo.get!(Repository, id)
@doc "List repos owned by a user or organization, namespaced view."
def list_repositories_for(%User{id: uid}) do
from(r in Repository,
where: r.owner_id == ^uid and is_nil(r.organization_id),
order_by: [asc: r.name]
)
|> Repo.all()
end
def list_repositories_for(%Organization{id: oid}) do
from(r in Repository, where: r.organization_id == ^oid, order_by: [asc: r.name])
|> Repo.all()
end
@doc """
Ids of repos in `org` that `viewer` may see. Mirrors the org page's
visibility policy:
* org member → every repo the org owns
* logged-in stranger → public + internal repos
* anonymous → public repos only
Used by the org CI dashboard to scope which runs are listed.
"""
def visible_repo_ids_for_org(%Organization{id: oid} = org, viewer) do
member? = viewer && GitGud.Organizations.member?(org, viewer)
query =
cond do
member? ->
from(r in Repository, where: r.organization_id == ^oid)
not is_nil(viewer) ->
from(r in Repository,
where: r.organization_id == ^oid and r.visibility in ["public", "internal"]
)
true ->
from(r in Repository,
where: r.organization_id == ^oid and r.visibility == "public"
)
end
query |> select([r], r.id) |> Repo.all()
end
@doc """
Ids of every repo a user "cares about": repos they own, repos in any
org they're a member of, and repos granted to them via a team. Used
by the personal CI dashboard.
"""
def relevant_repo_ids_for_user(%User{id: uid}) do
owned_and_member =
from(r in Repository,
left_join: m in GitGud.Organizations.Membership,
on: m.organization_id == r.organization_id and m.user_id == ^uid,
where: r.owner_id == ^uid or not is_nil(m.id),
select: r.id
)
|> Repo.all()
team_granted =
from(tr in GitGud.Organizations.TeamRepository,
join: tm in GitGud.Organizations.TeamMembership,
on: tm.team_id == tr.team_id and tm.user_id == ^uid,
select: tr.repository_id
)
|> Repo.all()
(owned_and_member ++ team_granted) |> Enum.uniq()
end
@doc """
Repos a user can pin to their personal profile: their own + any repo
in an organization they're a member of. Returns rows with `:owner` +
`:organization` preloaded so the UI can label them.
"""
def list_pinnable_for_user(%User{id: uid}) do
from(r in Repository,
left_join: m in GitGud.Organizations.Membership,
on: m.organization_id == r.organization_id and m.user_id == ^uid,
where: r.owner_id == ^uid or not is_nil(m.id),
order_by: [asc: r.name],
preload: [:owner, :organization]
)
|> Repo.all()
end
@doc """
Ensure a user/org has a `<handle>/<handle>` repo. Idempotent — if a
repo already exists at that path, returns `{:ok, existing}` without
touching it. Best-effort: caller should not raise on `{:error, _}`,
since failing to create the profile repo shouldn't block signup.
The created repo is `public` so the profile README is visible to
anonymous viewers.
"""
def ensure_profile_repo(%User{handle: handle, id: uid} = user)
when is_binary(handle) and handle != "" do
case Repo.one(
from r in Repository,
where: r.owner_id == ^uid and is_nil(r.organization_id) and r.name == ^handle,
limit: 1
) do
%Repository{} = existing ->
{:ok, existing}
nil ->
with {:ok, repo} <-
create_repository(user, %{
"name" => handle,
"visibility" => "public",
"description" =>
"Profile README — edit this repo's README.md to populate /u/#{handle}."
}) do
_ = seed_profile_readme(repo, :user)
{:ok, repo}
end
end
end
def ensure_profile_repo(%Organization{handle: handle, id: oid} = org) do
case Repo.get_by(Repository, organization_id: oid, name: handle) do
%Repository{} = existing ->
{:ok, existing}
nil ->
# Pick any admin as the audit-trail owner_id; if none exists yet
# we can't proceed (this would be a transient state during the
# creating transaction — callers run this after Membership has
# been inserted).
case Repo.one(
from m in GitGud.Organizations.Membership,
where: m.organization_id == ^oid and m.role == "admin",
join: u in assoc(m, :user),
select: u,
limit: 1
) do
nil ->
{:error, :no_admin}
%User{} = admin ->
with {:ok, repo} <-
do_create_repository(
org,
%{
"name" => handle,
"visibility" => "public",
"description" =>
"Profile README — edit this repo's README.md to populate /orgs/#{handle}."
},
%{owner_id: admin.id, organization_id: oid}
) do
_ = seed_profile_readme(repo, :org)
{:ok, repo}
end
end
end
end
# Stage a one-file initial commit so the profile shows a friendly
# placeholder instead of empty space. Best-effort — any plumbing
# failure is logged and the repo stays empty.
defp seed_profile_readme(%Repository{disk_path: path, default_branch: branch, name: name}, kind) do
body = default_profile_readme(kind, name)
with {:ok, blob_sha} <- hash_blob(path, body),
{:ok, tree_sha} <- mktree(path, [{"100644", "blob", blob_sha, "README.md"}]),
{:ok, commit_sha} <-
GitGud.Git.commit_tree(path, tree_sha, [], "Initial profile README", []),
:ok <- GitGud.Git.update_ref(path, "refs/heads/" <> (branch || "main"), commit_sha, nil) do
:ok
else
err ->
require Logger
Logger.warning("seed_profile_readme failed for #{path}: #{inspect(err)}")
err
end
end
# Write `content` as a blob in the bare repo. Goes via a tmp file so
# we don't need to thread stdin through Port (Erlang's half-close
# behavior on Ports is fiddly).
defp hash_blob(path, content) when is_binary(content) do
tmp = Path.join(System.tmp_dir!(), "gg-blob-#{:erlang.unique_integer([:positive])}.tmp")
File.write!(tmp, content)
try do
case System.cmd("git", ["-C", path, "hash-object", "-w", tmp], stderr_to_stdout: true) do
{out, 0} ->
case String.trim(out) do
<<hex::binary-size(40)>> -> GitGud.Git.from_hex(hex)
other -> {:error, {:hash_object, other}}
end
{err, code} ->
{:error, {:git_exit, code, err}}
end
after
File.rm(tmp)
end
end
# `git mktree` reads `<mode> <type> <sha>\tname\n` lines on stdin.
# Same tmp-file workaround as hash_blob — we redirect a tmp file in.
defp mktree(path, entries) do
stdin =
entries
|> Enum.map(fn {mode, type, sha, name} ->
"#{mode} #{type} #{GitGud.Git.to_hex(sha)}\t#{name}\n"
end)
|> IO.iodata_to_binary()
tmp = Path.join(System.tmp_dir!(), "gg-tree-#{:erlang.unique_integer([:positive])}.tmp")
File.write!(tmp, stdin)
try do
# `bash -c` because System.cmd doesn't support shell redirection.
cmd = "git -C #{shellesc(path)} mktree < #{shellesc(tmp)}"
case System.cmd("sh", ["-c", cmd], stderr_to_stdout: true) do
{out, 0} ->
case String.trim(out) do
<<hex::binary-size(40)>> -> GitGud.Git.from_hex(hex)
other -> {:error, {:mktree, other}}
end
{err, code} ->
{:error, {:git_exit, code, err}}
end
after
File.rm(tmp)
end
end
defp shellesc(s), do: "'" <> String.replace(s, "'", "'\\''") <> "'"
defp default_profile_readme(:user, handle) do
"""
# Hi, I'm @#{handle} 👋
This is your **Profile README**. Edit `README.md` in this repository
— `#{handle}/#{handle}` — and whatever you write will show up on your
profile page.
Say hello here. Tell people what you work on, what you care about,
where to find you elsewhere.
"""
end
defp default_profile_readme(:org, handle) do
"""
# #{handle}
This is the **Org README**. Edit `README.md` in this repository
— `#{handle}/#{handle}` — and whatever you write will show up on the
`@#{handle}` page.
Introduce the organization. Link to flagship projects, point at
onboarding docs, set expectations for contributors.
"""
end
@doc """
Locate and render the profile README for a user or organization.
Returns `{:ok, html_safe_string}` (markdown rendered) or `nil` if
the profile repo is missing / empty / has no README.
"""
def get_profile_readme(namespace, opts \\ [])
def get_profile_readme(%User{handle: h, id: uid}, opts) when is_binary(h) do
Repo.one(
from r in Repository,
where: r.owner_id == ^uid and is_nil(r.organization_id) and r.name == ^h,
limit: 1
)
|> render_profile_readme(opts)
end
def get_profile_readme(%Organization{handle: h, id: oid}, opts) do
Repo.get_by(Repository, organization_id: oid, name: h)
|> render_profile_readme(opts)
end
def get_profile_readme(_, _), do: nil
@doc """
Is this the namespace's same-name profile-README repo? Used to
apply muted "ghost" styling in listings + a marker on the repo page.
Pass the resolved namespace `handle` for the cheapest check; the
single-arg form requires `:owner` / `:organization` preloaded.
"""
@spec profile_repo?(Repository.t(), String.t()) :: boolean()
def profile_repo?(%Repository{name: name}, handle) when is_binary(handle),
do: name == handle
@spec profile_repo?(Repository.t()) :: boolean()
def profile_repo?(%Repository{name: name, organization: %Organization{handle: h}})
when name == h,
do: true
def profile_repo?(%Repository{name: name, owner: %User{handle: h}, organization_id: nil})
when name == h,
do: true
def profile_repo?(_), do: false
defp render_profile_readme(nil, _opts), do: nil
defp render_profile_readme(%Repository{} = repo, opts) do
theme = Keyword.get(opts, :theme)
with branch when is_binary(branch) <- repo.default_branch,
{:ok, sha} <- resolve(repo, branch),
{:ok, commit} <- get_commit(repo, sha),
{:ok, entries} <- get_tree(repo, commit.tree_sha),
%{sha: blob_sha, name: name} <- find_readme_entry(entries),
{:ok, bytes} <- get_blob_bytes(repo, blob_sha) do
if String.ends_with?(String.downcase(name), ".md") do
{:ok, GitGud.Markdown.to_html(bytes, theme: theme, repo: repo)}
else
{:ok, "<pre>" <> (bytes |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()) <> "</pre>"}
end
else
_ -> nil
end
end
defp find_readme_entry(entries) do
Enum.find(entries, fn e ->
e.type == "blob" and Regex.match?(~r/^readme(\.md)?$/i, e.name)
end)
end
@doc """
One-shot: ensure every user and every organization has its
`<handle>/<handle>` profile-README repo. Idempotent.
Returns `%{users: %{created, skipped, failed}, orgs: %{...}}`.
iex> GitGud.Repositories.backfill_profile_repos()
"""
def backfill_profile_repos do
users = Repo.all(User)
orgs = Repo.all(GitGud.Organizations.Organization)
%{
users: tally(users),
orgs: tally(orgs)
}
end
defp tally(rows) do
Enum.reduce(rows, %{created: 0, skipped: 0, failed: 0}, fn row, acc ->
case ensure_profile_repo(row) do
{:ok, %{inserted_at: ts}} when not is_nil(ts) ->
# Created during this call (we just inserted it).
if DateTime.diff(DateTime.utc_now(:second), ts, :second) < 10 do
%{acc | created: acc.created + 1}
else
%{acc | skipped: acc.skipped + 1}
end
{:ok, _existing} ->
%{acc | skipped: acc.skipped + 1}
{:error, _} ->
%{acc | failed: acc.failed + 1}
end
end)
end
@doc """
Resolve a handle to either a user or an organization. Organizations
win on collision — operators can avoid collisions at signup by
refusing to register an org whose handle equals an existing user's
local-part (TODO).
"""
def resolve_namespace(handle) when is_binary(handle) do
h = String.downcase(handle)
case Repo.get_by(Organization, handle: h) do
%Organization{} = org ->
{:ok, org}
nil ->
case Repo.one(
from u in User,
where:
u.email == ^handle or fragment("lower(split_part(?, '@', 1))", u.email) == ^h,
limit: 1
) do
%User{} = user -> {:ok, user}
nil -> :error
end
end
end
@doc """
Non-raising sibling of `get_repository_by_path!/2`. Returns `nil`
when the owner namespace or repo name doesn't resolve.
"""
def find_by_path(owner_name, repo_name) do
try do
get_repository_by_path!(owner_name, repo_name)
rescue
Ecto.NoResultsError -> nil
end
end
def get_repository_by_path!(owner_name, repo_name) do
case resolve_namespace(owner_name) do
{:ok, %Organization{id: oid}} ->
Repo.one!(
from r in Repository,
where: r.organization_id == ^oid and r.name == ^repo_name
)
{:ok, %User{id: uid}} ->
# User-owned repos must NOT have an organization_id set, so the
# org-owned set is properly partitioned.
Repo.one!(
from r in Repository,
where: r.owner_id == ^uid and is_nil(r.organization_id) and r.name == ^repo_name
)
:error ->
raise Ecto.NoResultsError, queryable: Repository
end
end
@doc """
Create a repo owned by `owner` and init its bare repository on disk.
Atomic in the sense that the on-disk init is rolled back if the DB insert
fails *after* it. We init first because the disk path depends on the
generated id only if we use the id, but we use `owner_id/name` instead
which is stable pre-insert.
"""
def create_repository(%User{} = owner, attrs) do
do_create_repository(owner, attrs, clone_fk(%{owner_id: owner.id, organization_id: nil}, attrs))
end
# Thread an optional remote import URL (`clone_addr`) into the fk so
# init_function/1 mirror-clones it instead of starting empty.
defp clone_fk(fk, attrs) do
case attrs["clone_addr"] || attrs[:clone_addr] do
url when is_binary(url) and url != "" -> Map.put(fk, :clone_from_url, url)
_ -> fk
end
end
@doc """
Create a repo under an organization. The acting `creator` must be
an admin of the org (callers should verify; we double-check here as
defense-in-depth and refuse otherwise). The repo's `owner_id` records
the creator for audit / fallback.
"""
def create_repository_for_org(%Organization{} = org, %User{} = creator, attrs) do
cond do
not GitGud.Organizations.org_admin?(org, creator) ->
{:error, :forbidden}
not allowed_under_org?(attrs["visibility"] || attrs[:visibility], org.visibility) ->
{:error, {:visibility_too_open, org.visibility}}
true ->
fk = clone_fk(%{owner_id: creator.id, organization_id: org.id}, attrs)
case do_create_repository(org, attrs, fk) do
{:ok, repo} = ok ->
_ = GitGud.Federation.Publishing.publish_org_repo_created(org, repo)
ok
err ->
err
end
end
end
# A repo can be no more open than its org. With ranks: 0=public, 1=internal,
# 2=private, the repo's rank must be >= the org's rank.
defp allowed_under_org?(nil, _org_vis), do: true
defp allowed_under_org?(repo_vis, org_vis) do
Organization.visibility_rank(repo_vis) >= Organization.visibility_rank(org_vis)
end
defp do_create_repository(namespace, attrs, fk) do
disk_path = Storage.repo_path(namespace, attrs["name"] || attrs[:name] || "")
changeset =
%Repository{
owner_id: fk.owner_id,
organization_id: fk.organization_id,
parent_repository_id: Map.get(fk, :parent_repository_id),
disk_path: disk_path
}
|> Repository.create_changeset(attrs)
Repo.transaction(fn ->
case Repo.insert(changeset) do
{:ok, repo} ->
case init_function(fk).(disk_path) do
:ok ->
# A freshly `git init`'d bare repo HEADs at `main`; point it
# at the repo's actual default branch so the first push of
# that branch isn't orphaned (UI/clone follow HEAD). Skip
# for clones/forks — they inherit the source HEAD.
if init_empty?(fk), do: set_default_head(disk_path, repo.default_branch)
install_hooks(disk_path)
configure_git(disk_path)
repo
{:error, reason} ->
Repo.rollback({:git_init, reason})
end
{:error, cs} ->
Repo.rollback(cs)
end
end)
end
defp init_empty?(fk),
do: not Map.has_key?(fk, :clone_from) and not Map.has_key?(fk, :clone_from_url)
defp set_default_head(_path, nil), do: :ok
defp set_default_head(path, branch) when is_binary(branch) do
System.cmd("git", ["-C", path, "symbolic-ref", "HEAD", "refs/heads/" <> branch],
stderr_to_stdout: true
)
:ok
end
defp init_function(%{clone_from_url: url}) when is_binary(url) do
fn dest -> clone_mirror(url, dest) end
end
defp init_function(%{clone_from: source_path}) when is_binary(source_path) do
fn dest -> clone_bare(source_path, dest) end
end
defp init_function(_), do: &Git.init_bare/1
defp clone_bare(source, dest) do
File.mkdir_p!(Path.dirname(dest))
case System.cmd("git", ["clone", "--bare", source, dest], stderr_to_stdout: true) do
{_out, 0} -> :ok
{out, _} -> {:error, {:git_clone, out}}
end
end
# Import from a remote URL (e.g. github.com/neiam/foo.git). `--mirror`
# grabs all branches/tags/notes, which is what a full import wants.
defp clone_mirror(url, dest) do
File.mkdir_p!(Path.dirname(dest))
case System.cmd("git", ["clone", "--mirror", url, dest], stderr_to_stdout: true) do
{_out, 0} -> :ok
{out, _} -> {:error, {:git_clone, out}}
end
end
@doc """
Fork `source_repo` under `dest_namespace` (a `%User{}` or
`%Organization{}`). The new repo's bare on-disk store is a clone of
the source.
`actor` is the user performing the fork — they must be able to read
the source repo and write to the destination namespace.
"""
def fork(%Repository{} = source_repo, dest_namespace, %User{} = actor, attrs \\ %{}) do
cond do
not can_read?(actor, source_repo) ->
{:error, :source_forbidden}
not can_create_in?(actor, dest_namespace) ->
{:error, :dest_forbidden}
true ->
do_fork(source_repo, dest_namespace, actor, attrs)
end
end
defp can_read?(_user, %Repository{visibility: vis}) when vis in ["public", "internal"], do: true
defp can_read?(%User{id: uid}, %Repository{owner_id: uid}), do: true
defp can_read?(_, _), do: false
defp can_create_in?(%User{id: uid}, %User{id: uid}), do: true
defp can_create_in?(_, %User{}), do: false
defp can_create_in?(%User{} = user, %Organization{} = org),
do: GitGud.Organizations.org_admin?(org, user)
defp can_create_in?(_, _), do: false
defp do_fork(source_repo, dest_namespace, %User{id: uid}, attrs) do
fk =
base_fk(dest_namespace, uid)
|> Map.put(:parent_repository_id, source_repo.id)
|> Map.put(:clone_from, source_repo.disk_path)
# Default fork name = source name; caller can override via `:name`.
fork_attrs =
attrs
|> Map.put_new("name", source_repo.name)
|> Map.put_new("description", source_repo.description)
|> Map.put_new("default_branch", source_repo.default_branch)
# Forks default to the source's visibility (you can't fork a
# private repo without permission anyway).
|> Map.put_new("visibility", source_repo.visibility)
do_create_repository(dest_namespace, fork_attrs, fk)
end
defp base_fk(%User{id: uid}, _actor_id), do: %{owner_id: uid, organization_id: nil}
defp base_fk(%Organization{id: oid}, actor_id),
do: %{owner_id: actor_id, organization_id: oid}
@doc "List repos that were forked from `parent`."
def list_forks(%Repository{id: rid}) do
from(r in Repository,
where: r.parent_repository_id == ^rid,
order_by: [asc: r.inserted_at],
preload: [:owner, :organization]
)
|> Repo.all()
end
@doc """
Compare a fork's branch against its parent's matching branch.
Returns `{:ok, %{behind: n, ahead: n, parent_sha: sha, fork_sha: sha}}`
or `{:error, reason}`. `behind` is how many parent commits the fork
hasn't picked up yet; `ahead` is fork-only commits.
Used by the sync UI to render "N commits behind upstream" and decide
whether `sync_fork/3` would be a clean fast-forward.
"""
def fork_status(%Repository{parent_repository_id: nil}, _branch),
do: {:error, :not_a_fork}
def fork_status(%Repository{} = fork, branch) when is_binary(branch) do
parent = get_repository!(fork.parent_repository_id)
with {:ok, fork_sha} <- resolve(fork, branch),
{:ok, parent_sha} <- fetch_and_resolve_parent(fork, parent, branch) do
behind = rev_count(fork.disk_path, fork_sha, parent_sha)
ahead = rev_count(fork.disk_path, parent_sha, fork_sha)
{:ok,
%{
behind: behind,
ahead: ahead,
parent_sha: parent_sha,
fork_sha: fork_sha,
fast_forward?: ahead == 0 and behind > 0
}}
end
end
defp fetch_and_resolve_parent(fork, parent, branch) do
# Bring the parent's branch into the fork's object DB under a
# stable internal ref. Doing this every check is cheap because git
# only transfers missing objects.
case System.cmd(
"git",
[
"-C",
fork.disk_path,
"fetch",
"--no-tags",
parent.disk_path,
"+refs/heads/" <> branch <> ":refs/upstream/" <> branch
],
stderr_to_stdout: true
) do
{_, 0} -> resolve(fork, "refs/upstream/" <> branch)
{out, _} -> {:error, {:fetch_failed, out}}
end
end
defp rev_count(repo_path, exclude_sha, include_sha) do
case System.cmd(
"git",
[
"-C",
repo_path,
"rev-list",
"--count",
Git.to_hex(include_sha),
"^" <> Git.to_hex(exclude_sha)
],
stderr_to_stdout: true
) do
{out, 0} -> out |> String.trim() |> String.to_integer()
_ -> 0
end
end
@doc """
Fast-forward `fork`'s `branch` to match the parent's same branch.
Options:
* `:force` (default `false`) — when `true`, overwrites a divergent
fork branch with the parent's head. When `false`, refuses if the
fork has commits the parent doesn't.
`actor` must be the fork's owner or an admin of its org.
"""
def sync_fork(repo, branch, actor, opts \\ [])
def sync_fork(%Repository{parent_repository_id: nil}, _branch, _actor, _opts),
do: {:error, :not_a_fork}
def sync_fork(%Repository{} = fork, branch, %User{} = actor, opts) do
fork = Repo.preload(fork, [:owner, :organization])
cond do
not can_push_to?(actor, fork) ->
{:error, :forbidden}
true ->
with {:ok, status} <- fork_status(fork, branch) do
do_sync_fork(fork, branch, status, Keyword.get(opts, :force, false))
end
end
end
defp can_push_to?(%User{id: uid}, %Repository{owner_id: uid, organization_id: nil}), do: true
defp can_push_to?(%User{} = user, %Repository{organization_id: oid}) when not is_nil(oid) do
org = Repo.get!(GitGud.Organizations.Organization, oid)
GitGud.Organizations.org_admin?(org, user)
end
defp can_push_to?(_, _), do: false
@doc """
Compare two refs, possibly across two repos.
Returns `{:ok, %{ahead, behind, commits, merge_base_sha, base_sha,
head_sha, diff_stat}}`. `commits` are head-only commits (what a PR
from head→base would contribute), newest first, bounded by
`:limit` (default 50).
When `head_repo` differs from `base_repo`, the head branch is
fetched into the base's object DB first (same plumbing cross-repo
PRs use).
"""
def compare(base_repo, base_ref, head_repo, head_ref, opts \\ [])
def compare(%Repository{} = base_repo, base_ref, %Repository{} = head_repo, head_ref, opts) do
limit = Keyword.get(opts, :limit, 50)
with {:ok, base_sha} <- resolve(base_repo, base_ref),
{:ok, head_sha} <- resolve_head_in_base(base_repo, head_repo, head_ref),
{:ok, mb_sha} <- Git.merge_base(base_repo.disk_path, base_sha, head_sha) do
ahead = rev_count(base_repo.disk_path, base_sha, head_sha)
behind = rev_count(base_repo.disk_path, head_sha, base_sha)
{:ok, commits} =
Git.log(base_repo.disk_path, head_sha, limit: limit)
stats =
case Git.diff_stats(base_repo.disk_path, base_sha, head_sha) do
{:ok, s} -> s
_ -> %{files_changed: 0, insertions: 0, deletions: 0, paths: []}
end
{:ok,
%{
base_sha: base_sha,
head_sha: head_sha,
merge_base_sha: mb_sha,
ahead: ahead,
behind: behind,
commits: commits,
diff_stat: stats
}}
end
end
defp resolve_head_in_base(%Repository{id: rid}, %Repository{id: rid} = repo, head_ref),
do: resolve(repo, head_ref)
defp resolve_head_in_base(%Repository{} = base, %Repository{} = head, head_ref) do
case System.cmd(
"git",
[
"-C",
base.disk_path,
"fetch",
"--no-tags",
head.disk_path,
"+refs/heads/" <> head_ref <> ":refs/compare/" <> head_ref
],
stderr_to_stdout: true
) do
{_, 0} -> resolve(base, "refs/compare/" <> head_ref)
{out, _} -> {:error, {:compare_fetch, out}}
end
end
defp do_sync_fork(_fork, _branch, %{behind: 0}, _force),
do: {:error, :already_up_to_date}
defp do_sync_fork(_fork, _branch, %{ahead: ahead} = status, false) when ahead > 0,
do: {:error, {:diverged, status}}
defp do_sync_fork(fork, branch, status, _force) do
case Git.update_ref(
fork.disk_path,
"refs/heads/" <> branch,
status.parent_sha,
status.fork_sha
) do
:ok ->
# Mirror the new ref into the cache so the UI flips immediately.
:ok =
apply_ref_update(fork, "refs/heads/" <> branch, status.fork_sha, status.parent_sha)
{:ok, %{from: status.fork_sha, to: status.parent_sha, behind: status.behind}}
err ->
err
end
end
defp install_hooks(disk_path) do
install_hook(disk_path, "post-receive", "gitgud-hook")
install_hook(disk_path, "pre-receive", "gitgud-prereceive")
end
@doc """
Per-repo git config required for CI. `actions/checkout` (and any
`git fetch --depth=1 <sha>`) asks the server to `want` a specific
commit SHA; vanilla git refuses unadvertised objects with "Server does
not allow request for unadvertised object". Enabling
`uploadpack.allowReachableSHA1InWant` (+ `allowAnySHA1InWant`) permits
it, matching what Gitea/Forgejo set on Actions-enabled repos.
Idempotent — safe to call on existing repos (see the
`gitgud.repos.configure` mix task for backfilling).
"""
def configure_git(disk_path) do
for {k, v} <- [
{"uploadpack.allowAnySHA1InWant", "true"},
{"uploadpack.allowReachableSHA1InWant", "true"},
{"uploadpack.allowTipSHA1InWant", "true"}
] do
System.cmd("git", ["-C", disk_path, "config", k, v], stderr_to_stdout: true)
end
:ok
end
defp install_hook(disk_path, name, default_bin) do
hook_path = Path.join([disk_path, "hooks", name])
bridge = script_path(default_bin)
env_var = "GITGUD_" <> String.upcase(String.replace(name, "-", "_")) <> "_BIN"
contents = """
#!/bin/sh
# Auto-generated by GitGud.Repositories — do not edit.
exec "${#{env_var}:-#{bridge}}"
"""
File.write!(hook_path, contents)
File.chmod!(hook_path, 0o755)
end
defp script_path(default_bin) do
case :code.priv_dir(:git_gud) do
{:error, _} -> default_bin
dir -> Path.join([to_string(dir), "scripts", default_bin])
end
end
def update_repository(%Repository{} = repo, attrs) do
new_vis = attrs["visibility"] || attrs[:visibility]
case maybe_check_org_visibility(repo, new_vis) do
:ok ->
repo
|> Repository.update_changeset(attrs)
|> Repo.update()
err ->
err
end
end
defp maybe_check_org_visibility(%Repository{organization_id: nil}, _), do: :ok
defp maybe_check_org_visibility(_repo, nil), do: :ok
defp maybe_check_org_visibility(%Repository{organization_id: oid}, new_vis) do
case Repo.get(Organization, oid) do
%Organization{visibility: ov} ->
if allowed_under_org?(new_vis, ov),
do: :ok,
else: {:error, {:visibility_too_open, ov}}
_ ->
:ok
end
end
def delete_repository(%Repository{} = repo) do
Repo.transaction(fn ->
Repo.delete!(repo)
_ = File.rm_rf(repo.disk_path)
repo
end)
end
# ── refs (cache-first) ───────────────────────────────────────────────
@doc """
Resolve the current refs for a repository. Reads from `git_refs` cache;
if the cache is empty (e.g. immediately after init), populates from disk.
"""
def list_refs(%Repository{id: rid} = repo) do
rows =
GitRef
|> where([r], r.repository_id == ^rid)
|> order_by([r], asc: r.ref_type, asc: r.name)
|> Repo.all()
case rows do
[] -> backfill_refs(repo)
list -> {:ok, list}
end
end
defp backfill_refs(%Repository{id: rid, disk_path: path}) do
with {:ok, refs} <- Git.list_refs(path) do
now = DateTime.utc_now(:second)
rows =
Enum.map(refs, fn r ->
%{
repository_id: rid,
name: r.name,
target_sha: r.target_sha,
peeled_sha: r.peeled_sha,
ref_type: r.ref_type,
updated_at: now
}
end)
{_, _} =
Repo.insert_all(GitRef, rows,
on_conflict: :replace_all,
conflict_target: [:repository_id, :name]
)
list_refs_from_db(rid)
end
end
defp list_refs_from_db(rid) do
rows =
GitRef
|> where([r], r.repository_id == ^rid)
|> Repo.all()
{:ok, rows}
end
@doc """
Apply ref updates reported by the post-receive hook. Old/new SHAs are
raw 20-byte binaries; a zero SHA in `new_sha` means deletion.
"""
def apply_ref_update(%Repository{id: rid}, ref_name, _old_sha, new_sha) do
cond do
zero_sha?(new_sha) ->
Repo.delete_all(from r in GitRef, where: r.repository_id == ^rid and r.name == ^ref_name)
:ok
true ->
now = DateTime.utc_now(:second)
ref_type = if String.starts_with?(ref_name, "refs/tags/"), do: "tag", else: "branch"
Repo.insert_all(
GitRef,
[
%{
repository_id: rid,
name: ref_name,
target_sha: new_sha,
ref_type: ref_type,
updated_at: now
}
],
on_conflict: {:replace, [:target_sha, :ref_type, :updated_at]},
conflict_target: [:repository_id, :name]
)
:ok
end
end
defp zero_sha?(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>), do: true
defp zero_sha?(_), do: false
# ── commits / trees / blobs (cache-first) ────────────────────────────
@doc """
Resolve a ref name or SHA to a full SHA, hitting `git_refs` first.
"""
def resolve(%Repository{id: rid, disk_path: path} = _repo, ref) when is_binary(ref) do
case Repo.get_by(GitRef, repository_id: rid, name: "refs/heads/" <> ref) do
%GitRef{target_sha: sha} ->
{:ok, sha}
_ ->
case Repo.get_by(GitRef, repository_id: rid, name: "refs/tags/" <> ref) do
%GitRef{peeled_sha: psha} when not is_nil(psha) -> {:ok, psha}
%GitRef{target_sha: sha} -> {:ok, sha}
_ -> Git.resolve(path, ref)
end
end
end
def resolve(_, <<_::binary-size(20)>> = sha), do: {:ok, sha}
@doc "Load a single commit. Reads PG cache first, backfills on miss."
def get_commit(%Repository{id: rid, disk_path: path}, sha) do
case Repo.get_by(GitCommit, repository_id: rid, sha: sha) do
%GitCommit{} = row ->
{:ok, commit_row_to_map(row)}
nil ->
with {:ok, commit} <- Git.commit(path, sha) do
_ = upsert_commit(rid, commit)
{:ok, commit}
end
end
end
@doc """
Walk commits newest-first. Currently always hits the backend; once we
have a usable `generation` populated we can serve common pagination
shapes from PG.
"""
def list_commits(%Repository{disk_path: path}, start_sha, opts \\ []),
do: Git.log(path, start_sha, opts)
@doc "Load a tree's entries. Cache-first."
def get_tree(%Repository{id: rid, disk_path: path}, sha) do
case Repo.get_by(GitTree, repository_id: rid, sha: sha) do
%GitTree{entries: %{"items" => items}} when is_list(items) ->
{:ok, Enum.map(items, &decode_tree_entry/1)}
_ ->
with {:ok, entries} <- Git.tree(path, sha) do
_ = upsert_tree(rid, sha, entries)
{:ok, entries}
end
end
end
def get_tree_entry_at(%Repository{disk_path: path} = _repo, tree_sha, subpath) do
Git.tree_entry_at(path, tree_sha, subpath)
end
@doc "Cheap blob metadata. Cache-first."
def get_blob_meta(%Repository{id: rid, disk_path: path}, sha) do
case Repo.get_by(GitBlobMeta, repository_id: rid, sha: sha) do
%GitBlobMeta{} = m ->
{:ok, blob_meta_row_to_map(m)}
nil ->
with {:ok, meta} <- Git.blob_meta(path, sha) do
_ = upsert_blob_meta(rid, meta)
{:ok, meta}
end
end
end
@doc "Load blob bytes. Never cached in PG (only in-memory + disk objects)."
def get_blob_bytes(%Repository{disk_path: path}, sha), do: Git.blob_bytes(path, sha)
@doc "Diff summary for a commit or between two commits. Cache-first."
def get_diff_stats(%Repository{id: rid, disk_path: path}, base_sha, head_sha) do
cache_key_eligible? = is_nil(base_sha) or single_parent?(base_sha, head_sha)
cached =
if cache_key_eligible? do
Repo.get_by(GitCommitDiffstat, repository_id: rid, sha: head_sha)
end
case cached do
%GitCommitDiffstat{} = row ->
{:ok, diffstat_row_to_map(row)}
_ ->
with {:ok, stats} <- Git.diff_stats(path, base_sha, head_sha) do
if cache_key_eligible?, do: upsert_diffstat(rid, head_sha, stats)
{:ok, stats}
end
end
end
defp single_parent?(_base, _head), do: false
@doc """
Full unified diff between two commits, parsed into the structured
`GitGud.Git.DiffParser.file_diff/0` shape the renderer walks.
Cached in `GitGud.Cache` keyed by `(base_sha, head_sha, context)`.
Commit SHAs are immutable so the parsed shape never goes stale;
the only reason to bust the entry is a parser bugfix (deploy →
restart → empty cache).
"""
def get_diff(%Repository{disk_path: path}, base_sha, head_sha, opts \\ []) do
context = Keyword.get(opts, :context, 3)
key = {:diff, base_sha, head_sha, context}
case GitGud.Cache.get(key) do
nil ->
with {:ok, raw} <- Git.diff(path, base_sha, head_sha, opts) do
parsed = GitGud.Git.DiffParser.parse(raw)
GitGud.Cache.put(key, parsed, ttl: :timer.hours(24))
{:ok, parsed}
end
cached ->
{:ok, cached}
end
end
# ── cache writers ────────────────────────────────────────────────────
@doc false
def upsert_commit(rid, commit) do
row = %{
repository_id: rid,
sha: commit.sha,
tree_sha: commit.tree_sha,
parent_shas: commit.parent_shas,
author_name: commit.author_name,
author_email: commit.author_email,
authored_at: commit.authored_at,
committer_name: commit.committer_name,
committer_email: commit.committer_email,
committed_at: commit.committed_at,
message: commit.message
}
Repo.insert_all(GitCommit, [row], on_conflict: :nothing)
end
@doc false
def upsert_tree(rid, sha, entries) do
encoded = %{
"items" =>
Enum.map(entries, fn e ->
%{
"name" => e.name,
"mode" => e.mode,
"type" => e.type,
"sha" => Base.encode16(e.sha, case: :lower),
"size" => e.size
}
end)
}
Repo.insert_all(GitTree, [%{repository_id: rid, sha: sha, entries: encoded}],
on_conflict: :nothing
)
end
@doc false
def upsert_blob_meta(rid, meta) do
row = %{
repository_id: rid,
sha: meta.sha,
size: meta.size,
is_binary: meta.is_binary,
mime: Map.get(meta, :mime),
line_count: Map.get(meta, :line_count),
detected_language: Map.get(meta, :detected_language)
}
Repo.insert_all(GitBlobMeta, [row], on_conflict: :nothing)
end
@doc false
def upsert_diffstat(rid, sha, stats) do
row = %{
repository_id: rid,
sha: sha,
files_changed: stats.files_changed,
insertions: stats.insertions,
deletions: stats.deletions,
paths: stats.paths
}
Repo.insert_all(GitCommitDiffstat, [row], on_conflict: :nothing)
end
# ── language stats ───────────────────────────────────────────────────
@doc """
Look up per-language byte/file breakdown for a commit. Cache-only —
returns `:not_computed` if the worker hasn't filled in this commit
yet. Callers should enqueue `GitGud.Repositories.Workers.LanguageStats`
when they get `:not_computed` and re-check next render.
"""
def get_language_stats(%Repository{id: rid}, sha) when is_binary(sha) do
case Repo.get_by(GitCommitLanguageStats, repository_id: rid, sha: sha) do
nil -> :not_computed
row -> {:ok, decode_language_stats(row)}
end
end
@doc """
Walk the tree at `sha` and bucket blob bytes by `detected_language`.
Writes the result into `git_commit_language_stats`. Synchronous —
called from the Oban worker.
"""
def compute_and_store_language_stats(%Repository{id: rid} = repo, sha) when is_binary(sha) do
with {:ok, commit} <- get_commit(repo, sha) do
buckets = walk_language_buckets(repo, commit.tree_sha, %{})
items =
buckets
|> Enum.map(fn {lang, %{bytes: b, files: f}} ->
%{"language" => lang, "bytes" => b, "files" => f}
end)
|> Enum.sort_by(&(-&1["bytes"]))
total_bytes = Enum.reduce(items, 0, &(&1["bytes"] + &2))
file_count = Enum.reduce(items, 0, &(&1["files"] + &2))
Repo.insert_all(
GitCommitLanguageStats,
[
%{
repository_id: rid,
sha: sha,
languages: %{"items" => items},
total_bytes: total_bytes,
file_count: file_count,
computed_at: DateTime.utc_now(:second)
}
],
on_conflict: {:replace, [:languages, :total_bytes, :file_count, :computed_at]},
conflict_target: [:repository_id, :sha]
)
{:ok,
%{
languages: items |> Enum.map(&decode_language_item/1),
total_bytes: total_bytes,
file_count: file_count
}}
end
end
defp walk_language_buckets(repo, tree_sha, acc) do
case get_tree(repo, tree_sha) do
{:ok, entries} ->
Enum.reduce(entries, acc, fn entry, inner ->
case entry.type do
"tree" -> walk_language_buckets(repo, entry.sha, inner)
"blob" -> bucket_blob(entry, inner)
_ -> inner
end
end)
_ ->
acc
end
end
# Bucket by filename — fast, no per-blob lookup. Misses (LICENSE,
# images, unrecognized extensions) are silently dropped, which is the
# same behavior GitHub's bar uses.
defp bucket_blob(%{name: name, size: size}, acc) do
case GitGud.Languages.detect(name) do
nil ->
acc
lang ->
bytes = size || 0
Map.update(
acc,
lang,
%{bytes: bytes, files: 1},
fn %{bytes: b, files: f} -> %{bytes: b + bytes, files: f + 1} end
)
end
end
defp decode_language_stats(%GitCommitLanguageStats{} = row) do
items =
case row.languages do
%{"items" => items} when is_list(items) -> items
_ -> []
end
%{
languages: Enum.map(items, &decode_language_item/1),
total_bytes: row.total_bytes,
file_count: row.file_count,
computed_at: row.computed_at
}
end
defp decode_language_item(%{} = item) do
%{
language: item["language"],
bytes: item["bytes"] || 0,
files: item["files"] || 0
}
end
# ── committer stats ──────────────────────────────────────────────────
@doc """
Aggregate per-author stats over every commit we have cached for this
repo. One row per `author_email`, sorted by commit count desc.
Returns a list of maps:
%{
name: string, # author_name on the first commit we saw
email: string,
commits: integer,
insertions: integer, # 0 if no diffstats cached for any commit
deletions: integer,
first_at: DateTime.t() | nil,
last_at: DateTime.t() | nil
}
Computed at request time — no separate table. The query is a single
GROUP BY over `git_commits` LEFT JOIN `git_commit_diffstats`, both of
which are populated by `CommitBackfill` on push.
"""
@spec committer_stats(Repository.t()) :: [map()]
def committer_stats(%Repository{id: rid}) do
Repo.all(
from c in GitCommit,
left_join: d in GitCommitDiffstat,
on: d.repository_id == c.repository_id and d.sha == c.sha,
where: c.repository_id == ^rid,
group_by: c.author_email,
select: %{
email: c.author_email,
name: fragment("(array_agg(? ORDER BY ? ASC))[1]", c.author_name, c.authored_at),
commits: count(c.sha),
insertions: coalesce(sum(d.insertions), 0),
deletions: coalesce(sum(d.deletions), 0),
first_at: min(c.authored_at),
last_at: max(c.authored_at)
},
order_by: [desc: count(c.sha), asc: c.author_email]
)
end
# ── decoders ─────────────────────────────────────────────────────────
defp commit_row_to_map(%GitCommit{} = c) do
%{
sha: c.sha,
tree_sha: c.tree_sha,
parent_shas: c.parent_shas,
author_name: c.author_name,
author_email: c.author_email,
authored_at: c.authored_at,
committer_name: c.committer_name,
committer_email: c.committer_email,
committed_at: c.committed_at,
message: c.message
}
end
defp decode_tree_entry(%{} = e) do
%{
name: e["name"],
mode: e["mode"],
type: e["type"],
sha: e["sha"] |> Base.decode16!(case: :mixed),
size: e["size"]
}
end
defp blob_meta_row_to_map(%GitBlobMeta{} = m) do
%{
sha: m.sha,
size: m.size,
is_binary: m.is_binary,
mime: m.mime,
line_count: m.line_count,
detected_language: m.detected_language
}
end
defp diffstat_row_to_map(%GitCommitDiffstat{} = d) do
%{
files_changed: d.files_changed,
insertions: d.insertions,
deletions: d.deletions,
paths: d.paths
}
end
end
46.1 KiB · text
5af55d9