lib/openagents/forge/repos.ex

58e6347eeb72 · 10 KB

defmodule OpenAgents.Forge.Repos do
  @moduledoc """
  Bare git repositories on the node's stateful partition — the warm cache
  side of the forge. Ref truth is the WAL (`OpenAgents.Forge.WAL`); everything
  here can be deleted and re-materialized from it (`OpenAgents.Forge.Sync`).

  All git invocations are argv-only against `--git-dir`; no shell strings
  ever carry request data.

  ## Names are not storage keys

  Every function here that reaches the disk takes a **storage key** — the
  opaque, unique segment `Repository.storage_key` holds — and never a
  repository **name**. `allowed_repos/0` and `valid_name?/1` are the only two
  that speak in names, and a name is what a person has: `openagents.com`, or
  the `owner/name` path they clone.

  The two are both strings and a name is a legal path segment, so passing one
  where the other belongs builds a path rather than failing. `bare_path/1` on a
  name produces a directory beside the real repository that projects nothing —
  which is what issue #190 found on the live node. `OpenAgents.Forge.RepoRef`
  is the one place a name becomes a key; call it before calling anything here.
  """

  require Logger

  @typedoc "What a person has: a repository name, or an `owner/name` path."
  @type name :: String.t()

  @typedoc "What this module keys every path with: see `OpenAgents.Forge.RepoRef`."
  @type storage_key :: String.t()

  @name_pattern ~r/^[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9])){0,63}$/
  @storage_key_pattern ~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/

  @doc "The forge data directory (bare repos + WAL cache + beam artifacts)."
  def data_dir do
    Application.get_env(:openagents, :forge_data_dir) ||
      "/var/lib/openagents/forge"
  end

  @doc """
  Repository *names* this forge serves operationally. Bounded, config-owned.

  These are names, not storage keys: `["openagents.com"]` is the name of the
  repository whose storage key is a UUID. Resolve one with
  `OpenAgents.Forge.RepoRef.storage_key/1` before using it as a path segment.
  """
  @spec allowed_repos() :: [name()]
  def allowed_repos do
    Application.get_env(:openagents, :forge_repos, ["openagents.com"])
  end

  @doc "Whether `name` is a well-formed name this forge serves operationally."
  @spec valid_name?(term()) :: boolean()
  def valid_name?(name) when is_binary(name) do
    Regex.match?(@name_pattern, name) and name in allowed_repos()
  end

  def valid_name?(_), do: false

  @doc """
  Whether an opaque repository storage key is safe as one path segment.

  Shape only. A repository name is shaped like a storage key, so this admits
  one; only `OpenAgents.Forge.RepoRef` can tell you which you are holding.
  """
  @spec valid_storage_key?(term()) :: boolean()
  def valid_storage_key?(storage_key) when is_binary(storage_key),
    do: Regex.match?(@storage_key_pattern, storage_key)

  def valid_storage_key?(_storage_key), do: false

  @doc "Absolute path of the bare repository for `storage_key`."
  @spec bare_path(storage_key()) :: String.t()
  def bare_path(storage_key), do: Path.join([data_dir(), "repos", storage_key <> ".git"])

  @doc "Delete one repository's disposable local bare-repository cache."
  @spec delete_repo(storage_key()) :: :ok | {:error, term()}
  def delete_repo(storage_key) do
    if valid_storage_key?(storage_key) do
      case File.rm_rf(bare_path(storage_key)) do
        {:ok, _removed} -> :ok
        {:error, reason, _path} -> {:error, reason}
      end
    else
      {:error, :invalid_storage_key}
    end
  end

  @doc "Initialize the bare repository if absent. Returns the path."
  @spec ensure_repo!(storage_key(), String.t()) :: String.t()
  def ensure_repo!(storage_key, default_branch \\ "main") do
    storage_key |> bare_path() |> ensure_repo_at!(default_branch)
  end

  @doc false
  def ensure_repo_at!(path, default_branch \\ "main") do
    quarantine_invalid_cache!(path)

    unless File.exists?(Path.join(path, "HEAD")) do
      File.mkdir_p!(path)
      {_, 0} = git(path, ["init", "--bare", "--initial-branch=#{default_branch}", path])
    end

    set_default_branch_at!(path, default_branch)
    hide_internal_refs_at!(path)

    path
  end

  # The cache is a disposable projection of the WAL. A directory that has a
  # HEAD file but that git refuses as a bare repository (for example one whose
  # refs directory was lost mid-write) would otherwise crash every read of the
  # repository forever. Move it aside so the caller reinitializes an empty
  # repository and WAL replay re-materializes every ref.
  defp quarantine_invalid_cache!(path) do
    if File.exists?(Path.join(path, "HEAD")) and not bare_repository_at?(path) do
      suffix = System.unique_integer([:positive, :monotonic])
      quarantine_path = path <> ".corrupt-#{suffix}"

      case File.rename(path, quarantine_path) do
        :ok ->
          Logger.warning(
            "forge_repo_cache_quarantined path=#{path} quarantine=#{quarantine_path}"
          )

          :ok

        {:error, reason} ->
          raise "cannot quarantine invalid repository cache #{path}: #{inspect(reason)}"
      end
    end

    :ok
  end

  defp bare_repository_at?(path) do
    match?({"true" <> _rest, 0}, git(path, ["rev-parse", "--is-bare-repository"]))
  end

  @spec set_default_branch!(storage_key(), String.t()) :: :ok
  def set_default_branch!(storage_key, default_branch) do
    storage_key |> bare_path() |> set_default_branch_at!(default_branch)
  end

  @doc false
  def set_default_branch_at!(path, default_branch) do
    {_, 0} = git(path, ["symbolic-ref", "HEAD", "refs/heads/#{default_branch}"])
    :ok
  end

  # Hidden internal refs (`refs/internal/`) retain stack boundary commits
  # (`OpenAgents.Forge.GitPlane`) without advertising them to git clients.
  defp hide_internal_refs_at!(path) do
    case git(path, ["config", "--get", "transfer.hideRefs"]) do
      {"refs/internal/" <> _rest, 0} ->
        :ok

      _unset ->
        {_, 0} = git(path, ["config", "transfer.hideRefs", "refs/internal/"])
        :ok
    end
  end

  @doc "Current refs of the bare repo as a `%{name => sha}` map."
  @spec refs(storage_key()) :: %{String.t() => String.t()}
  def refs(storage_key) do
    storage_key |> bare_path() |> refs_at()
  end

  @doc false
  def refs_at(path) do
    case git(path, ["for-each-ref", "--format=%(objectname) %(refname)"]) do
      {output, 0} ->
        output
        |> String.split("\n", trim: true)
        |> Map.new(fn line ->
          [sha, name] = String.split(line, " ", parts: 2)
          {name, sha}
        end)

      _error ->
        %{}
    end
  end

  @doc """
  Force the repo's refs to exactly `target_refs` (used for post-failure
  rollback and WAL materialization convergence). Deletes refs not present
  in the target.
  """
  @spec set_refs!(storage_key(), map()) :: :ok
  def set_refs!(storage_key, target_refs) when is_map(target_refs) do
    storage_key |> bare_path() |> set_refs_at!(target_refs)
  end

  @doc false
  def set_refs_at!(path, target_refs) when is_map(target_refs) do
    current = refs_at(path)

    Enum.each(current, fn {name, _sha} ->
      unless Map.has_key?(target_refs, name) do
        {_, 0} = git(path, ["update-ref", "-d", name])
      end
    end)

    # Replay converges refs after every WAL entry, so skipping the refs that
    # already match keeps a full rebuild at one `update-ref` per changed ref
    # instead of one per ref per entry.
    Enum.each(target_refs, fn {name, sha} ->
      unless Map.get(current, name) == sha do
        {_, 0} = git(path, ["update-ref", name, sha])
      end
    end)

    :ok
  end

  @doc "The WAL sequence this bare repo has applied (cache freshness marker)."
  @spec applied_seq(storage_key()) :: integer()
  def applied_seq(storage_key) do
    storage_key |> bare_path() |> applied_seq_at()
  end

  @doc false
  def applied_seq_at(path) do
    case File.read(Path.join(path, "openagents-wal-seq")) do
      {:ok, contents} ->
        case Integer.parse(String.trim(contents)) do
          {seq, _} -> seq
          :error -> -1
        end

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

  @spec record_applied_seq!(storage_key(), integer()) :: :ok
  def record_applied_seq!(storage_key, seq) when is_integer(seq) do
    storage_key |> bare_path() |> record_applied_seq_at!(seq)
  end

  @doc false
  def record_applied_seq_at!(path, seq) when is_integer(seq) do
    File.write!(Path.join(path, "openagents-wal-seq"), Integer.to_string(seq))
  end

  @doc """
  The WAL sequence at which this bare repo's shallow graft was last checked.

  Separate from the applied sequence because the two answer different
  questions. The applied sequence says which entries materialized; this says
  whether the objects they materialized can be walked from the refs, which is
  what `git upload-pack` needs and what `OpenAgents.Forge.Sync` reconciles.
  A cache written before this marker existed reports `-1` and is therefore
  checked once, which is how an already-damaged projection repairs itself.
  """
  def graft_seq_at(path) do
    case File.read(Path.join(path, "openagents-graft-seq")) do
      {:ok, contents} ->
        case Integer.parse(String.trim(contents)) do
          {seq, _} -> seq
          :error -> -1
        end

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

  @doc false
  def record_graft_seq_at!(path, seq) when is_integer(seq) do
    File.write!(Path.join(path, "openagents-graft-seq"), Integer.to_string(seq))
  end

  @doc "Run git with `--git-dir` pinned to the bare repo. Returns {output, status}."
  def git(git_dir, args, opts \\ []) do
    System.cmd(
      "git",
      ["--git-dir", git_dir | args],
      [stderr_to_stdout: true] ++ opts
    )
  end

  @doc false
  def git_with_stdin(git_dir, args, input, opts \\ []) when is_binary(input) do
    input_path =
      Path.join(
        System.tmp_dir!(),
        "forge-git-input-#{System.unique_integer([:positive])}-#{:erlang.phash2(self())}"
      )

    File.write!(input_path, input)

    try do
      System.cmd(
        "sh",
        [
          "-c",
          ~s(exec git "$@" < "$OPENAGENTS_GIT_INPUT"),
          "sh",
          "--git-dir",
          git_dir | args
        ],
        [env: [{"OPENAGENTS_GIT_INPUT", input_path}], stderr_to_stdout: true] ++ opts
      )
    after
      File.rm(input_path)
    end
  end
end