Add git replay and batch-CAS primitives to the forge git plane

a4408218d293 · Devin AI · · parent 744522f79f0d

Add git replay and batch-CAS primitives to the forge git plane

Give the forge the git primitives a stack service composes (#46):
ref resolution, ancestry checks, merge-base reads, tree-merge planning
with git merge-tree --write-tree, boundary-based commit replay, and
atomic multi-ref updates. Nothing here knows what a pull request is.

Reads run against the bare repo cache after a WAL freshness check.
A batch update validates every expected old OID, applies through one
git update-ref --stdin transaction, and persists one WAL entry per
batch so cache convergence and mirrors see one transition. Boundary
commits stay reachable through hidden refs under refs/internal/,
which transfer.hideRefs keeps out of ref advertisements.

Make Sync.with_repo_lock reentrant: :global locks are not reference
counted, so a nested :global.trans on the same id (ensure_fresh inside
a locked write) released the outer lock and let concurrent writers
interleave. The concurrency tests in git_plane_test.exs caught this.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added lib/openagents/forge/git_plane.ex
  • modified lib/openagents/forge/repos.ex
  • modified lib/openagents/forge/sync.ex
  • added test/openagents/forge/git_plane_test.exs

Diff

4 files changed, +1178 -4

lib/openagents/forge/git_plane.ex added +567

@@ -0,0 +1,567 @@

1
defmodule OpenAgents.Forge.GitPlane do
2
  @moduledoc """
3
  Git primitives a stack service composes (#46): ref resolution, ancestry
4
  checks, merge-base reads, tree-merge planning, boundary-based commit
5
  replay, and atomic multi-ref updates. Nothing here knows what a pull
6
  request is.
7
8
  Reads run against the bare repo cache after a WAL freshness check, the
9
  same way `OpenAgents.Forge.Browse` reads do. Writes go through
10
  `batch_update_refs/3`: every ref in the batch applies or none does, each
11
  update carries an expected old OID, and the WAL records one entry for the
12
  batch so cache convergence and mirrors see one transition.
13
14
  All git invocations are argv-only against `--git-dir`, per the
15
  `OpenAgents.Forge.Repos.git/3` discipline. Where git needs stdin
16
  (`update-ref --stdin`, `commit-tree`), the input rides a server-generated
17
  temp file exactly as `OpenAgents.Forge.GitHTTP.run_git_service/4` does.
18
  """
19
20
  require Logger
21
22
  alias OpenAgents.Forge.{Pushes, Repos, Sync, WAL}
23
24
  @oid_pattern ~r/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
25
  @ref_pattern ~r|\Arefs/[A-Za-z0-9][A-Za-z0-9._/-]{0,200}\z|
26
  @segment_pattern ~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/
27
  @committer_name "OpenAgents Forge"
28
  @committer_email "forge@openagents.com"
29
30
  @typedoc "A full object ID: 40 (SHA-1) or 64 (SHA-256) lowercase hex characters."
31
  @type oid :: String.t()
32
33
  @typedoc """
34
  One ref update in a batch.
35
36
  `:expected_old` is the OID the ref must currently have, or `:absent` when
37
  the ref must not exist yet. `:new` is the target OID, or `:delete` to
38
  remove the ref.
39
  """
40
  @type ref_update :: %{
41
          ref: String.t(),
42
          expected_old: oid | :absent,
43
          new: oid | :delete
44
        }
45
46
  ## Reads
47
48
  @doc "Resolve a ref name or (short) OID to a full commit OID, after a freshness check."
49
  def resolve_commit(repo, ref) do
50
    with :ok <- check_rev(ref),
51
         :ok <- Sync.ensure_fresh(repo) do
52
      case git(repo, ["rev-parse", "--verify", "--quiet", "--end-of-options", ref <> "^{commit}"]) do
53
        {output, 0} -> {:ok, String.trim(output)}
54
        _other -> {:error, :not_found}
55
      end
56
    end
57
  end
58
59
  @doc "Whether `ancestor` is an ancestor of (or equal to) `descendant`."
60
  def ancestor?(repo, ancestor, descendant) do
61
    with :ok <- check_rev(ancestor),
62
         :ok <- check_rev(descendant),
63
         :ok <- Sync.ensure_fresh(repo) do
64
      case git(repo, ["merge-base", "--is-ancestor", "--end-of-options", ancestor, descendant]) do
65
        {_output, 0} -> {:ok, true}
66
        {_output, 1} -> {:ok, false}
67
        _other -> {:error, :not_found}
68
      end
69
    end
70
  end
71
72
  @doc "The best common ancestor of two commits, or `{:error, :no_merge_base}`."
73
  def merge_base(repo, rev_a, rev_b) do
74
    with :ok <- check_rev(rev_a),
75
         :ok <- check_rev(rev_b),
76
         :ok <- Sync.ensure_fresh(repo) do
77
      case git(repo, ["merge-base", "--end-of-options", rev_a, rev_b]) do
78
        {output, 0} -> {:ok, String.trim(output)}
79
        {_output, 1} -> {:error, :no_merge_base}
80
        _other -> {:error, :not_found}
81
      end
82
    end
83
  end
84
85
  ## Tree-merge planning
86
87
  @doc """
88
  Plan a tree merge with `git merge-tree --write-tree` without touching refs.
89
90
  Returns `{:ok, %{tree: oid}}` for a clean merge. A conflicted merge
91
  returns `{:conflict, %{tree: oid, paths: [...], files: [...], messages: [...]}}`
92
  where `files` carries the structured `{mode, oid, stage, path}` rows git
93
  reports for each conflicted path. Pass `merge_base: oid` to pin the base
94
  instead of letting git compute one.
95
  """
96
  def merge_tree(repo, ours, theirs, opts \\ []) do
97
    with :ok <- check_rev(ours),
98
         :ok <- check_rev(theirs),
99
         :ok <- check_optional_rev(opts[:merge_base]),
100
         :ok <- Sync.ensure_fresh(repo) do
101
      merge_tree_from_cache(Repos.bare_path(repo), ours, theirs, opts)
102
    end
103
  end
104
105
  defp merge_tree_from_cache(path, ours, theirs, opts) do
106
    base_args =
107
      case opts[:merge_base] do
108
        nil -> []
109
        base -> ["--merge-base=" <> base]
110
      end
111
112
    args =
113
      ["merge-tree", "--write-tree", "--messages"] ++
114
        base_args ++ ["--end-of-options", ours, theirs]
115
116
    case Repos.git(path, args) do
117
      {output, 0} ->
118
        {:ok, %{tree: output |> String.split("\n", parts: 2) |> hd() |> String.trim()}}
119
120
      {output, 1} ->
121
        {:conflict, parse_conflicted_merge(output)}
122
123
      _other ->
124
        {:error, :merge_tree_failed}
125
    end
126
  end
127
128
  defp parse_conflicted_merge(output) do
129
    [tree | rest] = String.split(output, "\n")
130
    {file_lines, message_lines} = Enum.split_while(rest, &(&1 != ""))
131
132
    files =
133
      Enum.flat_map(file_lines, fn line ->
134
        with [meta, file_path] <- String.split(line, "\t", parts: 2),
135
             [mode, oid, stage] <- String.split(meta, " ", trim: true) do
136
          [%{mode: mode, oid: oid, stage: stage, path: file_path}]
137
        else
138
          _other -> []
139
        end
140
      end)
141
142
    %{
143
      tree: String.trim(tree),
144
      files: files,
145
      paths: files |> Enum.map(& &1.path) |> Enum.uniq(),
146
      messages: message_lines |> Enum.drop(1) |> Enum.reject(&(&1 == ""))
147
    }
148
  end
149
150
  ## Commit replay
151
152
  @doc """
153
  Replay commits with `git rebase --onto` boundary semantics: only commits
154
  reachable from `old_head` but not from `boundary` replay, in order, onto
155
  `onto`.
156
157
  Returns `{:ok, %{new_head: oid, replayed: [%{old: oid, new: oid}]}}` — an
158
  empty range returns `onto` unchanged. A conflicting commit returns
159
  `{:conflict, %{commit: oid, onto: oid, paths: [...], files: [...], messages: [...],
160
  replayed: [...]}}` with the steps that already succeeded, so a caller can
161
  persist the conflict state. New commits are created as unreachable objects;
162
  refs move only through `batch_update_refs/3`.
163
164
  Author identity and message are preserved from each original commit; the
165
  committer is the forge service identity, and the replayed commits are
166
  unsigned (the commit-signature policy of `docs/stacked-prs.md` section 12.5).
167
  Merge commits and root commits in the range are rejected.
168
  """
169
  def replay(repo, boundary, old_head, onto) do
170
    with :ok <- check_oid(boundary),
171
         :ok <- check_oid(old_head),
172
         :ok <- check_oid(onto),
173
         :ok <- Sync.ensure_fresh(repo) do
174
      path = Repos.bare_path(repo)
175
176
      with {:ok, commits} <- commits_after_boundary(path, boundary, old_head) do
177
        replay_each(path, commits, onto, [])
178
      end
179
    end
180
  end
181
182
  defp commits_after_boundary(path, boundary, old_head) do
183
    args = [
184
      "rev-list",
185
      "--reverse",
186
      "--topo-order",
187
      "--end-of-options",
188
      old_head,
189
      "^" <> boundary
190
    ]
191
192
    case Repos.git(path, args) do
193
      {output, 0} -> {:ok, String.split(output, "\n", trim: true)}
194
      _other -> {:error, :not_found}
195
    end
196
  end
197
198
  defp replay_each(_path, [], onto, replayed),
199
    do: {:ok, %{new_head: onto, replayed: Enum.reverse(replayed)}}
200
201
  defp replay_each(path, [commit | rest], onto, replayed) do
202
    with {:ok, parent} <- sole_parent(path, commit),
203
         {:ok, tree} <- replay_tree(path, commit, parent, onto, replayed),
204
         {:ok, new_commit} <- commit_replayed_tree(path, commit, tree, onto) do
205
      replay_each(path, rest, new_commit, [%{old: commit, new: new_commit} | replayed])
206
    end
207
  end
208
209
  defp sole_parent(path, commit) do
210
    case Repos.git(path, ["show", "-s", "--format=%P", "--end-of-options", commit]) do
211
      {output, 0} ->
212
        case String.split(output, " ", trim: true) |> Enum.map(&String.trim/1) do
213
          [parent] -> {:ok, parent}
214
          [] -> {:error, {:root_commit, commit}}
215
          _multiple -> {:error, {:merge_commit, commit}}
216
        end
217
218
      _other ->
219
        {:error, :not_found}
220
    end
221
  end
222
223
  defp replay_tree(path, commit, parent, onto, replayed) do
224
    case merge_tree_from_cache(path, onto, commit, merge_base: parent) do
225
      {:ok, %{tree: tree}} ->
226
        {:ok, tree}
227
228
      {:conflict, conflict} ->
229
        {:conflict,
230
         conflict
231
         |> Map.drop([:tree])
232
         |> Map.merge(%{commit: commit, onto: onto, replayed: Enum.reverse(replayed)})}
233
234
      {:error, reason} ->
235
        {:error, reason}
236
    end
237
  end
238
239
  defp commit_replayed_tree(path, original, tree, parent) do
240
    with {:ok, author} <- author_of(path, original),
241
         {message, 0} <-
242
           Repos.git(path, ["show", "-s", "--format=%B", "--end-of-options", original]) do
243
      env = [
244
        {"GIT_AUTHOR_NAME", author.name},
245
        {"GIT_AUTHOR_EMAIL", author.email},
246
        {"GIT_AUTHOR_DATE", author.date},
247
        {"GIT_COMMITTER_NAME", @committer_name},
248
        {"GIT_COMMITTER_EMAIL", @committer_email}
249
      ]
250
251
      case git_with_stdin(path, ["commit-tree", tree, "-p", parent], message, env) do
252
        {output, 0} -> {:ok, String.trim(output)}
253
        _other -> {:error, :commit_tree_failed}
254
      end
255
    else
256
      {:error, reason} -> {:error, reason}
257
      {_output, _status} -> {:error, :not_found}
258
    end
259
  end
260
261
  defp author_of(path, commit) do
262
    case Repos.git(path, ["show", "-s", "--format=%an%x00%ae%x00%aI", "--end-of-options", commit]) do
263
      {output, 0} ->
264
        case output |> String.trim() |> String.split("\x00", parts: 3) do
265
          [name, email, date] -> {:ok, %{name: name, email: email, date: date}}
266
          _other -> {:error, :not_found}
267
        end
268
269
      _other ->
270
        {:error, :not_found}
271
    end
272
  end
273
274
  ## Retention refs
275
276
  @doc """
277
  Build a hidden internal ref name under `refs/internal/`.
278
279
  Boundary commits stay reachable through these refs, so git's garbage
280
  collection never prunes them (`docs/stacked-prs.md` section 7.4). They are
281
  never advertised to clients: `OpenAgents.Forge.Repos.ensure_repo_at!/2`
282
  sets `transfer.hideRefs` to cover `refs/internal/`. Move them through
283
  `batch_update_refs/3` so they persist in the WAL like any other ref.
284
  """
285
  def internal_ref(segments) when is_list(segments) and segments != [] do
286
    if Enum.all?(segments, &valid_segment?/1) do
287
      {:ok, "refs/internal/" <> Enum.join(segments, "/")}
288
    else
289
      {:error, :invalid_ref}
290
    end
291
  end
292
293
  defp valid_segment?(segment) when is_binary(segment) do
294
    Regex.match?(@segment_pattern, segment) and not String.ends_with?(segment, ".lock")
295
  end
296
297
  defp valid_segment?(_segment), do: false
298
299
  ## Batch compare-and-swap ref updates
300
301
  @doc """
302
  Apply a batch of ref updates atomically: every ref applies or none does,
303
  and any mismatched expected OID rejects the whole batch.
304
305
  The updates apply through one `git update-ref --stdin` transaction under
306
  the per-repository lock, then persist as one WAL entry (a git bundle of
307
  the new objects) so cache convergence and mirrors see one transition. A
308
  WAL index conflict from another writer re-syncs, re-validates every
309
  expected OID, and retries once; a WAL persist failure rolls the local
310
  refs back, so the cache never gets ahead of the authority.
311
312
  Returns `{:ok, %{seq: seq, refs: refs_after}}` on success. Errors:
313
314
    * `{:error, {:expected_mismatch, ref, actual}}` — a live ref no longer
315
      matches its expected old OID (`actual` is the current OID or `:absent`)
316
    * `{:error, :invalid_update}` — malformed ref, OID, or duplicate ref
317
    * `{:error, :ref_update_failed}` — git rejected the transaction
318
    * `{:error, :wal_persist_failed}` — refs rolled back, safe to retry
319
  """
320
  def batch_update_refs(repo, updates, principal)
321
      when is_list(updates) and updates != [] and is_binary(principal) do
322
    with :ok <- validate_updates(updates) do
323
      Sync.with_repo_lock(repo, fn -> locked_batch(repo, updates, principal, false) end)
324
    end
325
  end
326
327
  defp validate_updates(updates) do
328
    refs = Enum.map(updates, &Map.get(&1, :ref))
329
330
    valid? =
331
      length(Enum.uniq(refs)) == length(refs) and
332
        Enum.all?(updates, fn update ->
333
          valid_ref_name?(update[:ref]) and
334
            valid_expected_old?(update[:expected_old]) and
335
            valid_new?(update[:new]) and
336
            not (update[:expected_old] == :absent and update[:new] == :delete)
337
        end)
338
339
    if valid?, do: :ok, else: {:error, :invalid_update}
340
  end
341
342
  defp valid_ref_name?(ref) when is_binary(ref) do
343
    Regex.match?(@ref_pattern, ref) and not String.contains?(ref, ["..", "//", "@{"]) and
344
      not String.ends_with?(ref, ["/", ".", ".lock"])
345
  end
346
347
  defp valid_ref_name?(_ref), do: false
348
349
  defp valid_expected_old?(:absent), do: true
350
  defp valid_expected_old?(oid), do: valid_oid?(oid)
351
352
  defp valid_new?(:delete), do: true
353
  defp valid_new?(oid), do: valid_oid?(oid)
354
355
  defp valid_oid?(oid) when is_binary(oid), do: Regex.match?(@oid_pattern, oid)
356
  defp valid_oid?(_oid), do: false
357
358
  defp locked_batch(repo, updates, principal, retried?) do
359
    with :ok <- Sync.ensure_fresh(repo) do
360
      path = Repos.ensure_repo!(repo)
361
      refs_before = Repos.refs(repo)
362
363
      with :ok <- check_expected(updates, refs_before),
364
           :ok <- apply_updates(path, updates) do
365
        refs_after = Repos.refs(repo)
366
367
        case persist_batch(repo, path, updates, refs_before, refs_after, principal) do
368
          {:ok, seq} ->
369
            Repos.record_applied_seq!(repo, seq)
370
            broadcast(repo, seq, refs_after)
371
            mirror_async(repo)
372
            {:ok, %{seq: seq, refs: refs_after}}
373
374
          {:error, :cas_conflict} when not retried? ->
375
            Repos.set_refs!(repo, refs_before)
376
            locked_batch(repo, updates, principal, true)
377
378
          {:error, reason} ->
379
            Logger.error(
380
              "forge_batch_update_wal_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
381
            )
382
383
            Repos.set_refs!(repo, refs_before)
384
            {:error, :wal_persist_failed}
385
        end
386
      end
387
    end
388
  end
389
390
  defp check_expected(updates, refs_before) do
391
    Enum.find_value(updates, :ok, fn update ->
392
      actual = Map.get(refs_before, update.ref, :absent)
393
394
      if actual == update.expected_old do
395
        nil
396
      else
397
        {:error, {:expected_mismatch, update.ref, actual}}
398
      end
399
    end)
400
  end
401
402
  defp apply_updates(path, updates) do
403
    zero = zero_oid(path)
404
405
    instructions =
406
      Enum.map_join(updates, fn update ->
407
        case update do
408
          %{new: :delete, expected_old: old} ->
409
            "delete " <> update.ref <> "\x00" <> old <> "\x00"
410
411
          %{new: new, expected_old: :absent} ->
412
            "update " <> update.ref <> "\x00" <> new <> "\x00" <> zero <> "\x00"
413
414
          %{new: new, expected_old: old} ->
415
            "update " <> update.ref <> "\x00" <> new <> "\x00" <> old <> "\x00"
416
        end
417
      end)
418
419
    case git_with_stdin(path, ["update-ref", "--stdin", "-z"], instructions, []) do
420
      {_output, 0} -> :ok
421
      {_output, _status} -> {:error, :ref_update_failed}
422
    end
423
  end
424
425
  defp zero_oid(path) do
426
    case Repos.git(path, ["rev-parse", "--show-object-format"]) do
427
      {"sha256" <> _rest, 0} -> String.duplicate("0", 64)
428
      _sha1 -> String.duplicate("0", 40)
429
    end
430
  end
431
432
  # One WAL entry per batch: the new objects ride a git bundle (the existing
433
  # `git_bundle` replay format), so a rebuilt cache converges in one step.
434
  # A batch that introduces no objects (deletes, moves to known OIDs)
435
  # records an `empty_import` entry; refs still converge from the index.
436
  defp persist_batch(repo, path, updates, refs_before, refs_after, principal) do
437
    {expected, index} =
438
      case WAL.read_index(repo) do
439
        {:ok, generation, index} -> {generation, index}
440
        {:error, :not_found} -> {:none, WAL.new_index()}
441
        {:error, reason} -> throw({:wal_error, reason})
442
      end
443
444
    seq = WAL.next_seq(index)
445
446
    with {:ok, object, format} <- put_batch_entry(repo, path, seq, updates, refs_before),
447
         entry = %{
448
           "seq" => seq,
449
           "object" => object,
450
           "format" => format,
451
           "refs" => refs_after,
452
           "principal" => principal,
453
           "pushed_at" => DateTime.utc_now() |> DateTime.to_iso8601()
454
         },
455
         {:ok, _generation} <- WAL.cas_index(repo, expected, WAL.append_entry(index, entry)) do
456
      {:ok, seq}
457
    end
458
  catch
459
    {:wal_error, reason} -> {:error, reason}
460
  end
461
462
  defp put_batch_entry(repo, path, seq, updates, refs_before) do
463
    positive_refs = for %{new: new} = update <- updates, new != :delete, do: update.ref
464
    negatives = refs_before |> Map.values() |> Enum.uniq() |> Enum.map(&("^" <> &1))
465
466
    if positive_refs == [] or not new_objects?(path, positive_refs, negatives) do
467
      with {:ok, object} <- WAL.put_entry(repo, seq, ""), do: {:ok, object, "ref_update"}
468
    else
469
      bundle_path =
470
        Path.join(
471
          System.tmp_dir!(),
472
          "forge-batch-#{System.unique_integer([:positive, :monotonic])}.bundle"
473
        )
474
475
      try do
476
        case Repos.git(path, ["bundle", "create", bundle_path | positive_refs] ++ negatives) do
477
          {_output, 0} ->
478
            with {:ok, object} <- WAL.put_entry_file(repo, seq, bundle_path) do
479
              {:ok, object, "git_bundle"}
480
            end
481
482
          {_output, _status} ->
483
            {:error, :bundle_create_failed}
484
        end
485
      after
486
        File.rm(bundle_path)
487
      end
488
    end
489
  end
490
491
  # `git bundle create` refuses an empty bundle, so a batch whose targets are
492
  # all already reachable (deletes, moves to known OIDs) records an
493
  # `empty_import` entry instead; refs still converge from the index.
494
  defp new_objects?(path, positive_refs, negatives) do
495
    case Repos.git(
496
           path,
497
           ["rev-list", "-n", "1", "--end-of-options"] ++ positive_refs ++ negatives
498
         ) do
499
      {output, 0} -> String.trim(output) != ""
500
      _other -> true
501
    end
502
  end
503
504
  defp broadcast(repo, seq, refs) do
505
    Phoenix.PubSub.broadcast(
506
      OpenAgents.PubSub,
507
      "forge:pushes",
508
      {:forge_push, %{repo: repo, wal_seq: seq, refs: refs}}
509
    )
510
  end
511
512
  defp mirror_async(repo) do
513
    if Pushes.mirror_url(repo) do
514
      Task.Supervisor.start_child(OpenAgents.Forge.TaskSupervisor, fn ->
515
        Pushes.mirror_now(repo)
516
      end)
517
    end
518
519
    :ok
520
  end
521
522
  ## Internals
523
524
  defp check_rev(rev) do
525
    if is_binary(rev) and Regex.match?(~r|\A[A-Za-z0-9][A-Za-z0-9._/-]{0,127}\z|, rev) and
526
         not String.contains?(rev, ".."),
527
       do: :ok,
528
       else: {:error, :not_found}
529
  end
530
531
  defp check_optional_rev(nil), do: :ok
532
  defp check_optional_rev(rev), do: check_rev(rev)
533
534
  defp check_oid(oid), do: if(valid_oid?(oid), do: :ok, else: {:error, :not_found})
535
536
  defp git(repo, args), do: Repos.git(Repos.bare_path(repo), args)
537
538
  # `sh` is used ONLY for stdin redirection of a server-generated temp path;
539
  # every git argument rides argv ("$@"), never the shell string — the same
540
  # pattern as `OpenAgents.Forge.GitHTTP.run_git_service/4`.
541
  defp git_with_stdin(path, args, input, env) do
542
    input_path =
543
      Path.join(
544
        System.tmp_dir!(),
545
        "forge-git-plane-#{System.unique_integer([:positive])}-#{:erlang.phash2(self())}"
546
      )
547
548
    File.write!(input_path, input)
549
550
    try do
551
      System.cmd(
552
        "sh",
553
        [
554
          "-c",
555
          ~s(exec git "$@" < "$FORGE_GIT_PLANE_INPUT"),
556
          "sh",
557
          "--git-dir",
558
          path | args
559
        ],
560
        env: env ++ [{"FORGE_GIT_PLANE_INPUT", input_path}],
561
        stderr_to_stdout: true
562
      )
563
    after
564
      File.rm(input_path)
565
    end
566
  end
567
end
lib/openagents/forge/repos.ex modified +14

@@ -62,6 +62,7 @@ defmodule OpenAgents.Forge.Repos do

62 62
    end
63 63
64 64
    set_default_branch_at!(path, default_branch)
65
    hide_internal_refs_at!(path)
65 66
66 67
    path
67 68
  end

@@ -76,6 +77,19 @@ defmodule OpenAgents.Forge.Repos do

76 77
    :ok
77 78
  end
78 79
80
  # Hidden internal refs (`refs/internal/`) retain stack boundary commits
81
  # (`OpenAgents.Forge.GitPlane`) without advertising them to git clients.
82
  defp hide_internal_refs_at!(path) do
83
    case git(path, ["config", "--get", "transfer.hideRefs"]) do
84
      {"refs/internal/" <> _rest, 0} ->
85
        :ok
86
87
      _unset ->
88
        {_, 0} = git(path, ["config", "transfer.hideRefs", "refs/internal/"])
89
        :ok
90
    end
91
  end
92
79 93
  @doc "Current refs of the bare repo as a `%{name => sha}` map."
80 94
  def refs(repo) do
81 95
    repo |> bare_path() |> refs_at()
lib/openagents/forge/sync.ex modified +30 -4

@@ -75,13 +75,33 @@ defmodule OpenAgents.Forge.Sync do

75 75
    synchronize(repo, fn -> do_replay_missing(repo, index, default_branch) end)
76 76
  end
77 77
78
  # Reentrant: `:global` locks are not reference counted, so a nested
79
  # `:global.trans` on the same id releases the lock when the inner call
80
  # exits (for example `ensure_fresh/2` inside a locked write). The process
81
  # dictionary marks the lock as held so nested calls run inline.
78 82
  @doc false
79 83
  def with_repo_lock(repo, function) when is_function(function, 0) do
80
    lock_id = {{__MODULE__, repo}, self()}
84
    held_key = {__MODULE__, :repo_lock, repo}
81 85
82
    case :global.trans(lock_id, function, [node()]) do
83
      {:aborted, reason} -> raise_sync(repo, :acquire_lock, reason)
84
      result -> result
86
    if Process.get(held_key) do
87
      function.()
88
    else
89
      lock_id = {{__MODULE__, repo}, self()}
90
91
      locked = fn ->
92
        Process.put(held_key, true)
93
94
        try do
95
          function.()
96
        after
97
          Process.delete(held_key)
98
        end
99
      end
100
101
      case :global.trans(lock_id, locked, [node()]) do
102
        {:aborted, reason} -> raise_sync(repo, :acquire_lock, reason)
103
        result -> result
104
      end
85 105
    end
86 106
  end
87 107

@@ -183,6 +203,12 @@ defmodule OpenAgents.Forge.Sync do

183 203
184 204
      "empty_import" ->
185 205
        :ok
206
207
      # A batch ref update that introduced no new objects
208
      # (`OpenAgents.Forge.GitPlane.batch_update_refs/3`); refs converge
209
      # from the index.
210
      "ref_update" ->
211
        :ok
186 212
    end
187 213
188 214
    Repos.record_applied_seq_at!(path, seq)
test/openagents/forge/git_plane_test.exs added +567

@@ -0,0 +1,567 @@

1
defmodule OpenAgents.Forge.GitPlaneTest do
2
  @moduledoc """
3
  Git primitives for the stack service (#46): ref resolution, ancestry,
4
  merge bases, `merge-tree --write-tree` planning, boundary-based commit
5
  replay, hidden retention refs, and atomic batch compare-and-swap ref
6
  updates persisted as one WAL entry per batch.
7
  """
8
9
  use OpenAgents.DataCase, async: false
10
11
  alias OpenAgents.Forge.{GitPlane, Repos, Sync, WAL}
12
13
  @repo "openagents.com"
14
15
  setup do
16
    base = Path.join(System.tmp_dir!(), "forge-plane-#{System.unique_integer([:positive])}")
17
    File.mkdir_p!(base)
18
    previous_data = Application.get_env(:openagents, :forge_data_dir)
19
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
20
    previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)
