Seed conflicted repositories in dev

42a014c · Gabriel Morell · 2026-09-12 05:28

4 files +363 -0
Message
{commit_body(@commit)}

Files changed

added lib/mix/tasks/gitgud.seed.conflicts.ex
+363 −0
@@ -0,0 +1,363 @@
1 +defmodule Mix.Tasks.Gitgud.Seed.Conflicts do
2 + @moduledoc """
3 + Build the situations the merge editor exists for, in dev, so they can
4 + be clicked through rather than imagined.
5 +
6 + mix gitgud.seed.conflicts [--owner <handle-or-email>] [--prefix <name>]
7 +
8 + Creates two repositories:
9 +
10 + * `<prefix>-pr` — a pull request that can't be merged, carrying one
11 + of each kind of conflict: both sides editing the same lines, both
12 + sides adding the same file, one side deleting what the other
13 + changed, and a binary file that can't be edited in a browser at
14 + all. Plus a file that merges cleanly, which should *not* appear in
15 + the editor.
16 +
17 + * `<prefix>-upstream` — a repository linked to a local "upstream"
18 + in reconcile mode, with one branch that fast-forwards, one that
19 + diverged, and one that exists only here. The settings panel should
20 + offer to resolve exactly the diverged one.
21 +
22 + Refuses to run outside dev: it writes repositories and history to
23 + whatever database it is pointed at.
24 +
25 + Safe to run while `mix phx.server` is up — it boots the app with the
26 + HTTP listener, the metrics port and the SSH daemon turned off, so it
27 + doesn't fight the dev server for any of them.
28 + """
29 + @shortdoc "Seed conflicted repositories for the merge editor (dev only)"
30 +
31 + use Mix.Task
32 +
33 + import Ecto.Query, only: [from: 2]
34 +
35 + alias GitGud.Accounts
36 + alias GitGud.Git
37 + alias GitGud.Merges
38 + alias GitGud.PullRequests
39 + alias GitGud.Repo
40 + alias GitGud.Repositories
41 + alias GitGud.Repositories.Storage
42 +
43 + @impl true
44 + def run(argv) do
45 + {opts, _, _} = OptionParser.parse(argv, strict: [owner: :string, prefix: :string])
46 +
47 + unless Mix.env() == :dev do
48 + Mix.raise("gitgud.seed.conflicts only runs in dev (MIX_ENV=#{Mix.env()})")
49 + end
50 +
51 + boot!()
52 +
53 + owner = resolve_owner(opts[:owner])
54 + prefix = opts[:prefix] || "merge-demo"
55 +
56 + Mix.shell().info("seeding as #{owner.handle} <#{owner.email}>\n")
57 +
58 + pr_url = seed_pull_request(owner, "#{prefix}-pr")
59 + upstream_url = seed_upstream(owner, "#{prefix}-upstream")
60 +
61 + Mix.shell().info("""
62 +
63 + Done.
64 +
65 + conflicted pull request #{pr_url}
66 + diverged upstream #{upstream_url}
67 +
68 + On the pull request, "Resolve them here" opens the editor. On the
69 + upstream settings page, press "Check without changing anything"
70 + first — that is what fetches upstream's side — then "Resolve" on the
71 + diverged branch.
72 + """)
73 + end
74 +
75 + # The dev server is usually already holding :4000, the metrics port
76 + # and the SSH port, so start everything except the things that listen.
77 + defp boot! do
78 + Mix.Task.run("app.config")
79 +
80 + endpoint = Application.get_env(:git_gud, GitGudWeb.Endpoint, [])
81 +
82 + Application.put_env(
83 + :git_gud,
84 + GitGudWeb.Endpoint,
85 + Keyword.merge(endpoint, server: false, http: false)
86 + )
87 +
88 + Application.put_env(:git_gud, :metrics_port, 0)
89 + Application.put_env(:git_gud, GitGud.Ssh.Server, enabled: false)
90 +
91 + Mix.Task.run("app.start")
92 + end
93 +
94 + # ── the conflicted pull request ──────────────────────────────────────
95 +
96 + defp seed_pull_request(owner, name) do
97 + repo = create_repo(owner, name, "A pull request with one of every kind of conflict")
98 +
99 + base =
100 + commit(repo, %{
101 + "README.md" => "# #{name}\n\nA repository that exists to conflict with itself.\n",
102 + "config.exs" => "config :app,\n timeout: 5_000,\n retries: 3\n",
103 + "doomed.txt" => "this file is about to be deleted on one side\n",
104 + "untouched.txt" => "nobody changes this\n",
105 + "logo.bin" => binary_blob("base")
106 + })
107 +
108 + # main: the target of the pull request.
109 + main =
110 + commit(
111 + repo,
112 + %{
113 + "README.md" => "# #{name}\n\nA repository that exists to conflict with itself.\n",
114 + "config.exs" => "config :app,\n timeout: 30_000,\n retries: 3\n",
115 + "untouched.txt" => "nobody changes this\n",
116 + "shared.txt" => "added on main\n",
117 + "logo.bin" => binary_blob("main")
118 + },
119 + [base]
120 + )
121 +
122 + # feature: the pull request's branch.
123 + feature =
124 + commit(
125 + repo,
126 + %{
127 + "README.md" => "# #{name}\n\nA repository that exists to conflict with itself.\n",
128 + "config.exs" => "config :app,\n timeout: 5_000,\n retries: 10\n",
129 + "doomed.txt" => "edited on the feature branch instead of deleted\n",
130 + "untouched.txt" => "nobody changes this\n",
131 + "shared.txt" => "added on feature\n",
132 + "logo.bin" => binary_blob("feature"),
133 + "only-on-feature.md" => "this one merges cleanly\n"
134 + },
135 + [base]
136 + )
137 +
138 + :ok = Git.update_ref(repo.disk_path, "refs/heads/main", main, nil)
139 + :ok = Git.update_ref(repo.disk_path, "refs/heads/feature", feature, nil)
140 + _ = Repositories.refresh_root_commits(repo)
141 +
142 + {:ok, pr} =
143 + PullRequests.create_pull_request(repo, owner, %{
144 + "title" => "Raise the retry count",
145 + "body" => """
146 + Every kind of conflict at once, so the editor has something to chew on:
147 +
148 + * `config.exs` — both sides edited the same lines
149 + * `shared.txt` — both sides added it
150 + * `doomed.txt` — deleted on main, edited here
151 + * `logo.bin` — binary, so it can only be taken whole
152 +
153 + `only-on-feature.md` and `untouched.txt` should not appear in the editor.
154 + """,
155 + "source_ref" => "feature",
156 + "target_ref" => "main"
157 + })
158 +
159 + # Populate `mergeable` so the PR page shows the conflict banner (and
160 + # the link into the editor) without waiting for a background check.
161 + {:ok, false} = PullRequests.check_mergeability(pr)
162 +
163 + report(repo, "pull request ##{pr.number}")
164 + "/r/#{Storage.repo_handle(repo)}/#{repo.name}/pulls/#{pr.number}"
165 + end
166 +
167 + # ── the diverged upstream ────────────────────────────────────────────
168 +
169 + defp seed_upstream(owner, name) do
170 + # A bare repository standing in for a remote. It never appears in the
171 + # UI; it is only somewhere to fetch from.
172 + upstream = create_repo(owner, "#{name}-remote", "Stands in for a remote; not interesting")
173 +
174 + base =
175 + commit(upstream, %{
176 + "README.md" => "# #{name}\n\nUpstream's copy.\n",
177 + "CHANGELOG.md" => "## unreleased\n\n- nothing yet\n"
178 + })
179 +
180 + :ok = Git.update_ref(upstream.disk_path, "refs/heads/main", base, nil)
181 + :ok = Git.update_ref(upstream.disk_path, "refs/heads/docs", base, nil)
182 +
183 + # The local repository: a clone, then unlinked, which is what a
184 + # hand-migrated repo looks like before an import adopts it.
185 + _ = delete_existing(owner, name)
186 +
187 + {:ok, local} =
188 + Repositories.create_repository(owner, %{
189 + "name" => name,
190 + "description" => "Linked to an upstream that has moved on",
191 + "clone_addr" => upstream.disk_path
192 + })
193 +
194 + local =
195 + local
196 + |> Ecto.Changeset.change(upstream_url: nil)
197 + |> Repo.update!()
198 + |> Repo.preload([:owner, :organization])
199 +
200 + {:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
201 +
202 + # Upstream moves on: main gains a commit we can fast-forward to, and
203 + # CHANGELOG.md changes on a line we are also about to change.
204 + up_main =
205 + commit(
206 + upstream,
207 + %{
208 + "README.md" => "# #{name}\n\nUpstream's copy, now with more words.\n",
209 + "CHANGELOG.md" => "## unreleased\n\n- upstream fixed the parser\n"
210 + },
211 + [base]
212 + )
213 +
214 + :ok = Git.update_ref(upstream.disk_path, "refs/heads/main", up_main, base)
215 +
216 + # And we move too, on the same line of the same file.
217 + our_main =
218 + commit(
219 + local,
220 + %{
221 + "README.md" => "# #{name}\n\nUpstream's copy.\n",
222 + "CHANGELOG.md" => "## unreleased\n\n- we fixed the parser\n"
223 + },
224 + [base]
225 + )
226 +
227 + :ok = Git.update_ref(local.disk_path, "refs/heads/main", our_main, base)
228 +
229 + # A branch only we have, to prove reconcile mode never prunes it.
230 + ours_only = commit(local, %{"README.md" => "# #{name}\n\nOn a branch of our own.\n"}, [base])
231 + :ok = Git.update_ref(local.disk_path, "refs/heads/local-only", ours_only, nil)
232 +
233 + # Fetch upstream's side now, so `refs/upstream-sync/*` is populated
234 + # and "Resolve" works on first click.
235 + {:ok, plan} = Repositories.upstream_status(local)
236 +
237 + report(local, "diverged: #{Enum.map_join(plan.diverged, ", ", & &1.branch)}")
238 +
239 + # Show that the editor's other entry point is live too.
240 + case plan.diverged do
241 + [%{branch: branch} | _] ->
242 + {:ok, analysis} =
243 + Merges.analyze(
244 + local,
245 + elem(Git.from_hex(hd(plan.diverged).local_sha), 1),
246 + elem(Git.from_hex(hd(plan.diverged).upstream_sha), 1)
247 + )
248 +
249 + Mix.shell().info(
250 + " #{branch}: #{length(analysis.files)} conflicted file(s) — " <>
251 + Enum.map_join(analysis.files, ", ", & &1.path)
252 + )
253 +
254 + [] ->
255 + Mix.shell().info(" (nothing diverged — that isn't supposed to happen)")
256 + end
257 +
258 + "/r/#{Storage.repo_handle(local)}/#{local.name}/settings"
259 + end
260 +
261 + # ── plumbing ─────────────────────────────────────────────────────────
262 +
263 + # A PNG header, then a NUL-bearing chunk: the binary sniff looks for a
264 + # zero byte in the first 8KiB, and the magic number alone has none —
265 + # so a "binary" fixture without this renders as text in the editor,
266 + # which is the opposite of what it is meant to demonstrate.
267 + defp binary_blob(tag) do
268 + <<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 1, 0>> <>
269 + :crypto.hash(:sha256, tag) <> <<0, 0, 0, 0>>
270 + end
271 +
272 + defp create_repo(owner, name, description) do
273 + _ = delete_existing(owner, name)
274 +
275 + {:ok, repo} =
276 + Repositories.create_repository(owner, %{
277 + "name" => name,
278 + "description" => description,
279 + "visibility" => "public"
280 + })
281 +
282 + Repo.preload(repo, [:owner, :organization])
283 + end
284 +
285 + # Commit an exact set of files: a path left out of `files` is a path
286 + # this commit deletes.
287 + defp commit(repo, files, parents \\ []) do
288 + path = repo.disk_path
289 +
290 + entries =
291 + Enum.map_join(files, "", fn {name, contents} ->
292 + "100644 blob #{Git.to_hex(write_blob!(path, contents))}\t#{name}\n"
293 + end)
294 +
295 + tree = mktree!(path, entries)
296 +
297 + {:ok, sha} =
298 + Git.commit_tree(path, tree, parents, message_for(files),
299 + author: {"seed", "seed@gitgud.local", DateTime.utc_now(:second)}
300 + )
301 +
302 + sha
303 + end
304 +
305 + defp message_for(files), do: "Seed: #{files |> Map.keys() |> Enum.sort() |> Enum.join(", ")}"
306 +
307 + defp write_blob!(path, contents) do
308 + {:ok, sha} = Git.write_blob(path, contents)
309 + sha
310 + end
311 +
312 + defp mktree!(path, entries) do
313 + tmp = Path.join(System.tmp_dir!(), "seed-tree-#{System.unique_integer([:positive])}")
314 + File.write!(tmp, entries)
315 +
316 + try do
317 + {out, 0} = System.shell("git -C #{path} mktree < #{tmp}", stderr_to_stdout: true)
318 + {:ok, sha} = Git.from_hex(String.trim(out))
319 + sha
320 + after
321 + File.rm(tmp)
322 + end
323 + end
324 +
325 + # A seed you can't re-run is a seed you stop trusting. Dev-only task,
326 + # so removing what a previous run made is the right call rather than
327 + # erroring on the name.
328 + defp delete_existing(owner, name) do
329 + query =
330 + from(r in Repositories.Repository,
331 + where: r.owner_id == ^owner.id and is_nil(r.organization_id) and r.name == ^name
332 + )
333 +
334 + case Repo.one(query) do
335 + nil ->
336 + :ok
337 +
338 + repo ->
339 + Mix.shell().info(" replacing existing #{name}")
340 + {:ok, _} = Repositories.delete_repository(repo)
341 + :ok
342 + end
343 + end
344 +
345 + defp report(repo, note),
346 + do: Mix.shell().info(" #{Storage.repo_handle(repo)}/#{repo.name} — #{note}")
347 +
348 + defp resolve_owner(nil) do
349 + case Repo.all(Accounts.User) do
350 + [user | _] -> Repo.preload(user, [])
351 + [] -> Mix.raise("no users in this database — sign up first, or pass --owner")
352 + end
353 + end
354 +
355 + defp resolve_owner(handle_or_email) do
356 + query = if String.contains?(handle_or_email, "@"), do: :email, else: :handle
357 +
358 + case Repo.get_by(Accounts.User, [{query, handle_or_email}]) do
359 + nil -> Mix.raise("no user with #{query} #{inspect(handle_or_email)}")
360 + user -> user
361 + end
362 + end
363 +end

Parents: 62dda47