12.1 KiB · text History 42a014c
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