21
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
22
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
23
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
24
25
    on_exit(fn ->
26
      restore_env(:forge_data_dir, previous_data)
27
      restore_env(:forge_wal_dir, previous_wal)
28
      restore_env(:forge_wal_adapter, previous_adapter)
29
      File.rm_rf(base)
30
    end)
31
32
    seed_repo(base)
33
  end
34
35
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
36
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
37
38
  # Commit graph, seeded via plumbing and then recorded as WAL entry 0 so the
39
  # bare repo is a disposable projection like it is in production:
40
  #
41
  #     base ── b1 ── b2 ── c1     (layer-1 = b2, layer-2 = c1)
42
  #        ├── trunk_x             (main moved past the boundary)
43
  #        └── conflict_k          (touches the same path as b1)
44
  #     root_d                     (disconnected history)
45
  defp seed_repo(base) do
46
    path = Repos.ensure_repo!(@repo)
47
48
    file_base = write_blob(path, "base\n")
49
    other_base = write_blob(path, "other\n")
50
51
    tree_base =
52
      mktree(path, "100644 blob #{file_base}\tfile.txt\n100644 blob #{other_base}\tother.txt\n")
53
54
    base_commit = commit_tree(path, tree_base, [], "Base commit\n")
55
56
    file_b1 = write_blob(path, "layer one\n")
