neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Seed conflicted repositories in dev
42a014c · Gabriel Morell · 2026-09-12 05:28
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