defmodule GitGud.Wikis do
@moduledoc """
Wikis are stored as a sister bare git repo per project at
`<owner>/<repo>.wiki.git`. Pages are top-level markdown files in that
repo; `Home.md` is the index.
Edits are commits. Authoring identity is taken from the actor user.
The wiki repo is initialized lazily on the first write.
"""
alias GitGud.Accounts.User
alias GitGud.Git
alias GitGud.Repositories.Repository
alias GitGud.Repositories.Storage
@wiki_default_branch "main"
@page_extension ".md"
@type page_meta :: %{slug: String.t(), title: String.t(), size: integer() | nil}
# ── lifecycle ────────────────────────────────────────────────────────
def wiki_path(%Repository{disk_path: dp}), do: Storage.wiki_path_for(dp)
def initialized?(%Repository{} = repo), do: File.dir?(wiki_path(repo))
@doc "Init the sister wiki bare repo if it doesn't exist yet."
def ensure_initialized(%Repository{} = repo) do
path = wiki_path(repo)
if File.dir?(path), do: :ok, else: Git.init_bare(path)
end
# ── reads ────────────────────────────────────────────────────────────
@doc """
List wiki pages by walking the wiki repo's HEAD tree. Returns an
empty list if the wiki hasn't been initialized or has no commits yet.
"""
@spec list_pages(Repository.t()) :: [page_meta()]
def list_pages(%Repository{} = repo) do
path = wiki_path(repo)
with true <- File.dir?(path),
{:ok, head_sha} <- Git.resolve(path, @wiki_default_branch),
{:ok, commit} <- Git.commit(path, head_sha),
{:ok, entries} <- Git.tree(path, commit.tree_sha) do
entries
|> Enum.filter(&page?/1)
|> Enum.map(&entry_to_meta/1)
|> Enum.sort_by(& &1.slug)
else
_ -> []
end
end
defp page?(%{type: "blob", name: name}), do: String.ends_with?(name, @page_extension)
defp page?(_), do: false
defp entry_to_meta(%{name: name, sha: sha, size: size}) do
slug = String.replace_suffix(name, @page_extension, "")
%{
slug: slug,
title: slug,
size: size,
sha: sha
}
end
@doc "Fetch a page's raw bytes. Returns nil if missing."
@spec get_page(Repository.t(), String.t()) :: binary() | nil
def get_page(%Repository{} = repo, slug) do
path = wiki_path(repo)
filename = page_filename(slug)
with true <- File.dir?(path),
{:ok, head_sha} <- Git.resolve(path, @wiki_default_branch),
{:ok, commit} <- Git.commit(path, head_sha),
{:ok, %{type: "blob", sha: blob_sha}} <-
Git.tree_entry_at(path, commit.tree_sha, filename),
{:ok, bytes} <- Git.blob_bytes(path, blob_sha) do
bytes
else
_ -> nil
end
end
# ── writes ───────────────────────────────────────────────────────────
@doc """
Create or update a wiki page. Builds a new tree from the previous one
(if any) with the page added/replaced, commits it, and atomically
updates the wiki branch via CAS on the previous tip SHA.
"""
def put_page(%Repository{} = repo, %User{} = actor, slug, body, opts \\ []) do
:ok = ensure_initialized(repo)
path = wiki_path(repo)
filename = page_filename(slug)
message = Keyword.get(opts, :message, "Update " <> filename)
{parent_sha, parent_tree_sha} = current_tip(path)
with {:ok, blob_sha} <- hash_object(path, body),
{:ok, tree_sha} <- build_tree(path, parent_tree_sha, filename, blob_sha),
{:ok, commit_sha} <-
Git.commit_tree(
path,
tree_sha,
List.wrap(parent_sha),
message,
author: identity(actor)
),
:ok <-
Git.update_ref(
path,
"refs/heads/" <> @wiki_default_branch,
commit_sha,
parent_sha
) do
{:ok, commit_sha}
end
end
@doc "Delete a wiki page. Same plumbing pattern as put_page."
def delete_page(%Repository{} = repo, %User{} = actor, slug, opts \\ []) do
path = wiki_path(repo)
filename = page_filename(slug)
message = Keyword.get(opts, :message, "Delete " <> filename)
{parent_sha, parent_tree_sha} = current_tip(path)
if is_nil(parent_tree_sha) do
{:error, :not_found}
else
with {:ok, tree_sha} <- build_tree(path, parent_tree_sha, filename, :delete),
{:ok, commit_sha} <-
Git.commit_tree(
path,
tree_sha,
[parent_sha],
message,
author: identity(actor)
),
:ok <-
Git.update_ref(
path,
"refs/heads/" <> @wiki_default_branch,
commit_sha,
parent_sha
) do
{:ok, commit_sha}
end
end
end
# ── plumbing helpers ─────────────────────────────────────────────────
defp current_tip(path) do
with {:ok, sha} <- Git.resolve(path, @wiki_default_branch),
{:ok, commit} <- Git.commit(path, sha) do
{sha, commit.tree_sha}
else
_ -> {nil, nil}
end
end
defp hash_object(path, body) do
# Avoid stdin: write the body to a tempfile and let `git
# hash-object -w <file>` slurp it.
tmp =
Path.join(System.tmp_dir!(), "gitgud-blob-#{System.unique_integer([:positive])}")
File.write!(tmp, body)
try do
case System.cmd("git", ["-C", path, "hash-object", "-w", tmp]) do
{hex, 0} -> Git.from_hex(String.trim(hex)) |> wrap()
{err, code} -> {:error, {:git_exit, code, err}}
end
after
_ = File.rm(tmp)
end
end
defp wrap({:ok, sha}), do: {:ok, sha}
defp wrap(:error), do: {:error, :invalid_sha}
@doc false
# Build a new tree by copying entries from `parent_tree_sha` (if any),
# then either replacing/adding `filename` to point at `blob_sha`, or
# removing it if `blob_sha == :delete`.
#
# Implemented via a scratch index file fed to `git read-tree` /
# `git update-index` / `git write-tree`.
defp build_tree(path, parent_tree_sha, filename, blob_or_delete) do
index = Path.join(System.tmp_dir!(), "gitgud-index-#{System.unique_integer([:positive])}")
# `update-index --force-remove` insists on a worktree even for bare
# repos; point it at an empty scratch dir.
work = Path.join(System.tmp_dir!(), "gitgud-wt-#{System.unique_integer([:positive])}")
File.mkdir_p!(work)
env = [
{"GIT_INDEX_FILE", index},
{"GIT_WORK_TREE", work},
{"GIT_DIR", path}
]
try do
if parent_tree_sha do
{_, 0} =
System.cmd("git", ["read-tree", Git.to_hex(parent_tree_sha)],
env: env,
stderr_to_stdout: true
)
end
case blob_or_delete do
:delete ->
{out, code} =
System.cmd("git", ["update-index", "--force-remove", filename],
env: env,
stderr_to_stdout: true
)
if code != 0, do: throw({:update_index, out})
blob_sha when is_binary(blob_sha) ->
{_, 0} =
System.cmd(
"git",
[
"update-index",
"--add",
"--cacheinfo",
"100644," <> Git.to_hex(blob_sha) <> "," <> filename
],
env: env,
stderr_to_stdout: true
)
end
case System.cmd("git", ["write-tree"], env: env, stderr_to_stdout: true) do
{hex, 0} -> Git.from_hex(String.trim(hex)) |> wrap()
{err, code} -> {:error, {:git_exit, code, err}}
end
catch
{:update_index, msg} -> {:error, {:update_index, msg}}
after
_ = File.rm(index)
_ = File.rm_rf(work)
end
end
defp identity(%User{email: email}) do
name = email |> String.split("@") |> hd()
{name, email, DateTime.utc_now(:second)}
end
defp page_filename(slug) do
slug |> String.replace(~r/[^A-Za-z0-9._-]+/, "-") |> Kernel.<>(@page_extension)
end
end
8.3 KiB · text
5af55d9