57
58
    tree_b1 =
59
      mktree(path, "100644 blob #{file_b1}\tfile.txt\n100644 blob #{other_base}\tother.txt\n")
60
61
    b1 = commit_tree(path, tree_b1, ["-p", base_commit], "Layer one, first commit\n")
62
63
    b2_extra = write_blob(path, "second\n")
64
65
    tree_b2 =
66
      mktree(
67
        path,
68
        "100644 blob #{b2_extra}\tb2.txt\n100644 blob #{file_b1}\tfile.txt\n" <>
69
          "100644 blob #{other_base}\tother.txt\n"
70
      )
71
72
    b2 = commit_tree(path, tree_b2, ["-p", b1], "Layer one, second commit\n")
73
74
    c1_extra = write_blob(path, "layer two\n")
75
76
    tree_c1 =
77
      mktree(
78
        path,
79
        "100644 blob #{b2_extra}\tb2.txt\n100644 blob #{c1_extra}\tc1.txt\n" <>
80
          "100644 blob #{file_b1}\tfile.txt\n100644 blob #{other_base}\tother.txt\n"
81
      )
82
83
    c1 = commit_tree(path, tree_c1, ["-p", b2], "Layer two\n")
84
85
    other_x = write_blob(path, "trunk moved\n")
86
87
    tree_x =
