7.7 KiB · text History b868907
defmodule GitGudWeb.RepoLive.MergeTest do
  @moduledoc """
  The in-browser merge editor. Its two callers — a conflicting pull
  request and a branch that diverged from upstream — arrive at the same
  route with the same two commits, so the tests drive it the same way.
  """
  use GitGudWeb.ConnCase, async: false

  import Phoenix.LiveViewTest
  import GitGud.ForgeFixtures
  import GitGud.AccountsFixtures

  alias GitGud.Git
  alias GitGud.Repositories.Storage

  @moduletag :git_required

  setup :register_and_log_in_user

  setup %{user: user} do
    if System.find_executable("git") == nil do
      {:skip, "git not on PATH"}
    else
      {:ok, repo} =
        GitGud.Repositories.create_repository(user, %{"name" => "conflicted#{uniq()}"})

      repo = GitGud.Repo.preload(repo, [:owner, :organization])

      base = commit(repo, %{"f.txt" => "line1\nline2\nline3\n", "keep.txt" => "keep\n"}, [])
      ours = commit(repo, %{"f.txt" => "line1\nOURS\nline3\n", "keep.txt" => "keep\n"}, [base])

      theirs =
        commit(repo, %{"f.txt" => "line1\nTHEIRS\nline3\n", "keep.txt" => "keep\n"}, [base])

      :ok = Git.update_ref(repo.disk_path, "refs/heads/main", ours, nil)
      :ok = Git.update_ref(repo.disk_path, "refs/heads/feature", theirs, nil)

      path = "/r/#{Storage.repo_handle(repo)}/#{repo.name}/merge?into=main&from=feature"

      %{repo: repo, base: base, ours: ours, theirs: theirs, path: path}
    end
  end

  test "lists the conflicts and opens git's marked-up merge", ctx do
    {:ok, lv, _html} = live(ctx.conn, ctx.path)

    html = render_async(lv)

    assert html =~ "f.txt"
    # keep.txt merged cleanly, so it isn't the operator's problem.
    refute html =~ "keep.txt"
    assert html =~ "<<<<<<<"
    assert html =~ "OURS"
    assert html =~ "THEIRS"
    # Nothing is resolved yet, so merging is not offered.
    assert html =~ "1 still to resolve"
  end

  test "taking one side merges it and moves the branch", ctx do
    {:ok, lv, _html} = live(ctx.conn, ctx.path)
    render_async(lv)

    lv
    |> element("button[phx-click=take][phx-value-side=theirs]")
    |> render_click()

    assert render(lv) =~ "(theirs)"

    lv |> element("button[phx-click=merge]") |> render_click()

    assert_redirect(lv)

    assert {:ok, head} = Git.resolve(ctx.repo.disk_path, "refs/heads/main")
    assert {:ok, commit} = Git.commit(ctx.repo.disk_path, head)
    assert commit.parent_shas == [ctx.ours, ctx.theirs]

    {:ok, entry} = Git.tree_entry_at(ctx.repo.disk_path, commit.tree_sha, "f.txt")
    assert {:ok, "line1\nTHEIRS\nline3\n"} = Git.blob_bytes(ctx.repo.disk_path, entry.sha)
  end

  test "an edited resolution is what gets committed", ctx do
    {:ok, lv, _html} = live(ctx.conn, ctx.path)
    render_async(lv)

    resolved = "line1\nboth of them\nline3\n"

    lv
    |> form("form[phx-change=edit]", %{"path" => "f.txt", "text" => resolved})
    |> render_change()

    lv
    |> form("form[phx-change=set_message]", %{"message" => "Settle f.txt"})
    |> render_change()

    lv |> element("button[phx-click=merge]") |> render_click()
    assert_redirect(lv)

    {:ok, head} = Git.resolve(ctx.repo.disk_path, "refs/heads/main")
    {:ok, commit} = Git.commit(ctx.repo.disk_path, head)
    assert commit.message =~ "Settle f.txt"

    {:ok, entry} = Git.tree_entry_at(ctx.repo.disk_path, commit.tree_sha, "f.txt")
    assert {:ok, ^resolved} = Git.blob_bytes(ctx.repo.disk_path, entry.sha)
  end

  test "leaving the markers in is refused rather than committed", ctx do
    {:ok, lv, _html} = live(ctx.conn, ctx.path)
    html = render_async(lv)

    # Pretend the operator edited nothing and hit merge: the seeded text
    # still carries markers, so it has to be typed in explicitly.
    assert html =~ "<<<<<<<"

    marked = "<<<<<<< ours\nOURS\n=======\nTHEIRS\n>>>>>>> theirs\n"

    lv
    |> form("form[phx-change=edit]", %{"path" => "f.txt", "text" => marked})
    |> render_change()

    lv |> element("button[phx-click=merge]") |> render_click()
    # The merge runs off the LiveView process, so the refusal arrives
    # after the click returns.
    html = render_async(lv)

    assert html =~ "Conflict markers left in f.txt"
    # And the branch is where it was.
    assert {:ok, sha} = Git.resolve(ctx.repo.disk_path, "refs/heads/main")
    assert sha == ctx.ours
  end

  test "a branch that moved underneath is reported, not overwritten", ctx do
    {:ok, lv, _html} = live(ctx.conn, ctx.path)
    render_async(lv)

    lv |> element("button[phx-click=take][phx-value-side=ours]") |> render_click()

    # Someone pushes to main while the page is open.
    moved = commit(ctx.repo, %{"f.txt" => "line1\nSOMEONE ELSE\nline3\n"}, [ctx.ours])
    :ok = Git.update_ref(ctx.repo.disk_path, "refs/heads/main", moved, ctx.ours)

    lv |> element("button[phx-click=merge]") |> render_click()
    html = render_async(lv)

    assert html =~ "moved while you were resolving"
    assert {:ok, ^moved} = Git.resolve(ctx.repo.disk_path, "refs/heads/main")
  end

  test "a clean merge says so instead of showing an editor", ctx do
    clean =
      commit(ctx.repo, %{"f.txt" => "line1\nOURS\nline3\n", "new.txt" => "new\n"}, [ctx.ours])

    :ok = Git.update_ref(ctx.repo.disk_path, "refs/heads/clean", clean, nil)

    {:ok, lv, _html} =
      live(
        ctx.conn,
        "/r/#{Storage.repo_handle(ctx.repo)}/#{ctx.repo.name}/merge?into=main&from=clean"
      )

    html = render_async(lv)
    assert html =~ "merge cleanly"
    refute html =~ "still to resolve"
  end

  test "a stranger can't open the merge page of a private repository", ctx do
    other = user_fixture()
    conn = log_in_user(build_conn(), other)

    # Repositories default to private, so this one is. It used to
    # redirect with "you don't have permission to merge", which is a
    # sentence that confirms the repository exists — `RepoAuth` now
    # refuses the mount first, identically to a repository that isn't
    # there.
    assert_raise Ecto.NoResultsError, fn -> live(conn, ctx.path) end
  end

  test "a stranger who can read but not write is told why", %{repo: repo, path: path} do
    {:ok, _} = GitGud.Repositories.update_repository(repo, %{"visibility" => "public"})
    conn = log_in_user(build_conn(), user_fixture())

    # Readable, so the read gate lets them through and the merge page's
    # own write check is what answers — which is the behaviour the
    # previous version of this test was actually about, on a repository
    # where saying so leaks nothing.
    assert {:error, {:redirect, %{to: to, flash: flash}}} = live(conn, path)
    assert to == "/r/#{Storage.repo_handle(repo)}/#{repo.name}"
    assert flash["error"] =~ "permission"
  end

  # ── helpers ──────────────────────────────────────────────────────────

  defp uniq, do: System.unique_integer([:positive])

  defp commit(repo, files, parents) do
    path = repo.disk_path

    entries =
      Enum.map_join(files, "", fn {name, contents} ->
        "100644 blob #{Git.to_hex(blob(path, contents))}\t#{name}\n"
      end)

    tmp = Path.join(System.tmp_dir!(), "tree-#{uniq()}")
    File.write!(tmp, entries)
    {out, 0} = System.shell("git -C #{path} mktree < #{tmp}", stderr_to_stdout: true)
    File.rm(tmp)
    {:ok, tree} = Git.from_hex(String.trim(out))

    {:ok, sha} =
      Git.commit_tree(path, tree, parents, "c#{uniq()}",
        author: {"t", "t@t", DateTime.utc_now(:second)}
      )

    sha
  end

  defp blob(path, contents) do
    tmp = Path.join(System.tmp_dir!(), "blob-#{uniq()}")
    File.write!(tmp, contents)
    {hex, 0} = System.cmd("git", ["-C", path, "hash-object", "-w", tmp])
    File.rm(tmp)
    {:ok, sha} = Git.from_hex(String.trim(hex))
    sha
  end
end