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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.5 KiB · text
History
5e43417