88
      mktree(path, "100644 blob #{file_base}\tfile.txt\n100644 blob #{other_x}\tother.txt\n")
89
90
    trunk_x = commit_tree(path, tree_x, ["-p", base_commit], "Trunk advance\n")
91
92
    file_k = write_blob(path, "conflicting\n")
93
94
    tree_k =
95
      mktree(path, "100644 blob #{file_k}\tfile.txt\n100644 blob #{other_base}\tother.txt\n")
96
97
    conflict_k = commit_tree(path, tree_k, ["-p", base_commit], "Conflicting change\n")
98
99
    root_blob = write_blob(path, "disconnected\n")
100
    tree_d = mktree(path, "100644 blob #{root_blob}\td.txt\n")
101
    root_d = commit_tree(path, tree_d, [], "Disconnected root\n")
102
103
    refs = %{
104
      "refs/heads/main" => trunk_x,
105
      "refs/heads/layer-1" => b2,
106
      "refs/heads/layer-2" => c1,
107
      "refs/heads/boundary" => base_commit,
108
      "refs/heads/conflicting" => conflict_k,
109
      "refs/heads/disconnected" => root_d
110
    }
111
112
    Enum.each(refs, fn {name, sha} ->
113
      {_, 0} = Repos.git(path, ["update-ref", name, sha])
114
    end)
