test/openagents/forge/git_plane_test.exs

main at 58e6347eeb72 · 20 KB

defmodule OpenAgents.Forge.GitPlaneTest do
  @moduledoc """
  Git primitives for the stack service (#46): ref resolution, ancestry,
  merge bases, `merge-tree --write-tree` planning, boundary-based commit
  replay, hidden retention refs, and atomic batch compare-and-swap ref
  updates persisted as one WAL entry per batch.
  """

  use OpenAgents.DataCase, async: false

  alias OpenAgents.Forge.{GitPlane, Repos, Sync, WAL}

  @repo "openagents.com"

  setup do
    base = Path.join(System.tmp_dir!(), "forge-plane-#{System.unique_integer([:positive])}")
    File.mkdir_p!(base)
    previous_data = Application.get_env(:openagents, :forge_data_dir)
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
    previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)

    on_exit(fn ->
      restore_env(:forge_data_dir, previous_data)
      restore_env(:forge_wal_dir, previous_wal)
      restore_env(:forge_wal_adapter, previous_adapter)
      File.rm_rf(base)
    end)

    seed_repo(base)
  end

  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)

  # Commit graph, seeded via plumbing and then recorded as WAL entry 0 so the
  # bare repo is a disposable projection like it is in production:
  #
  #     base ── b1 ── b2 ── c1     (layer-1 = b2, layer-2 = c1)
  #        ├── trunk_x             (main moved past the boundary)
  #        └── conflict_k          (touches the same path as b1)
  #     root_d                     (disconnected history)
  defp seed_repo(base) do
    path = Repos.ensure_repo!(@repo)

    file_base = write_blob(path, "base\n")
    other_base = write_blob(path, "other\n")

    tree_base =
      mktree(path, "100644 blob #{file_base}\tfile.txt\n100644 blob #{other_base}\tother.txt\n")

    base_commit = commit_tree(path, tree_base, [], "Base commit\n")

    file_b1 = write_blob(path, "layer one\n")

    tree_b1 =
      mktree(path, "100644 blob #{file_b1}\tfile.txt\n100644 blob #{other_base}\tother.txt\n")

    b1 = commit_tree(path, tree_b1, ["-p", base_commit], "Layer one, first commit\n")

    b2_extra = write_blob(path, "second\n")

    tree_b2 =
      mktree(
        path,
        "100644 blob #{b2_extra}\tb2.txt\n100644 blob #{file_b1}\tfile.txt\n" <>
          "100644 blob #{other_base}\tother.txt\n"
      )

    b2 = commit_tree(path, tree_b2, ["-p", b1], "Layer one, second commit\n")

    c1_extra = write_blob(path, "layer two\n")

    tree_c1 =
      mktree(
        path,
        "100644 blob #{b2_extra}\tb2.txt\n100644 blob #{c1_extra}\tc1.txt\n" <>
          "100644 blob #{file_b1}\tfile.txt\n100644 blob #{other_base}\tother.txt\n"
      )

    c1 = commit_tree(path, tree_c1, ["-p", b2], "Layer two\n")

    other_x = write_blob(path, "trunk moved\n")

    tree_x =
      mktree(path, "100644 blob #{file_base}\tfile.txt\n100644 blob #{other_x}\tother.txt\n")

    trunk_x = commit_tree(path, tree_x, ["-p", base_commit], "Trunk advance\n")

    file_k = write_blob(path, "conflicting\n")

    tree_k =
      mktree(path, "100644 blob #{file_k}\tfile.txt\n100644 blob #{other_base}\tother.txt\n")

    conflict_k = commit_tree(path, tree_k, ["-p", base_commit], "Conflicting change\n")

    root_blob = write_blob(path, "disconnected\n")
    tree_d = mktree(path, "100644 blob #{root_blob}\td.txt\n")
    root_d = commit_tree(path, tree_d, [], "Disconnected root\n")

    refs = %{
      "refs/heads/main" => trunk_x,
      "refs/heads/layer-1" => b2,
      "refs/heads/layer-2" => c1,
      "refs/heads/boundary" => base_commit,
      "refs/heads/conflicting" => conflict_k,
      "refs/heads/disconnected" => root_d
    }

    Enum.each(refs, fn {name, sha} ->
      {_, 0} = Repos.git(path, ["update-ref", name, sha])
    end)

    bundle = Path.join(base, "seed.bundle")
    {_, 0} = Repos.git(path, ["bundle", "create", bundle, "--all"])
    {:ok, object} = WAL.put_entry_file(@repo, 0, bundle)

    entry = %{
      "seq" => 0,
      "object" => object,
      "format" => "git_bundle",
      "refs" => refs,
      "principal" => "test:seed",
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
    }

    {:ok, _generation} = WAL.cas_index(@repo, :none, WAL.append_entry(WAL.new_index(), entry))
    Repos.record_applied_seq!(@repo, 0)

    %{
      path: path,
      base_commit: base_commit,
      b1: b1,
      b2: b2,
      c1: c1,
      trunk_x: trunk_x,
      conflict_k: conflict_k,
      root_d: root_d,
      refs: refs
    }
  end

  defp write_blob(path, content) do
    {sha, 0} = git_in(path, ["hash-object", "-w", "--stdin"], content)
    String.trim(sha)
  end

  defp mktree(path, listing) do
    {sha, 0} = git_in(path, ["mktree"], listing)
    String.trim(sha)
  end

  defp commit_tree(path, tree, parent_args, message) do
    {sha, 0} =
      git_in(path, ["commit-tree", tree] ++ parent_args, message,
        env: [
          {"GIT_AUTHOR_NAME", "Test Author"},
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
          {"GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z"},
          {"GIT_COMMITTER_NAME", "Test Author"},
          {"GIT_COMMITTER_EMAIL", "author@example.test"},
          {"GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"}
        ]
      )

    String.trim(sha)
  end

  defp git_in(path, args, stdin, opts \\ []) do
    input = Path.join(System.tmp_dir!(), "plane-stdin-#{System.unique_integer([:positive])}")
    File.write!(input, stdin)

    try do
      System.cmd(
        "sh",
        ["-c", ~s(exec git --git-dir "$GD" "$@" < "$IN"), "sh"] ++ args,
        env: [{"GD", path}, {"IN", input}] ++ Keyword.get(opts, :env, [])
      )
    after
      File.rm(input)
    end
  end

  defp show(path, args) do
    {output, 0} = Repos.git(path, args)
    String.trim(output)
  end

  describe "resolve_commit/2" do
    test "resolves a branch, a full OID, and a short OID", %{b2: b2} do
      assert {:ok, ^b2} = GitPlane.resolve_commit(@repo, "layer-1")
      assert {:ok, ^b2} = GitPlane.resolve_commit(@repo, b2)
      assert {:ok, ^b2} = GitPlane.resolve_commit(@repo, String.slice(b2, 0, 10))
    end

    test "an unknown or malformed ref is :not_found" do
      assert {:error, :not_found} = GitPlane.resolve_commit(@repo, "no-such-branch")
      assert {:error, :not_found} = GitPlane.resolve_commit(@repo, "-evil")
      assert {:error, :not_found} = GitPlane.resolve_commit(@repo, "a..b")
    end
  end

  describe "ancestor?/3" do
    test "reports ancestry along and across branches", %{
      base_commit: base_commit,
      b2: b2,
      trunk_x: trunk_x
    } do
      assert {:ok, true} = GitPlane.ancestor?(@repo, base_commit, b2)
      assert {:ok, false} = GitPlane.ancestor?(@repo, b2, base_commit)
      assert {:ok, false} = GitPlane.ancestor?(@repo, b2, trunk_x)
      assert {:ok, true} = GitPlane.ancestor?(@repo, b2, b2)
    end
  end

  describe "containing/3" do
    test "answers the whole matrix in one read", %{
      base_commit: base_commit,
      b1: b1,
      b2: b2,
      c1: c1,
      trunk_x: trunk_x
    } do
      assert {:ok, matrix} = GitPlane.containing(@repo, [b1, trunk_x], [b2, c1, trunk_x])

      assert matrix[b1] == [b2, c1]
      assert matrix[trunk_x] == [trunk_x]

      assert {:ok, %{^base_commit => carried}} =
               GitPlane.containing(@repo, [base_commit], [b2, trunk_x])

      assert carried == [b2, trunk_x]
    end

    test "a revision the repository does not have contains nothing", %{b2: b2} do
      absent = String.duplicate("a", 40)

      assert {:ok, matrix} = GitPlane.containing(@repo, [absent, b2], [b2, absent])
      assert matrix[absent] == []
      assert matrix[b2] == [b2]
    end

    test "refuses a matrix past the pair bound rather than scanning it", %{b2: b2} do
      candidates = List.duplicate(b2, 65)

      assert {:error, :too_many_pairs} = GitPlane.containing(@repo, [b2], candidates)
      assert {:ok, _matrix} = GitPlane.containing(@repo, [b2], Enum.take(candidates, 64))
    end

    test "an empty ask is an empty answer", %{b2: b2} do
      assert {:ok, %{}} = GitPlane.containing(@repo, [], [b2])
      assert {:ok, %{^b2 => []}} = GitPlane.containing(@repo, [b2], [])
      assert {:error, :not_found} = GitPlane.containing(@repo, b2, [b2])
    end
  end

  describe "merge_base/3" do
    test "finds the common ancestor of diverged branches", %{base_commit: base_commit} do
      assert {:ok, ^base_commit} = GitPlane.merge_base(@repo, "layer-1", "main")
    end

    test "disconnected histories have no merge base", %{root_d: root_d, b2: b2} do
      assert {:error, :no_merge_base} = GitPlane.merge_base(@repo, root_d, b2)
    end
  end

  describe "merge_tree/4" do
    test "a clean merge returns the merged tree without touching refs", %{path: path, refs: refs} do
      assert {:ok, %{tree: tree}} = GitPlane.merge_tree(@repo, "layer-1", "main")
      assert show(path, ["cat-file", "blob", tree <> ":file.txt"]) == "layer one"
      assert show(path, ["cat-file", "blob", tree <> ":other.txt"]) == "trunk moved"
      assert Repos.refs(@repo) == refs
    end

    test "a conflict returns structured path data", %{refs: refs} do
      assert {:conflict, conflict} = GitPlane.merge_tree(@repo, "layer-1", "conflicting")
      assert conflict.paths == ["file.txt"]
      assert Enum.all?(conflict.files, &(&1.path == "file.txt"))
      assert Enum.map(conflict.files, & &1.stage) == ["1", "2", "3"]
      assert Regex.match?(~r/\A[0-9a-f]{40,64}\z/, conflict.tree)
      assert Repos.refs(@repo) == refs
    end

    test "an explicit merge base is honored", %{b1: b1, b2: b2, path: path} do
      assert {:ok, %{tree: tree}} = GitPlane.merge_tree(@repo, "main", b2, merge_base: b1)
      assert show(path, ["cat-file", "blob", tree <> ":other.txt"]) == "trunk moved"
    end
  end

  describe "replay/4" do
    test "replays only commits after the boundary onto the new parent", %{
      path: path,
      base_commit: base_commit,
      b1: b1,
      b2: b2,
      trunk_x: trunk_x
    } do
      assert {:ok, %{new_head: new_head, replayed: replayed}} =
               GitPlane.replay(@repo, base_commit, b2, trunk_x)

      assert [%{old: ^b1, new: new_b1}, %{old: ^b2, new: new_b2}] = replayed
      assert new_head == new_b2
      assert show(path, ["rev-parse", new_b1 <> "^"]) == trunk_x
      assert show(path, ["rev-parse", new_b2 <> "^"]) == new_b1
      assert show(path, ["cat-file", "blob", new_b2 <> ":file.txt"]) == "layer one"
      assert show(path, ["cat-file", "blob", new_b2 <> ":other.txt"]) == "trunk moved"

      assert show(path, ["show", "-s", "--format=%an <%ae>", new_b1]) ==
               "Test Author <author@example.test>"

      assert show(path, ["show", "-s", "--format=%cn <%ce>", new_b1]) ==
               "OpenAgents Forge <forge@openagents.com>"

      assert show(path, ["show", "-s", "--format=%s", new_b1]) == "Layer one, first commit"
    end

    test "an empty range returns the new parent unchanged", %{b2: b2, trunk_x: trunk_x} do
      assert {:ok, %{new_head: ^trunk_x, replayed: []}} =
               GitPlane.replay(@repo, b2, b2, trunk_x)
    end

    test "a conflicting commit reports the commit, paths, and completed steps", %{
      base_commit: base_commit,
      b1: b1,
      conflict_k: conflict_k
    } do
      assert {:conflict, conflict} = GitPlane.replay(@repo, base_commit, conflict_k, b1)
      assert conflict.commit == conflict_k
      assert conflict.onto == b1
      assert conflict.paths == ["file.txt"]
      assert conflict.replayed == []
    end

    test "a merge commit in the range is rejected", %{
      path: path,
      base_commit: base_commit,
      b2: b2,
      trunk_x: trunk_x
    } do
      tree = show(path, ["rev-parse", b2 <> "^{tree}"])
      merge = commit_tree(path, tree, ["-p", b2, "-p", trunk_x], "Merge\n")

      assert {:error, {:merge_commit, ^merge}} =
               GitPlane.replay(@repo, base_commit, merge, trunk_x)
    end
  end

  describe "internal_ref/1" do
    test "builds hidden retention ref names" do
      assert {:ok, "refs/internal/stacks/7/boundary"} =
               GitPlane.internal_ref(["stacks", "7", "boundary"])
    end

    test "rejects unsafe segments" do
      assert {:error, :invalid_ref} = GitPlane.internal_ref(["a/b"])
      assert {:error, :invalid_ref} = GitPlane.internal_ref(["-flag"])
      assert {:error, :invalid_ref} = GitPlane.internal_ref([""])
      assert {:error, :invalid_ref} = GitPlane.internal_ref(["x.lock"])
    end
  end

  describe "batch_update_refs/3" do
    test "applies every ref in one WAL transition and survives cache loss", %{
      b2: b2,
      base_commit: base_commit,
      refs: refs
    } do
      {:ok, retention} = GitPlane.internal_ref(["stacks", "1", "boundary"])

      updates = [
        %{ref: "refs/heads/stack-1", expected_old: :absent, new: b2},
        %{ref: retention, expected_old: :absent, new: base_commit}
      ]

      assert {:ok, %{seq: 1, refs: refs_after}} =
               GitPlane.batch_update_refs(@repo, updates, "test:batch")

      assert refs_after ==
               Map.merge(refs, %{"refs/heads/stack-1" => b2, retention => base_commit})

      {:ok, _generation, index} = WAL.read_index(@repo)
      assert [_seed, batch_entry] = WAL.entries(index)
      assert batch_entry["principal"] == "test:batch"
      assert WAL.refs(index) == refs_after

      File.rm_rf!(Repos.bare_path(@repo))
      assert :ok = Sync.ensure_fresh(@repo)
      assert Repos.refs(@repo) == refs_after
    end

    test "a mismatched expected OID rejects the whole batch", %{
      b2: b2,
      trunk_x: trunk_x,
      refs: refs
    } do
      updates = [
        %{ref: "refs/heads/stack-1", expected_old: :absent, new: b2},
        %{ref: "refs/heads/main", expected_old: b2, new: b2}
      ]

      assert {:error, {:expected_mismatch, "refs/heads/main", ^trunk_x}} =
               GitPlane.batch_update_refs(@repo, updates, "test:batch")

      assert Repos.refs(@repo) == refs
      {:ok, _generation, index} = WAL.read_index(@repo)
      assert length(WAL.entries(index)) == 1
    end

    test "a git-rejected transaction applies none of the batch", %{b2: b2, refs: refs} do
      updates = [
        %{ref: "refs/heads/stack-1", expected_old: :absent, new: b2},
        %{ref: "refs/heads/main/nested", expected_old: :absent, new: b2}
      ]

      assert {:error, :ref_update_failed} =
               GitPlane.batch_update_refs(@repo, updates, "test:batch")

      assert Repos.refs(@repo) == refs
    end

    test "deletes and moves to known OIDs persist without a bundle", %{
      b2: b2,
      c1: c1,
      base_commit: base_commit
    } do
      updates = [
        %{ref: "refs/heads/layer-2", expected_old: c1, new: :delete},
        %{ref: "refs/heads/boundary", expected_old: base_commit, new: b2}
      ]

      assert {:ok, %{seq: 1, refs: refs_after}} =
               GitPlane.batch_update_refs(@repo, updates, "test:batch")

      refute Map.has_key?(refs_after, "refs/heads/layer-2")
      assert refs_after["refs/heads/boundary"] == b2

      {:ok, _generation, index} = WAL.read_index(@repo)
      assert [_seed, batch_entry] = WAL.entries(index)
      assert batch_entry["format"] == "ref_update"

      File.rm_rf!(Repos.bare_path(@repo))
      assert :ok = Sync.ensure_fresh(@repo)
      assert Repos.refs(@repo) == refs_after
    end

    test "malformed updates never reach git", %{b2: b2} do
      assert {:error, :invalid_update} =
               GitPlane.batch_update_refs(
                 @repo,
                 [%{ref: "main", expected_old: :absent, new: b2}],
                 "t"
               )

      assert {:error, :invalid_update} =
               GitPlane.batch_update_refs(
                 @repo,
                 [%{ref: "refs/heads/x", expected_old: :absent, new: "not-an-oid"}],
                 "t"
               )

      assert {:error, :invalid_update} =
               GitPlane.batch_update_refs(
                 @repo,
                 [
                   %{ref: "refs/heads/x", expected_old: :absent, new: b2},
                   %{ref: "refs/heads/x", expected_old: :absent, new: b2}
                 ],
                 "t"
               )

      assert {:error, :invalid_update} =
               GitPlane.batch_update_refs(
                 @repo,
                 [%{ref: "refs/heads/x", expected_old: :absent, new: :delete}],
                 "t"
               )
    end

    test "hidden internal refs are not advertised to clients", %{
      path: path,
      base_commit: base_commit
    } do
      {:ok, retention} = GitPlane.internal_ref(["stacks", "1", "boundary"])

      assert {:ok, _result} =
               GitPlane.batch_update_refs(
                 @repo,
                 [%{ref: retention, expected_old: :absent, new: base_commit}],
                 "test:batch"
               )

      {advertised, 0} =
        OpenAgents.Forge.GitHTTP.run_git_service(
          "upload-pack",
          ["--advertise-refs", path],
          "",
          nil
        )

      refute advertised =~ "refs/internal/"
      assert advertised =~ "refs/heads/main"
    end

    test "concurrent writers with one expected OID produce exactly one winner", %{
      path: path,
      base_commit: base_commit,
      trunk_x: trunk_x
    } do
      tree = show(path, ["rev-parse", base_commit <> "^{tree}"])

      results =
        1..6
        |> Task.async_stream(
          fn n ->
            commit = commit_tree(path, tree, ["-p", trunk_x], "Contender #{n}\n")

            GitPlane.batch_update_refs(
              @repo,
              [%{ref: "refs/heads/main", expected_old: trunk_x, new: commit}],
              "test:writer-#{n}"
            )
          end,
          timeout: :infinity
        )
        |> Enum.map(fn {:ok, result} -> result end)

      assert Enum.count(results, &match?({:ok, _}, &1)) == 1

      assert Enum.count(
               results,
               &match?({:error, {:expected_mismatch, "refs/heads/main", _}}, &1)
             ) ==
               5
    end

    test "concurrent batches never interleave: paired refs move together in every WAL entry", %{
      path: path,
      base_commit: base_commit
    } do
      tree = show(path, ["rev-parse", base_commit <> "^{tree}"])

      pair = ["refs/heads/pair-a", "refs/heads/pair-b"]

      assert {:ok, _result} =
               GitPlane.batch_update_refs(
                 @repo,
                 Enum.map(pair, &%{ref: &1, expected_old: :absent, new: base_commit}),
                 "test:pair-seed"
               )

      writers = 6

      1..writers
      |> Task.async_stream(
        fn n -> advance_pair(path, tree, pair, n, 20) end,
        timeout: :infinity
      )
      |> Enum.each(fn result -> assert {:ok, :ok} = result end)

      {:ok, _generation, index} = WAL.read_index(@repo)
      entries = WAL.entries(index)
      assert length(entries) == writers + 2

      entries
      |> Enum.drop(1)
      |> Enum.chunk_every(2, 1, :discard)
      |> Enum.each(fn [previous, entry] ->
        changed =
          entry["refs"]
          |> Enum.filter(fn {name, sha} -> previous["refs"][name] != sha end)
          |> Enum.map(&elem(&1, 0))
          |> Enum.sort()

        assert changed == pair,
               "WAL entry #{entry["seq"]} interleaved a batch: changed #{inspect(changed)}"
      end)
    end
  end

  # Retry loop for the interleave test: read the live pair tips, build one
  # new commit on each, and CAS both refs in one batch.
  defp advance_pair(_path, _tree, _pair, _n, 0), do: {:error, :retries_exhausted}

  defp advance_pair(path, tree, [ref_a, ref_b] = pair, n, retries) do
    refs = Repos.refs(@repo)
    old_a = Map.fetch!(refs, ref_a)
    old_b = Map.fetch!(refs, ref_b)
    new_a = commit_tree(path, tree, ["-p", old_a], "Pair A by writer #{n}\n")
    new_b = commit_tree(path, tree, ["-p", old_b], "Pair B by writer #{n}\n")

    case GitPlane.batch_update_refs(
           @repo,
           [
             %{ref: ref_a, expected_old: old_a, new: new_a},
             %{ref: ref_b, expected_old: old_b, new: new_b}
           ],
           "test:pair-#{n}"
         ) do
      {:ok, _result} ->
        :ok

      {:error, {:expected_mismatch, _ref, _actual}} ->
        advance_pair(path, tree, pair, n, retries - 1)
    end
  end
end