defmodule Mix.Tasks.Gitgud.Seed.Conflicts do
@moduledoc """
Build the situations the merge editor exists for, in dev, so they can
be clicked through rather than imagined.
mix gitgud.seed.conflicts [--owner <handle-or-email>] [--prefix <name>]
Creates two repositories:
* `<prefix>-pr` — a pull request that can't be merged, carrying one
of each kind of conflict: both sides editing the same lines, both
sides adding the same file, one side deleting what the other
changed, and a binary file that can't be edited in a browser at
all. Plus a file that merges cleanly, which should *not* appear in
the editor.
* `<prefix>-upstream` — a repository linked to a local "upstream"
in reconcile mode, with one branch that fast-forwards, one that
diverged, and one that exists only here. The settings panel should
offer to resolve exactly the diverged one.
Refuses to run outside dev: it writes repositories and history to
whatever database it is pointed at.
Safe to run while `mix phx.server` is up — it boots the app with the
HTTP listener, the metrics port and the SSH daemon turned off, so it
doesn't fight the dev server for any of them.
"""
@shortdoc "Seed conflicted repositories for the merge editor (dev only)"
use Mix.Task
import Ecto.Query, only: [from: 2]
alias GitGud.Accounts
alias GitGud.Git
alias GitGud.Merges
alias GitGud.PullRequests
alias GitGud.Repo
alias GitGud.Repositories
alias GitGud.Repositories.Storage
@impl true
def run(argv) do
{opts, _, _} = OptionParser.parse(argv, strict: [owner: :string, prefix: :string])
unless Mix.env() == :dev do
Mix.raise("gitgud.seed.conflicts only runs in dev (MIX_ENV=#{Mix.env()})")
end
boot!()
owner = resolve_owner(opts[:owner])
prefix = opts[:prefix] || "merge-demo"
Mix.shell().info("seeding as #{owner.handle} <#{owner.email}>\n")
pr_url = seed_pull_request(owner, "#{prefix}-pr")
upstream_url = seed_upstream(owner, "#{prefix}-upstream")
Mix.shell().info("""
Done.
conflicted pull request #{pr_url}
diverged upstream #{upstream_url}
On the pull request, "Resolve them here" opens the editor. On the
upstream settings page, press "Check without changing anything"
first — that is what fetches upstream's side — then "Resolve" on the
diverged branch.
""")
end
# The dev server is usually already holding :4000, the metrics port
# and the SSH port, so start everything except the things that listen.
defp boot! do
Mix.Task.run("app.config")
endpoint = Application.get_env(:git_gud, GitGudWeb.Endpoint, [])
Application.put_env(
:git_gud,
GitGudWeb.Endpoint,
Keyword.merge(endpoint, server: false, http: false)
)
Application.put_env(:git_gud, :metrics_port, 0)
Application.put_env(:git_gud, GitGud.Ssh.Server, enabled: false)
Mix.Task.run("app.start")
end
# ── the conflicted pull request ──────────────────────────────────────
defp seed_pull_request(owner, name) do
repo = create_repo(owner, name, "A pull request with one of every kind of conflict")
base =
commit(repo, %{
"README.md" => "# #{name}\n\nA repository that exists to conflict with itself.\n",
"config.exs" => "config :app,\n timeout: 5_000,\n retries: 3\n",
"doomed.txt" => "this file is about to be deleted on one side\n",
"untouched.txt" => "nobody changes this\n",
"logo.bin" => binary_blob("base")
})
# main: the target of the pull request.
main =
commit(
repo,
%{
"README.md" => "# #{name}\n\nA repository that exists to conflict with itself.\n",
"config.exs" => "config :app,\n timeout: 30_000,\n retries: 3\n",
"untouched.txt" => "nobody changes this\n",
"shared.txt" => "added on main\n",
"logo.bin" => binary_blob("main")
},
[base]
)
# feature: the pull request's branch.
feature =
commit(
repo,
%{
"README.md" => "# #{name}\n\nA repository that exists to conflict with itself.\n",
"config.exs" => "config :app,\n timeout: 5_000,\n retries: 10\n",
"doomed.txt" => "edited on the feature branch instead of deleted\n",
"untouched.txt" => "nobody changes this\n",
"shared.txt" => "added on feature\n",
"logo.bin" => binary_blob("feature"),
"only-on-feature.md" => "this one merges cleanly\n"
},
[base]
)
:ok = Git.update_ref(repo.disk_path, "refs/heads/main", main, nil)
:ok = Git.update_ref(repo.disk_path, "refs/heads/feature", feature, nil)
_ = Repositories.refresh_root_commits(repo)
{:ok, pr} =
PullRequests.create_pull_request(repo, owner, %{
"title" => "Raise the retry count",
"body" => """
Every kind of conflict at once, so the editor has something to chew on:
* `config.exs` — both sides edited the same lines
* `shared.txt` — both sides added it
* `doomed.txt` — deleted on main, edited here
* `logo.bin` — binary, so it can only be taken whole
`only-on-feature.md` and `untouched.txt` should not appear in the editor.
""",
"source_ref" => "feature",
"target_ref" => "main"
})
# Populate `mergeable` so the PR page shows the conflict banner (and
# the link into the editor) without waiting for a background check.
{:ok, false} = PullRequests.check_mergeability(pr)
report(repo, "pull request ##{pr.number}")
"/r/#{Storage.repo_handle(repo)}/#{repo.name}/pulls/#{pr.number}"
end
# ── the diverged upstream ────────────────────────────────────────────
defp seed_upstream(owner, name) do
# A bare repository standing in for a remote. It never appears in the
# UI; it is only somewhere to fetch from.
upstream = create_repo(owner, "#{name}-remote", "Stands in for a remote; not interesting")
base =
commit(upstream, %{
"README.md" => "# #{name}\n\nUpstream's copy.\n",
"CHANGELOG.md" => "## unreleased\n\n- nothing yet\n"
})
:ok = Git.update_ref(upstream.disk_path, "refs/heads/main", base, nil)
:ok = Git.update_ref(upstream.disk_path, "refs/heads/docs", base, nil)
# The local repository: a clone, then unlinked, which is what a
# hand-migrated repo looks like before an import adopts it.
_ = delete_existing(owner, name)
{:ok, local} =
Repositories.create_repository(owner, %{
"name" => name,
"description" => "Linked to an upstream that has moved on",
"clone_addr" => upstream.disk_path
})
local =
local
|> Ecto.Changeset.change(upstream_url: nil)
|> Repo.update!()
|> Repo.preload([:owner, :organization])
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
# Upstream moves on: main gains a commit we can fast-forward to, and
# CHANGELOG.md changes on a line we are also about to change.
up_main =
commit(
upstream,
%{
"README.md" => "# #{name}\n\nUpstream's copy, now with more words.\n",
"CHANGELOG.md" => "## unreleased\n\n- upstream fixed the parser\n"
},
[base]
)
:ok = Git.update_ref(upstream.disk_path, "refs/heads/main", up_main, base)
# And we move too, on the same line of the same file.
our_main =
commit(
local,
%{
"README.md" => "# #{name}\n\nUpstream's copy.\n",
"CHANGELOG.md" => "## unreleased\n\n- we fixed the parser\n"
},
[base]
)
:ok = Git.update_ref(local.disk_path, "refs/heads/main", our_main, base)
# A branch only we have, to prove reconcile mode never prunes it.
ours_only = commit(local, %{"README.md" => "# #{name}\n\nOn a branch of our own.\n"}, [base])
:ok = Git.update_ref(local.disk_path, "refs/heads/local-only", ours_only, nil)
# Fetch upstream's side now, so `refs/upstream-sync/*` is populated
# and "Resolve" works on first click.
{:ok, plan} = Repositories.upstream_status(local)
report(local, "diverged: #{Enum.map_join(plan.diverged, ", ", & &1.branch)}")
# Show that the editor's other entry point is live too.
case plan.diverged do
[%{branch: branch} | _] ->
{:ok, analysis} =
Merges.analyze(
local,
elem(Git.from_hex(hd(plan.diverged).local_sha), 1),
elem(Git.from_hex(hd(plan.diverged).upstream_sha), 1)
)
Mix.shell().info(
" #{branch}: #{length(analysis.files)} conflicted file(s) — " <>
Enum.map_join(analysis.files, ", ", & &1.path)
)
[] ->
Mix.shell().info(" (nothing diverged — that isn't supposed to happen)")
end
"/r/#{Storage.repo_handle(local)}/#{local.name}/settings"
end
# ── plumbing ─────────────────────────────────────────────────────────
# A PNG header, then a NUL-bearing chunk: the binary sniff looks for a
# zero byte in the first 8KiB, and the magic number alone has none —
# so a "binary" fixture without this renders as text in the editor,
# which is the opposite of what it is meant to demonstrate.
defp binary_blob(tag) do
<<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 1, 0>> <>
:crypto.hash(:sha256, tag) <> <<0, 0, 0, 0>>
end
defp create_repo(owner, name, description) do
_ = delete_existing(owner, name)
{:ok, repo} =
Repositories.create_repository(owner, %{
"name" => name,
"description" => description,
"visibility" => "public"
})
Repo.preload(repo, [:owner, :organization])
end
# Commit an exact set of files: a path left out of `files` is a path
# this commit deletes.
defp commit(repo, files, parents \\ []) do
path = repo.disk_path
entries =
Enum.map_join(files, "", fn {name, contents} ->
"100644 blob #{Git.to_hex(write_blob!(path, contents))}\t#{name}\n"
end)
tree = mktree!(path, entries)
{:ok, sha} =
Git.commit_tree(path, tree, parents, message_for(files),
author: {"seed", "seed@gitgud.local", DateTime.utc_now(:second)}
)
sha
end
defp message_for(files), do: "Seed: #{files |> Map.keys() |> Enum.sort() |> Enum.join(", ")}"
defp write_blob!(path, contents) do
{:ok, sha} = Git.write_blob(path, contents)
sha
end
defp mktree!(path, entries) do
tmp = Path.join(System.tmp_dir!(), "seed-tree-#{System.unique_integer([:positive])}")
File.write!(tmp, entries)
try do
{out, 0} = System.shell("git -C #{path} mktree < #{tmp}", stderr_to_stdout: true)
{:ok, sha} = Git.from_hex(String.trim(out))
sha
after
File.rm(tmp)
end
end
# A seed you can't re-run is a seed you stop trusting. Dev-only task,
# so removing what a previous run made is the right call rather than
# erroring on the name.
defp delete_existing(owner, name) do
query =
from(r in Repositories.Repository,
where: r.owner_id == ^owner.id and is_nil(r.organization_id) and r.name == ^name
)
case Repo.one(query) do
nil ->
:ok
repo ->
Mix.shell().info(" replacing existing #{name}")
{:ok, _} = Repositories.delete_repository(repo)
:ok
end
end
defp report(repo, note),
do: Mix.shell().info(" #{Storage.repo_handle(repo)}/#{repo.name} — #{note}")
defp resolve_owner(nil) do
case Repo.all(Accounts.User) do
[user | _] -> Repo.preload(user, [])
[] -> Mix.raise("no users in this database — sign up first, or pass --owner")
end
end
defp resolve_owner(handle_or_email) do
query = if String.contains?(handle_or_email, "@"), do: :email, else: :handle
case Repo.get_by(Accounts.User, [{query, handle_or_email}]) do
nil -> Mix.raise("no user with #{query} #{inspect(handle_or_email)}")
user -> user
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.1 KiB · text
History
42a014c