115
116
    bundle = Path.join(base, "seed.bundle")
117
    {_, 0} = Repos.git(path, ["bundle", "create", bundle, "--all"])
118
    {:ok, object} = WAL.put_entry_file(@repo, 0, bundle)
119
120
    entry = %{
121
      "seq" => 0,
122
      "object" => object,
123
      "format" => "git_bundle",
124
      "refs" => refs,
125
      "principal" => "test:seed",
126
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
127
    }
128
129
    {:ok, _generation} = WAL.cas_index(@repo, :none, WAL.append_entry(WAL.new_index(), entry))
130
    Repos.record_applied_seq!(@repo, 0)
131
132
    %{
133
      path: path,
134
      base_commit: base_commit,
135
      b1: b1,
136
      b2: b2,
137
      c1: c1,
138
      trunk_x: trunk_x,
139
      conflict_k: conflict_k,
140
      root_d: root_d,
141
      refs: refs
142
    }
143
  end
144
145
  defp write_blob(path, content) do
146
    {sha, 0} = git_in(path, ["hash-object", "-w", "--stdin"], content)
147
    String.trim(sha)
148
  end
149
150
  defp mktree(path, listing) do
151
    {sha, 0} = git_in(path, ["mktree"], listing)
152
    String.trim(sha)
153
  end
