12.5 KiB · text History 5e43417
defmodule GitGud.MergesTest do
  @moduledoc """
  What a merge editor has to be handed: every unresolved path, all three
  sides of it, and git's own marked-up merge to open.
  """
  use GitGud.DataCase, async: false
  use Oban.Testing, repo: GitGud.Repo

  alias GitGud.Git
  alias GitGud.Merges

  import GitGud.ForgeFixtures

  @moduletag :git_required

  setup do
    if System.find_executable("git") == nil do
      {:skip, "git not on PATH"}
    else
      {_user, repo} = repository_fixture()
      %{repo: repo}
    end
  end

  describe "analyze/3" do
    test "a merge that needs nobody comes back clean", %{repo: repo} do
      base = commit(repo, %{"f.txt" => "one\n"}, [])
      ours = commit(repo, %{"f.txt" => "one\n", "a.txt" => "a\n"}, [base])
      theirs = commit(repo, %{"f.txt" => "one\n", "b.txt" => "b\n"}, [base])

      assert {:ok, analysis} = Merges.analyze(repo, ours, theirs)
      assert analysis.status == :clean
      assert analysis.files == []
      assert analysis.base_sha == base

      # The tree is real and holds both sides' work — a clean analysis is
      # directly committable.
      assert {:ok, entries} = Git.tree(repo.disk_path, analysis.tree)
      assert Enum.map(entries, & &1.name) |> Enum.sort() == ["a.txt", "b.txt", "f.txt"]
    end

    test "both sides editing one file gives all three versions and the markers", %{repo: repo} do
      base = commit(repo, %{"f.txt" => "line1\nline2\nline3\n"}, [])
      ours = commit(repo, %{"f.txt" => "line1\nOURS\nline3\n"}, [base])
      theirs = commit(repo, %{"f.txt" => "line1\nTHEIRS\nline3\n"}, [base])

      assert {:ok, analysis} = Merges.analyze(repo, ours, theirs)
      assert analysis.status == :conflict
      assert [file] = analysis.files

      assert file.path == "f.txt"
      assert file.type == "content"
      refute file.binary?

      assert file.base.text == "line1\nline2\nline3\n"
      assert file.ours.text == "line1\nOURS\nline3\n"
      assert file.theirs.text == "line1\nTHEIRS\nline3\n"

      # git's own marked-up merge, which is what the editor opens.
      assert file.merged.text =~ "<<<<<<<"
      assert file.merged.text =~ "OURS"
      assert file.merged.text =~ "======="
      assert file.merged.text =~ "THEIRS"
      assert file.merged.text =~ ">>>>>>>"
      # Unconflicted context survives.
      assert file.merged.text =~ "line1\n"
    end

    test "both sides adding the same path has no base", %{repo: repo} do
      base = commit(repo, %{"f.txt" => "one\n"}, [])
      ours = commit(repo, %{"f.txt" => "one\n", "new.txt" => "from ours\n"}, [base])
      theirs = commit(repo, %{"f.txt" => "one\n", "new.txt" => "from theirs\n"}, [base])

      assert {:ok, %{files: [file]}} = Merges.analyze(repo, ours, theirs)

      assert file.path == "new.txt"
      assert file.type == "add/add"
      # The shape of the nils is the kind of conflict.
      assert is_nil(file.base)
      assert file.ours.text == "from ours\n"
      assert file.theirs.text == "from theirs\n"
    end

    test "one side deleting what the other changed reports the missing side", %{repo: repo} do
      base = commit(repo, %{"f.txt" => "one\n", "doomed.txt" => "keep\n"}, [])
      ours = commit(repo, %{"f.txt" => "one\n"}, [base])
      theirs = commit(repo, %{"f.txt" => "one\n", "doomed.txt" => "edited\n"}, [base])

      assert {:ok, %{files: [file]}} = Merges.analyze(repo, ours, theirs)

      assert file.path == "doomed.txt"
      assert file.type =~ "delete"
      assert file.base.text == "keep\n"
      assert is_nil(file.ours)
      assert file.theirs.text == "edited\n"
    end

    test "a binary conflict is flagged rather than handed to a text editor", %{repo: repo} do
      bin = fn tail -> <<0, 1, 2, 0, 255>> <> tail end

      base = commit(repo, %{"img.bin" => bin.("base")}, [])
      ours = commit(repo, %{"img.bin" => bin.("ours")}, [base])
      theirs = commit(repo, %{"img.bin" => bin.("theirs")}, [base])

      assert {:ok, %{files: [file]}} = Merges.analyze(repo, ours, theirs)

      assert file.path == "img.bin"
      assert file.binary?
      assert is_nil(file.ours.text)
      assert is_nil(file.theirs.text)
      # The sides are still identified, so "take ours / take theirs" is
      # available even though editing isn't.
      assert file.ours.sha != file.theirs.sha
      assert file.ours.size > 0
    end

    test "several conflicts come back sorted, and clean paths don't", %{repo: repo} do
      base = commit(repo, %{"a.txt" => "a\n", "b.txt" => "b\n", "c.txt" => "c\n"}, [])

      ours =
        commit(repo, %{"a.txt" => "a ours\n", "b.txt" => "b\n", "c.txt" => "c ours\n"}, [base])

      theirs =
        commit(
          repo,
          %{"a.txt" => "a theirs\n", "b.txt" => "b theirs\n", "c.txt" => "c theirs\n"},
          [
            base
          ]
        )

      assert {:ok, %{files: files}} = Merges.analyze(repo, ours, theirs)

      # b.txt changed on one side only: merged, not conflicted.
      assert Enum.map(files, & &1.path) == ["a.txt", "c.txt"]
    end

    test "unrelated histories are refused rather than offered an editor", %{repo: repo} do
      ours = commit(repo, %{"f.txt" => "ours\n"}, [])
      theirs = commit(repo, %{"g.txt" => "theirs\n"}, [])

      assert {:error, :no_merge_base} = Merges.analyze(repo, ours, theirs)
    end

    test "the analysis writes no ref", %{repo: repo} do
      base = commit(repo, %{"f.txt" => "line1\n"}, [])
      ours = commit(repo, %{"f.txt" => "ours\n"}, [base])
      theirs = commit(repo, %{"f.txt" => "theirs\n"}, [base])

      {:ok, before_refs} = Git.list_refs(repo.disk_path)
      assert {:ok, %{status: :conflict}} = Merges.analyze(repo, ours, theirs)
      assert {:ok, ^before_refs} = Git.list_refs(repo.disk_path)
    end
  end

  describe "resolve/4" do
    setup %{repo: repo} do
      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/target", ours, nil)
      {:ok, analysis} = Merges.analyze(repo, ours, theirs)

      %{base: base, ours: ours, theirs: theirs, analysis: analysis}
    end

    test "an edited file becomes the merge commit's content", ctx do
      resolved = "line1\nboth\nline3\n"

      assert {:ok, %{commit: commit}} =
               Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => {:text, resolved}},
                 ref: "refs/heads/target",
                 author: {"t", "t@t", DateTime.utc_now(:second)}
               )

      # A real merge: both sides are parents, so history stays honest
      # about where the change came from.
      assert {:ok, %{parent_shas: [ours, theirs]} = c} = Git.commit(ctx.repo.disk_path, commit)

      assert ours == ctx.ours
      assert theirs == ctx.theirs
      assert c.message =~ "Merge"

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

      # Untouched paths come through byte-identical.
      assert {:ok, keep} = Git.tree_entry_at(ctx.repo.disk_path, c.tree_sha, "keep.txt")
      assert {:ok, "keep\n"} = Git.blob_bytes(ctx.repo.disk_path, keep.sha)

      # And the ref moved to it.
      assert {:ok, ^commit} = Git.resolve(ctx.repo.disk_path, "refs/heads/target")
    end

    test "plays the push of the resolved merge, by whoever resolved it", ctx do
      assert {:ok, %{commit: commit}} =
               Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => :ours},
                 ref: "refs/heads/target",
                 actor_id: 42
               )

      assert_enqueued(
        worker: GitGud.Repositories.Workers.CommitBackfill,
        args: %{ref: "refs/heads/target", new_sha_hex: Git.to_hex(commit), pusher_id: 42}
      )
    end

    test "taking one side verbatim", ctx do
      assert {:ok, %{commit: commit}} =
               Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => :theirs},
                 ref: "refs/heads/target"
               )

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

    test "taking the side that deleted the file deletes it", %{repo: repo} do
      base = commit(repo, %{"f.txt" => "one\n", "doomed.txt" => "keep\n"}, [])
      ours = commit(repo, %{"f.txt" => "one\n"}, [base])
      theirs = commit(repo, %{"f.txt" => "one\n", "doomed.txt" => "edited\n"}, [base])
      :ok = Git.update_ref(repo.disk_path, "refs/heads/del", ours, nil)

      {:ok, analysis} = Merges.analyze(repo, ours, theirs)

      assert {:ok, %{commit: commit}} =
               Merges.resolve(repo, analysis, %{"doomed.txt" => :ours}, ref: "refs/heads/del")

      {:ok, c} = Git.commit(repo.disk_path, commit)
      assert {:error, :not_found} = Git.tree_entry_at(repo.disk_path, c.tree_sha, "doomed.txt")
    end

    test "a partial resolution is refused, not guessed at", %{repo: repo} do
      base = commit(repo, %{"a.txt" => "a\n", "b.txt" => "b\n"}, [])
      ours = commit(repo, %{"a.txt" => "a ours\n", "b.txt" => "b ours\n"}, [base])
      theirs = commit(repo, %{"a.txt" => "a theirs\n", "b.txt" => "b theirs\n"}, [base])
      :ok = Git.update_ref(repo.disk_path, "refs/heads/partial", ours, nil)

      {:ok, analysis} = Merges.analyze(repo, ours, theirs)

      assert {:error, {:unresolved, ["b.txt"]}} =
               Merges.resolve(repo, analysis, %{"a.txt" => :ours}, ref: "refs/heads/partial")

      # Nothing moved.
      assert {:ok, ^ours} = Git.resolve(repo.disk_path, "refs/heads/partial")
    end

    test "content that still has conflict markers is refused", ctx do
      marked = ctx.analysis.files |> hd() |> get_in([Access.key(:merged), Access.key(:text)])
      assert marked =~ "<<<<<<<"

      assert {:error, {:conflict_markers, ["f.txt"]}} =
               Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => {:text, marked}},
                 ref: "refs/heads/target"
               )

      assert {:ok, sha} = Git.resolve(ctx.repo.disk_path, "refs/heads/target")
      assert sha == ctx.ours
    end

    test "a push that lands mid-resolution wins", ctx do
      # Someone else moves the branch between analysis and resolution.
      moved = commit(ctx.repo, %{"f.txt" => "line1\nSOMEONE ELSE\nline3\n"}, [ctx.ours])
      :ok = Git.update_ref(ctx.repo.disk_path, "refs/heads/target", moved, ctx.ours)

      assert {:error, :concurrent_update} =
               Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => {:text, "resolved\n"}},
                 ref: "refs/heads/target"
               )

      assert {:ok, ^moved} = Git.resolve(ctx.repo.disk_path, "refs/heads/target")
    end

    test "the resolved branch merges cleanly afterwards", ctx do
      {:ok, %{commit: commit}} =
        Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => {:text, "line1\nboth\nline3\n"}},
          ref: "refs/heads/target"
        )

      # The point of recording both parents: merging theirs again is a
      # no-op rather than the same conflict a second time.
      assert {:ok, %{status: :clean}} = Merges.analyze(ctx.repo, commit, ctx.theirs)
    end
  end

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

  # Commit an exact set of files — the whole tree, so 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(blob(path, contents))}\t#{name}\n"
      end)

    tmp = Path.join(System.tmp_dir!(), "tree-#{System.unique_integer([:positive])}")
    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#{System.unique_integer([:positive])}",
        author: {"t", "t@t", DateTime.utc_now(:second)}
      )

    sha
  end

  defp blob(path, contents) do
    tmp = Path.join(System.tmp_dir!(), "blob-#{System.unique_integer([:positive])}")
    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