lib/openagents/forge/browse.ex

58e6347eeb72 · 13 KB

defmodule OpenAgents.Forge.Browse do
  @moduledoc """
  Bounded, read-only git plumbing for the forge web UI (#136/#137): resolve
  refs, read blobs/trees/commits/diffs from the bare repos `OpenAgents.Forge.Repos`
  maintains. Pure reads — never writes a ref, never touches the WAL directly;
  `OpenAgents.Forge.Sync.ensure_fresh/1` runs first so a stale cache converges the
  same way it does for smart-HTTP reads.

  Every invocation is argv-only (the `Repos.git/3` discipline) and every
  output is size-bounded with honest truncation flags: a public page must
  never stream an unbounded `git show` to an anonymous socket.
  """

  alias OpenAgents.Forge.{Repos, Sync}
  alias OpenAgents.Repositories.Repository

  @ref_pattern ~r|^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$|
  @path_pattern ~r|^[^\x00]{1,512}$|
  @blob_cap 1_000_000
  @diff_cap 500_000
  @message_cap 65_536
  @list_cap 400

  @doc "Whether `ref` is shaped like a ref/sha we will pass to git at all."
  def valid_ref?(ref) when is_binary(ref) do
    Regex.match?(@ref_pattern, ref) and not String.contains?(ref, "..")
  end

  def valid_ref?(_), do: false

  @doc "Whether `path` is shaped like a repo-relative path we will pass to git."
  def valid_path?(path) when is_binary(path) do
    Regex.match?(@path_pattern, path) and not String.starts_with?(path, "/") and
      not String.contains?(path, "..") and not String.starts_with?(path, "-")
  end

  def valid_path?(_), do: false

  @doc "Resolve a branch name or (short) sha to a full commit sha."
  def resolve_commit(repo, ref) do
    with :ok <- check(repo, ref) do
      freshen(repo)
      resolve_commit_from_cache(repo, ref)
    end
  end

  defp freshen(repo) do
    Sync.ensure_fresh!(storage_key(repo), default_branch(repo))
  end

  @doc "The default branch head, if the repo has one."
  def head(repo) do
    _ = freshen(repo)
    head_from_cache(repo)
  end

  @doc "Read all repository-home data after one cache-freshness check."
  def overview(repo, log_limit \\ 20) do
    _ = freshen(repo)

    head =
      case head_from_cache(repo) do
        {:ok, sha} -> sha
        _ -> nil
      end

    %{
      head: head,
      readme: overview_readme(repo, head),
      commits: overview_commits(repo, head, log_limit),
      entries: overview_entries(repo, head),
      refs: if(head, do: refs_from_cache(repo), else: [])
    }
  end

  defp head_from_cache(repo) do
    refs = Repos.refs(storage_key(repo))

    case Map.fetch(refs, "refs/heads/#{default_branch(repo)}") do
      {:ok, sha} -> {:ok, sha}
      :error when map_size(refs) > 0 -> {:ok, refs |> Enum.sort() |> hd() |> elem(1)}
      _ -> {:error, :empty}
    end
  end

  @doc "Branches and tags as `[%{name, sha, kind}]`, bounded."
  def refs(repo), do: refs_from_cache(repo)

  defp refs_from_cache(repo) do
    repo
    |> storage_key()
    |> Repos.refs()
    |> Enum.flat_map(fn {name, sha} ->
      case name do
        "refs/heads/" <> short -> [%{name: short, sha: sha, kind: :branch}]
        "refs/tags/" <> short -> [%{name: short, sha: sha, kind: :tag}]
        _ -> []
      end
    end)
    |> Enum.sort_by(&{&1.kind, &1.name})
    |> Enum.take(@list_cap)
  end

  defp resolve_commit_from_cache(repo, ref) do
    case git(repo, ["rev-parse", "--verify", "--end-of-options", ref <> "^{commit}"]) do
      {output, 0} -> {:ok, String.trim(output)}
      _ -> {:error, :not_found}
    end
  end

  defp overview_readme(_repo, nil), do: nil

  defp overview_readme(repo, head) do
    Enum.find_value(["README.md", "README"], fn name ->
      case blob_from_cache(repo, head, name) do
        {:ok, blob} -> %{name: name, blob: blob}
        _ -> nil
      end
    end)
  end

  defp overview_commits(_repo, nil, _limit), do: []

  defp overview_commits(repo, head, limit) do
    case log_from_cache(repo, head, limit) do
      {:ok, commits} -> commits
      _ -> []
    end
  end

  defp overview_entries(_repo, nil), do: []

  defp overview_entries(repo, head) do
    case tree_from_cache(repo, head, "") do
      {:ok, entries} -> entries
      _ -> []
    end
  end

  @doc """
  One commit's bounded metadata: message, author name, committed-at, parents,
  and its provenance trailers (`Co-Authored-By`, `Claude-Session`,
  `Changelog*` — the fields the transparency spec renders first-class).
  Author emails are deliberately not returned.
  """
  def commit(repo, sha) do
    with {:ok, full} <- resolve_commit(repo, sha) do
      format = "%H%x00%an%x00%cI%x00%P%x00%B"

      case git(repo, ["show", "-s", "--format=" <> format, "--end-of-options", full]) do
        {output, 0} ->
          [h, author, date, parents, message] = String.split(output, "\x00", parts: 5)
          message = truncate(String.trim_trailing(message), @message_cap)

          {:ok,
           %{
             sha: h,
             author: author,
             committed_at: date,
             parents: String.split(parents, " ", trim: true),
             subject: message |> String.split("\n", parts: 2) |> hd(),
             message: message,
             trailers: trailers(message)
           }}

        _ ->
          {:error, :not_found}
      end
    end
  end

  @doc "Changed files for a commit as `[%{status, path}]`, bounded."
  def changed_files(repo, sha) do
    with {:ok, full} <- resolve_commit(repo, sha) do
      case git(repo, [
             "diff-tree",
             "--no-commit-id",
             "--name-status",
             "-r",
             "-M",
             "--root",
             "--end-of-options",
             full
           ]) do
        {output, 0} ->
          files =
            output
            |> String.split("\n", trim: true)
            |> Enum.take(@list_cap)
            |> Enum.flat_map(fn line ->
              case String.split(line, "\t") do
                [status | paths] when paths != [] -> [%{status: status, path: List.last(paths)}]
                _ -> []
              end
            end)

          {:ok, files}

        _ ->
          {:error, :not_found}
      end
    end
  end

  @doc "The unified diff for a commit, byte-capped with a truncation flag."
  def diff(repo, sha) do
    with {:ok, full} <- resolve_commit(repo, sha) do
      case git(repo, ["diff-tree", "-p", "-M", "--root", "--no-color", "--end-of-options", full]) do
        {output, 0} -> {:ok, truncate(output, @diff_cap), byte_size(output) > @diff_cap}
        _ -> {:error, :not_found}
      end
    end
  end

  @doc "The unified diff between two commits, byte-capped with a truncation flag."
  def diff_range(repo, base, head) do
    with :ok <- check(repo, head),
         {:ok, base_full} <- resolve_commit(repo, base),
         {:ok, head_full} <- resolve_commit_from_cache(repo, head) do
      case git(repo, ["diff", "-M", "--no-color", "--end-of-options", base_full, head_full]) do
        {output, 0} -> {:ok, truncate(output, @diff_cap), byte_size(output) > @diff_cap}
        _ -> {:error, :not_found}
      end
    end
  end

  @doc "Directory listing at `ref`/`path` as `[%{name, kind, size}]`, bounded."
  def tree(repo, ref, path \\ "") do
    with :ok <- check(repo, ref),
         :ok <- check_path(path, allow_empty: true) do
      _ = freshen(repo)
      tree_from_cache(repo, ref, path)
    end
  end

  defp tree_from_cache(repo, ref, path) do
    with {:ok, full} <- resolve_commit_from_cache(repo, ref) do
      spec = if path == "", do: full, else: full <> ":" <> path

      case git(repo, ["ls-tree", "-l", "--end-of-options", spec]) do
        {output, 0} ->
          entries =
            output
            |> String.split("\n", trim: true)
            |> Enum.take(@list_cap)
            |> Enum.flat_map(fn line ->
              with [meta, name] <- String.split(line, "\t", parts: 2),
                   [_mode, kind, _sha, size] <- String.split(meta, " ", trim: true) do
                [%{name: name, kind: kind, size: parse_size(size)}]
              else
                _ -> []
              end
            end)
            |> Enum.sort_by(&{&1.kind != "tree", &1.name})

          {:ok, entries}

        _ ->
          {:error, :not_found}
      end
    end
  end

  @doc """
  A file's bounded content at `ref`/`path`:
  `{:ok, %{content, truncated, binary, size}}`.
  """
  def blob(repo, ref, path) do
    with :ok <- check(repo, ref),
         :ok <- check_path(path) do
      _ = freshen(repo)
      blob_from_cache(repo, ref, path)
    end
  end

  @doc "Read file-page data from one locked projection after one freshness check."
  def blob_page(repo, ref, path) do
    with :ok <- check(repo, ref),
         :ok <- check_path(path) do
      Sync.with_repo_lock(storage_key(repo), fn ->
        _ = freshen(repo)

        with {:ok, sha} <- resolve_commit_from_cache(repo, ref) do
          head = with {:ok, current} <- head_from_cache(repo), do: current

          with {:ok, blob} <- blob_at_commit_from_cache(repo, sha, path) do
            {:ok, %{sha: sha, head: head, blob: blob}}
          end
        end
      end)
    end
  end

  defp blob_from_cache(repo, ref, path) do
    with {:ok, full} <- resolve_commit_from_cache(repo, ref) do
      blob_at_commit_from_cache(repo, full, path)
    end
  end

  defp blob_at_commit_from_cache(repo, commit, path) do
    spec = commit <> ":" <> path

    with {size_out, 0} <- git(repo, ["cat-file", "-s", spec]),
         {size, _} <- Integer.parse(String.trim(size_out)),
         {output, 0} <- git(repo, ["cat-file", "blob", spec]) do
      content = truncate(output, @blob_cap)

      {:ok,
       %{
         content: content,
         truncated: size > @blob_cap,
         binary: binary_content?(content),
         size: size
       }}
    else
      _ -> {:error, :not_found}
    end
  end

  @doc "The README blob at a commit, if one exists."
  def readme(repo, ref) do
    Enum.find_value(["README.md", "README"], {:error, :not_found}, fn name ->
      case blob(repo, ref, name) do
        {:ok, found} -> {:ok, name, found}
        _ -> nil
      end
    end)
  end

  @doc "Bounded commit list from `ref` as commit maps (no diffs)."
  def log(repo, ref, limit \\ 30) do
    with :ok <- check(repo, ref) do
      _ = freshen(repo)
      log_from_cache(repo, ref, limit)
    end
  end

  defp log_from_cache(repo, ref, limit) do
    limit = min(limit, 100)

    with {:ok, full} <- resolve_commit_from_cache(repo, ref) do
      format = "%H%x00%an%x00%cI%x00%s%x01"

      case git(repo, [
             "log",
             "-n",
             Integer.to_string(limit),
             "--format=" <> format,
             "--end-of-options",
             full
           ]) do
        {output, 0} ->
          commits =
            output
            |> String.split("\x01", trim: true)
            |> Enum.flat_map(fn record ->
              case record |> String.trim_leading("\n") |> String.split("\x00", parts: 4) do
                [sha, author, date, subject] ->
                  [%{sha: sha, author: author, committed_at: date, subject: subject}]

                _ ->
                  []
              end
            end)

          {:ok, commits}

        _ ->
          {:error, :not_found}
      end
    end
  end

  @known_trailers ~w(Co-Authored-By Claude-Session Changelog Changelog-Category Changelog-Visibility)

  @doc "Parse provenance/changelog trailers out of a commit message."
  def trailers(message) do
    message
    |> String.split("\n")
    |> Enum.flat_map(fn line ->
      case Regex.run(~r/^([A-Za-z][A-Za-z-]{1,40}):\s+(.{1,500})$/, String.trim(line)) do
        [_, key, value] when key in @known_trailers -> [{key, String.trim(value)}]
        _ -> []
      end
    end)
  end

  # ── internals ────────────────────────────────────────────────────────────

  defp check(repo, ref) do
    cond do
      not Repos.valid_storage_key?(storage_key(repo)) -> {:error, :not_found}
      not valid_ref?(ref) -> {:error, :not_found}
      true -> :ok
    end
  end

  defp check_path(path, opts \\ []) do
    cond do
      path == "" and Keyword.get(opts, :allow_empty, false) -> :ok
      valid_path?(path) -> :ok
      true -> {:error, :not_found}
    end
  end

  defp git(repo, args), do: Repos.git(Repos.bare_path(storage_key(repo)), args)

  defp storage_key(%Repository{storage_key: storage_key}), do: storage_key
  defp storage_key(storage_key) when is_binary(storage_key), do: storage_key

  defp default_branch(%Repository{default_branch: default_branch}), do: default_branch
  defp default_branch(_storage_key), do: "main"

  defp truncate(binary, cap) when byte_size(binary) > cap,
    do: binary |> binary_part(0, cap) |> trim_to_valid_utf8()

  defp truncate(binary, _cap), do: binary

  # A byte-capped cut can split a multibyte character; trimming at most three
  # trailing bytes repairs that. Binary (non-UTF-8) content stays as-is — the
  # `binary` flag keeps it out of text rendering entirely.
  defp trim_to_valid_utf8(binary), do: trim_to_valid_utf8(binary, 3)

  defp trim_to_valid_utf8(binary, 0), do: binary

  defp trim_to_valid_utf8(binary, tries) do
    cond do
      String.valid?(binary) -> binary
      byte_size(binary) == 0 -> binary
      true -> trim_to_valid_utf8(binary_part(binary, 0, byte_size(binary) - 1), tries - 1)
    end
  end

  defp binary_content?(content) do
    content |> binary_part(0, min(byte_size(content), 8_192)) |> String.contains?(<<0>>)
  end

  defp parse_size("-"), do: nil

  defp parse_size(size) do
    case Integer.parse(size) do
      {n, _} -> n
      :error -> nil
    end
  end
end