154
155
  defp commit_tree(path, tree, parent_args, message) do
156
    {sha, 0} =
157
      git_in(path, ["commit-tree", tree] ++ parent_args, message,
158
        env: [
159
          {"GIT_AUTHOR_NAME", "Test Author"},
160
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
161
          {"GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z"},
162
          {"GIT_COMMITTER_NAME", "Test Author"},
163
          {"GIT_COMMITTER_EMAIL", "author@example.test"},
164
          {"GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"}
165
        ]
166
      )
167
168
    String.trim(sha)
169
  end
170
171
  defp git_in(path, args, stdin, opts \\ []) do
172
    input = Path.join(System.tmp_dir!(), "plane-stdin-#{System.unique_integer([:positive])}")
173
    File.write!(input, stdin)
174
175
    try do
176
      System.cmd(
177
        "sh",
178
        ["-c", ~s(exec git --git-dir "$GD" "$@" < "$IN"), "sh"] ++ args,
179
        env: [{"GD", path}, {"IN", input}] ++ Keyword.get(opts, :env, [])
180
      )
181
    after
182
      File.rm(input)
183
    end
184
  end
185
186
  defp show(path, args) do
187
    {output, 0} = Repos.git(path, args)
188
    String.trim(output)
189
  end
190
191
  describe "resolve_commit/2" do
192
    test "resolves a branch, a full OID, and a short OID", %{b2: b2} do
193
      assert {:ok, ^b2} = GitPlane.resolve_commit(@repo, "layer-1")
194
      assert {:ok, ^b2} = GitPlane.resolve_commit(@repo, b2)
195
      assert {:ok, ^b2} = GitPlane.resolve_commit(@repo, String.slice(b2, 0, 10))
196
    end
197
198
    test "an unknown or malformed ref is :not_found" do
199
      assert {:error, :not_found} = GitPlane.resolve_commit(@repo, "no-such-branch")
200
      assert {:error, :not_found} = GitPlane.resolve_commit(@repo, "-evil")
201
      assert {:error, :not_found} = GitPlane.resolve_commit(@repo, "a..b")
202
    end
203
  end
204
205
  describe "ancestor?/3" do
206
    test "reports ancestry along and across branches", %{
207
      base_commit: base_commit,
208
      b2: b2,
209
      trunk_x: trunk_x
210
    } do
211
      assert {:ok, true} = GitPlane.ancestor?(@repo, base_commit, b2)
212
      assert {:ok, false} = GitPlane.ancestor?(@repo, b2, base_commit)
213
      assert {:ok, false} = GitPlane.ancestor?(@repo, b2, trunk_x)
214
      assert {:ok, true} = GitPlane.ancestor?(@repo, b2, b2)
215
    end
216
  end
217
218
  describe "merge_base/3" do
219
    test "finds the common ancestor of diverged branches", %{base_commit: base_commit} do
220
      assert {:ok, ^base_commit} = GitPlane.merge_base(@repo, "layer-1", "main")
221
    end
222
223
    test "disconnected histories have no merge base", %{root_d: root_d, b2: b2} do
224
      assert {:error, :no_merge_base} = GitPlane.merge_base(@repo, root_d, b2)
225
    end
226
  end
227
228
  describe "merge_tree/4" do
229
    test "a clean merge returns the merged tree without touching refs", %{path: path, refs: refs} do
230
      assert {:ok, %{tree: tree}} = GitPlane.merge_tree(@repo, "layer-1", "main")
231
      assert show(path, ["cat-file", "blob", tree <> ":file.txt"]) == "layer one"
232
      assert show(path, ["cat-file", "blob", tree <> ":other.txt"]) == "trunk moved"
233
      assert Repos.refs(@repo) == refs
234
    end
235
236
    test "a conflict returns structured path data", %{refs: refs} do
237
      assert {:conflict, conflict} = GitPlane.merge_tree(@repo, "layer-1", "conflicting")
238
      assert conflict.paths == ["file.txt"]
239
      assert Enum.all?(conflict.files, &(&1.path == "file.txt"))
240
      assert Enum.map(conflict.files, & &1.stage) == ["1", "2", "3"]
241
      assert Regex.match?(~r/\A[0-9a-f]{40,64}\z/, conflict.tree)
242
      assert Repos.refs(@repo) == refs
243
    end
244
245
    test "an explicit merge base is honored", %{b1: b1, b2: b2, path: path} do
246
      assert {:ok, %{tree: tree}} = GitPlane.merge_tree(@repo, "main", b2, merge_base: b1)
247
      assert show(path, ["cat-file", "blob", tree <> ":other.txt"]) == "trunk moved"
248
    end
249
  end
