|
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
|