250
251
  describe "replay/4" do
252
    test "replays only commits after the boundary onto the new parent", %{
253
      path: path,
254
      base_commit: base_commit,
255
      b1: b1,
256
      b2: b2,
257
      trunk_x: trunk_x
258
    } do
259
      assert {:ok, %{new_head: new_head, replayed: replayed}} =
260
               GitPlane.replay(@repo, base_commit, b2, trunk_x)
261
262
      assert [%{old: ^b1, new: new_b1}, %{old: ^b2, new: new_b2}] = replayed
263
      assert new_head == new_b2
264
      assert show(path, ["rev-parse", new_b1 <> "^"]) == trunk_x
265
      assert show(path, ["rev-parse", new_b2 <> "^"]) == new_b1
266
      assert show(path, ["cat-file", "blob", new_b2 <> ":file.txt"]) == "layer one"
267
      assert show(path, ["cat-file", "blob", new_b2 <> ":other.txt"]) == "trunk moved"
268
269
      assert show(path, ["show", "-s", "--format=%an <%ae>", new_b1]) ==
270
               "Test Author <author@example.test>"
271
272
      assert show(path, ["show", "-s", "--format=%cn <%ce>", new_b1]) ==
273
               "OpenAgents Forge <forge@openagents.com>"
274
275
      assert show(path, ["show", "-s", "--format=%s", new_b1]) == "Layer one, first commit"
276
    end
277
278
    test "an empty range returns the new parent unchanged", %{b2: b2, trunk_x: trunk_x} do
279
      assert {:ok, %{new_head: ^trunk_x, replayed: []}} =
280
               GitPlane.replay(@repo, b2, b2, trunk_x)
281
    end
282
283
    test "a conflicting commit reports the commit, paths, and completed steps", %{
284
      base_commit: base_commit,
285
      b1: b1,
286
      conflict_k: conflict_k
287
    } do
288
      assert {:conflict, conflict} = GitPlane.replay(@repo, base_commit, conflict_k, b1)
289
      assert conflict.commit == conflict_k
290
      assert conflict.onto == b1
291
      assert conflict.paths == ["file.txt"]
292
      assert conflict.replayed == []
293
    end
294
295
    test "a merge commit in the range is rejected", %{
296
      path: path,
297
      base_commit: base_commit,
298
      b2: b2,
299
      trunk_x: trunk_x
300
    } do
301
      tree = show(path, ["rev-parse", b2 <> "^{tree}"])
302
      merge = commit_tree(path, tree, ["-p", b2, "-p", trunk_x], "Merge\n")
303
304
      assert {:error, {:merge_commit, ^merge}} =
305
               GitPlane.replay(@repo, base_commit, merge, trunk_x)
306
    end
307
  end
308
309
  describe "internal_ref/1" do
310
    test "builds hidden retention ref names" do
311
      assert {:ok, "refs/internal/stacks/7/boundary"} =
312
               GitPlane.internal_ref(["stacks", "7", "boundary"])
313
    end
314
315
    test "rejects unsafe segments" do
316
      assert {:error, :invalid_ref} = GitPlane.internal_ref(["a/b"])
317
      assert {:error, :invalid_ref} = GitPlane.internal_ref(["-flag"])
318
      assert {:error, :invalid_ref} = GitPlane.internal_ref([""])
319
      assert {:error, :invalid_ref} = GitPlane.internal_ref(["x.lock"])
320
    end
321
  end
322
323
  describe "batch_update_refs/3" do
324
    test "applies every ref in one WAL transition and survives cache loss", %{
325
      b2: b2,
326
      base_commit: base_commit,
327
      refs: refs
328
    } do
329
      {:ok, retention} = GitPlane.internal_ref(["stacks", "1", "boundary"])
330
331
      updates = [
332
        %{ref: "refs/heads/stack-1", expected_old: :absent, new: b2},
333
        %{ref: retention, expected_old: :absent, new: base_commit}
334
      ]
335
336
      assert {:ok, %{seq: 1, refs: refs_after}} =
337
               GitPlane.batch_update_refs(@repo, updates, "test:batch")
338
339
      assert refs_after ==
340
               Map.merge(refs, %{"refs/heads/stack-1" => b2, retention => base_commit})
341
342
      {:ok, _generation, index} = WAL.read_index(@repo)
343
      assert [_seed, batch_entry] = WAL.entries(index)
344
      assert batch_entry["principal"] == "test:batch"
345
      assert WAL.refs(index) == refs_after
346
347
      File.rm_rf!(Repos.bare_path(@repo))
348
      assert :ok = Sync.ensure_fresh(@repo)
349
      assert Repos.refs(@repo) == refs_after
350
    end
351
352
    test "a mismatched expected OID rejects the whole batch", %{
353
      b2: b2,
354
      trunk_x: trunk_x,
355
      refs: refs
356
    } do
357
      updates = [
358
        %{ref: "refs/heads/stack-1", expected_old: :absent, new: b2},
359
        %{ref: "refs/heads/main", expected_old: b2, new: b2}
360
      ]
361
362
      assert {:error, {:expected_mismatch, "refs/heads/main", ^trunk_x}} =
363
               GitPlane.batch_update_refs(@repo, updates, "test:batch")
364
365
      assert Repos.refs(@repo) == refs
366
      {:ok, _generation, index} = WAL.read_index(@repo)
367
      assert length(WAL.entries(index)) == 1
368
    end
369
370
    test "a git-rejected transaction applies none of the batch", %{b2: b2, refs: refs} do
371
      updates = [
372
        %{ref: "refs/heads/stack-1", expected_old: :absent, new: b2},
373
        %{ref: "refs/heads/main/nested", expected_old: :absent, new: b2}
374
      ]
375
376
      assert {:error, :ref_update_failed} =
377
               GitPlane.batch_update_refs(@repo, updates, "test:batch")
378
379
      assert Repos.refs(@repo) == refs
380
    end
381
382
    test "deletes and moves to known OIDs persist without a bundle", %{
383
      b2: b2,
384
      c1: c1,
385
      base_commit: base_commit
386
    } do
387
      updates = [
388
        %{ref: "refs/heads/layer-2", expected_old: c1, new: :delete},
389
        %{ref: "refs/heads/boundary", expected_old: base_commit, new: b2}
390
      ]
391
392
      assert {:ok, %{seq: 1, refs: refs_after}} =
393
               GitPlane.batch_update_refs(@repo, updates, "test:batch")
394
395
      refute Map.has_key?(refs_after, "refs/heads/layer-2")
396
      assert refs_after["refs/heads/boundary"] == b2
397
398
      {:ok, _generation, index} = WAL.read_index(@repo)
399
      assert [_seed, batch_entry] = WAL.entries(index)
400
      assert batch_entry["format"] == "ref_update"
401
402
      File.rm_rf!(Repos.bare_path(@repo))
403
      assert :ok = Sync.ensure_fresh(@repo)
404
      assert Repos.refs(@repo) == refs_after
405
    end
406
407
    test "malformed updates never reach git", %{b2: b2} do
408
      assert {:error, :invalid_update} =
409
               GitPlane.batch_update_refs(
410
                 @repo,
411
                 [%{ref: "main", expected_old: :absent, new: b2}],
412
                 "t"
413
               )
414
415
      assert {:error, :invalid_update} =
416
               GitPlane.batch_update_refs(
417
                 @repo,
418
                 [%{ref: "refs/heads/x", expected_old: :absent, new: "not-an-oid"}],
419
                 "t"
420
               )
421
422
      assert {:error, :invalid_update} =
423
               GitPlane.batch_update_refs(
424
                 @repo,
425
                 [
426
                   %{ref: "refs/heads/x", expected_old: :absent, new: b2},
427
                   %{ref: "refs/heads/x", expected_old: :absent, new: b2}
428
                 ],
429
                 "t"
430
               )
431
432
      assert {:error, :invalid_update} =
433
               GitPlane.batch_update_refs(
434
                 @repo,
435
                 [%{ref: "refs/heads/x", expected_old: :absent, new: :delete}],
436
                 "t"
437
               )
438
    end
439
440
    test "hidden internal refs are not advertised to clients", %{
441
      path: path,
442
      base_commit: base_commit
443
    } do
444
      {:ok, retention} = GitPlane.internal_ref(["stacks", "1", "boundary"])
445
446
      assert {:ok, _result} =
447
               GitPlane.batch_update_refs(
448
                 @repo,
449
                 [%{ref: retention, expected_old: :absent, new: base_commit}],
450
                 "test:batch"
451
               )
452
453
      {advertised, 0} =
454
        OpenAgents.Forge.GitHTTP.run_git_service(
455
          "upload-pack",
456
          ["--advertise-refs", path],
457
          "",
458
          nil
459
        )
460
461
      refute advertised =~ "refs/internal/"
462
      assert advertised =~ "refs/heads/main"
463
    end
464
465
    test "concurrent writers with one expected OID produce exactly one winner", %{
466
      path: path,
467
      base_commit: base_commit,
468
      trunk_x: trunk_x
469
    } do
470
      tree = show(path, ["rev-parse", base_commit <> "^{tree}"])
471
472
      results =
473
        1..6
474
        |> Task.async_stream(
475
          fn n ->
476
            commit = commit_tree(path, tree, ["-p", trunk_x], "Contender #{n}\n")
477
478
            GitPlane.batch_update_refs(
479
              @repo,
480
              [%{ref: "refs/heads/main", expected_old: trunk_x, new: commit}],
481
              "test:writer-#{n}"
482
            )
483
          end,
484
          timeout: :infinity
485
        )
486
        |> Enum.map(fn {:ok, result} -> result end)
487
488
      assert Enum.count(results, &match?({:ok, _}, &1)) == 1
489
490
      assert Enum.count(
491
               results,
492
               &match?({:error, {:expected_mismatch, "refs/heads/main", _}}, &1)
493
             ) ==
494
               5
495
    end
496
497
    test "concurrent batches never interleave: paired refs move together in every WAL entry", %{
498
      path: path,
499
      base_commit: base_commit
500
    } do
501
      tree = show(path, ["rev-parse", base_commit <> "^{tree}"])
502
503
      pair = ["refs/heads/pair-a", "refs/heads/pair-b"]
504
505
      assert {:ok, _result} =
506
               GitPlane.batch_update_refs(
507
                 @repo,
508
                 Enum.map(pair, &%{ref: &1, expected_old: :absent, new: base_commit}),
509
                 "test:pair-seed"
510
               )
511
512
      writers = 6
513
514
      1..writers
515
      |> Task.async_stream(
516
        fn n -> advance_pair(path, tree, pair, n, 20) end,
517
        timeout: :infinity
518
      )
519
      |> Enum.each(fn result -> assert {:ok, :ok} = result end)
520
521
      {:ok, _generation, index} = WAL.read_index(@repo)
522
      entries = WAL.entries(index)
523
      assert length(entries) == writers + 2
524
525
      entries
526
      |> Enum.drop(1)
527
      |> Enum.chunk_every(2, 1, :discard)
528
      |> Enum.each(fn [previous, entry] ->
529
        changed =
530
          entry["refs"]
531
          |> Enum.filter(fn {name, sha} -> previous["refs"][name] != sha end)
532
          |> Enum.map(&elem(&1, 0))
533
          |> Enum.sort()
534
535
        assert changed == pair,
536
               "WAL entry #{entry["seq"]} interleaved a batch: changed #{inspect(changed)}"
537
      end)
538
    end
539
  end
540
541
  # Retry loop for the interleave test: read the live pair tips, build one
542
  # new commit on each, and CAS both refs in one batch.
543
  defp advance_pair(_path, _tree, _pair, _n, 0), do: {:error, :retries_exhausted}
544
545
  defp advance_pair(path, tree, [ref_a, ref_b] = pair, n, retries) do
546
    refs = Repos.refs(@repo)
547
    old_a = Map.fetch!(refs, ref_a)
548
    old_b = Map.fetch!(refs, ref_b)
549
    new_a = commit_tree(path, tree, ["-p", old_a], "Pair A by writer #{n}\n")
550
    new_b = commit_tree(path, tree, ["-p", old_b], "Pair B by writer #{n}\n")
551
552
    case GitPlane.batch_update_refs(
553
           @repo,
554
           [
555
             %{ref: ref_a, expected_old: old_a, new: new_a},
556
             %{ref: ref_b, expected_old: old_b, new: new_b}
557
           ],
558
           "test:pair-#{n}"
559
         ) do
560
      {:ok, _result} ->
561
        :ok
562
563
      {:error, {:expected_mismatch, _ref, _actual}} ->
564
        advance_pair(path, tree, pair, n, retries - 1)
565
    end
566
  end
567
end

This page updates live while a promote is in flight · changelog