neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Resolve merge conflicts in the browser
62dda47 · Gabriel Morell · 2026-09-12 05:22
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/git.ex
+60
−0
| 50 | 50 | sha: sha(), |
| 51 | 51 | committed_at: DateTime.t() |
| 52 | 52 | } |
| 53 | + @typedoc """ | |
| 54 | + One path a three-way merge couldn't resolve on its own. | |
| 55 | + | |
| 56 | + `base`/`ours`/`theirs` are the blob at that path on each side, `nil` | |
| 57 | + where the side doesn't have it — which is what tells a modify/delete | |
| 58 | + apart from a content conflict. | |
| 59 | + """ | |
| 60 | + @type merge_conflict :: %{ | |
| 61 | + path: String.t(), | |
| 62 | + type: String.t(), | |
| 63 | + base: sha() | nil, | |
| 64 | + ours: sha() | nil, | |
| 65 | + theirs: sha() | nil, | |
| 66 | + modes: %{optional(atom()) => String.t()} | |
| 67 | + } | |
| 68 | + @typedoc """ | |
| 69 | + The result of a three-way merge that was written but not committed. | |
| 70 | + | |
| 71 | + `tree` is a real tree in the object database either way; when | |
| 72 | + `conflicts` is non-empty its conflicted paths hold the merged text | |
| 73 | + *with conflict markers*, which is exactly what an editor wants to | |
| 74 | + open. | |
| 75 | + """ | |
| 76 | + @type merge_analysis :: %{ | |
| 77 | + tree: sha(), | |
| 78 | + conflicts: [merge_conflict()] | |
| 79 | + } | |
| 80 | + @typedoc """ | |
| 81 | + Edits to apply to an existing tree: a blob to put at a path (with its | |
| 82 | + mode), or `:delete` to take the path out. | |
| 83 | + """ | |
| 84 | + @type tree_changes :: %{optional(String.t()) => {sha(), String.t()} | :delete} | |
| 53 | 85 | @type diff_stat :: %{ |
| 54 | 86 | files_changed: integer(), |
| 55 | 87 | insertions: integer(), |
| 182 | 214 | {:ok, sha()} | {:error, :conflict | term()} |
| 183 | 215 | def merge_tree(path, base, a, b), do: dispatch(:merge_tree, [path, base, a, b]) |
| 184 | 216 | |
| 217 | + @doc """ | |
| 218 | + The same three-way merge, but reporting *what* conflicted instead of | |
| 219 | + only *that* it did. | |
| 220 | + | |
| 221 | + `merge_tree/4` answers the question a merge button asks. This answers | |
| 222 | + the question a merge editor asks: every unresolved path, the blob on | |
| 223 | + each of the three sides, and a written tree whose conflicted paths | |
| 224 | + carry the marked-up merge. Nothing is committed and no ref moves. | |
| 225 | + """ | |
| 226 | + @spec merge_analysis(repo_path(), sha(), sha(), sha()) :: | |
| 227 | + {:ok, merge_analysis()} | {:error, term()} | |
| 228 | + def merge_analysis(path, base, ours, theirs), | |
| 229 | + do: dispatch(:merge_analysis, [path, base, ours, theirs]) | |
| 230 | + | |
| 231 | + @doc "Write `bytes` into the object database as a blob." | |
| 232 | + @spec write_blob(repo_path(), binary()) :: {:ok, sha()} | {:error, term()} | |
| 233 | + def write_blob(path, bytes), do: dispatch(:write_blob, [path, bytes]) | |
| 234 | + | |
| 235 | + @doc """ | |
| 236 | + Write a new tree that is `base_tree` with `changes` applied. | |
| 237 | + | |
| 238 | + The unit a merge resolution works in: take the tree git produced, swap | |
| 239 | + the resolved paths, keep everything else byte-identical. | |
| 240 | + """ | |
| 241 | + @spec write_tree_with(repo_path(), sha(), tree_changes()) :: {:ok, sha()} | {:error, term()} | |
| 242 | + def write_tree_with(path, base_tree, changes), | |
| 243 | + do: dispatch(:write_tree_with, [path, base_tree, changes]) | |
| 244 | + | |
| 185 | 245 | @doc """ |
| 186 | 246 | Create a commit object from an existing tree and parent list. `author` |
| 187 | 247 | is `{name, email, datetime}`. Returns the new commit SHA. |
modified
lib/git_gud/git/backend.ex
+5
−0
| 29 | 29 | {:ok, Git.sha()} | {:error, term()} |
| 30 | 30 | @callback merge_tree(Git.repo_path(), Git.sha(), Git.sha(), Git.sha()) :: |
| 31 | 31 | {:ok, Git.sha()} | {:error, term()} |
| 32 | + @callback merge_analysis(Git.repo_path(), Git.sha(), Git.sha(), Git.sha()) :: | |
| 33 | + {:ok, Git.merge_analysis()} | {:error, term()} | |
| 34 | + @callback write_blob(Git.repo_path(), binary()) :: {:ok, Git.sha()} | {:error, term()} | |
| 35 | + @callback write_tree_with(Git.repo_path(), Git.sha(), Git.tree_changes()) :: | |
| 36 | + {:ok, Git.sha()} | {:error, term()} | |
| 32 | 37 | @callback commit_tree(Git.repo_path(), Git.sha(), [Git.sha()], String.t(), keyword()) :: |
| 33 | 38 | {:ok, Git.sha()} | {:error, term()} |
| 34 | 39 | @callback update_ref(Git.repo_path(), String.t(), Git.sha(), Git.sha() | nil) :: |
modified
lib/git_gud/git/cli.ex
+214
−0
| 463 | 463 | end |
| 464 | 464 | end |
| 465 | 465 | |
| 466 | + # `git merge-tree --write-tree -z` is the machine-readable form, and | |
| 467 | + # its output is three NUL-separated sections: | |
| 468 | + # | |
| 469 | + # <tree oid> | |
| 470 | + # <mode> <oid> <stage>\t<path> one per conflicted stage, then "" | |
| 471 | + # <n> <path>… <type> <message> one record per informational line | |
| 472 | + # | |
| 473 | + # Stage 1 is the merge base, 2 ours, 3 theirs — a path missing a stage | |
| 474 | + # is a path that side doesn't have, which is how modify/delete tells | |
| 475 | + # itself apart from a content conflict. Exit 0 means clean, 1 means | |
| 476 | + # conflicts; anything else is a real failure. | |
| 477 | + @impl true | |
| 478 | + def merge_analysis(path, base, ours, theirs) do | |
| 479 | + args = [ | |
| 480 | + "merge-tree", | |
| 481 | + "--write-tree", | |
| 482 | + "-z", | |
| 483 | + "--merge-base=" <> Git.to_hex(base), | |
| 484 | + Git.to_hex(ours), | |
| 485 | + Git.to_hex(theirs) | |
| 486 | + ] | |
| 487 | + | |
| 488 | + case System.cmd("git", ["-C", path | args], stderr_to_stdout: false) do | |
| 489 | + {out, code} when code in [0, 1] -> | |
| 490 | + parse_merge_analysis(out) | |
| 491 | + | |
| 492 | + {err, code} -> | |
| 493 | + {:error, {:git_exit, code, err}} | |
| 494 | + end | |
| 495 | + end | |
| 496 | + | |
| 497 | + defp parse_merge_analysis(out) do | |
| 498 | + [tree_hex | rest] = String.split(out, <<0>>) | |
| 499 | + | |
| 500 | + with {:ok, tree} <- Git.from_hex(String.trim(tree_hex)) |> wrap_or_error() do | |
| 501 | + # The empty record that ends the stage section stays at the head | |
| 502 | + # of the remainder; drop it, or the message parse starts on a | |
| 503 | + # record boundary that isn't one. | |
| 504 | + {stages, messages} = Enum.split_while(rest, &(&1 != "")) | |
| 505 | + messages = Enum.drop_while(messages, &(&1 == "")) | |
| 506 | + | |
| 507 | + conflicts = | |
| 508 | + stages | |
| 509 | + |> Enum.flat_map(&parse_stage_line/1) | |
| 510 | + |> Enum.group_by(fn {path, _stage, _sha, _mode} -> path end) | |
| 511 | + |> Enum.map(&build_conflict(&1, parse_messages(messages))) | |
| 512 | + |> Enum.sort_by(& &1.path) | |
| 513 | + | |
| 514 | + {:ok, %{tree: tree, conflicts: conflicts}} | |
| 515 | + end | |
| 516 | + end | |
| 517 | + | |
| 518 | + # "<mode> <oid> <stage>\t<path>" | |
| 519 | + defp parse_stage_line(line) do | |
| 520 | + with [meta, path] <- String.split(line, "\t", parts: 2), | |
| 521 | + [mode, hex, stage] <- String.split(meta, " ", trim: true), | |
| 522 | + {:ok, sha} <- Git.from_hex(hex) do | |
| 523 | + [{path, stage, sha, mode}] | |
| 524 | + else | |
| 525 | + _ -> [] | |
| 526 | + end | |
| 527 | + end | |
| 528 | + | |
| 529 | + # The informational section is a stream of records, each: | |
| 530 | + # | |
| 531 | + # <n> <path>…(n of them) <type> <message> | |
| 532 | + # | |
| 533 | + # Fold it into `%{path => [{type, message}]}`. Parsing it properly | |
| 534 | + # rather than pattern-matching the prose matters because the two carry | |
| 535 | + # different things: the type is sometimes the coarse "CONFLICT | |
| 536 | + # (contents)" where only the message says "add/add". | |
| 537 | + defp parse_messages(records), do: parse_messages(records, %{}) | |
| 538 | + | |
| 539 | + defp parse_messages([], acc), do: acc | |
| 540 | + | |
| 541 | + defp parse_messages([count | rest], acc) do | |
| 542 | + case Integer.parse(String.trim(count)) do | |
| 543 | + {n, ""} when n >= 0 -> | |
| 544 | + {paths, rest} = Enum.split(rest, n) | |
| 545 | + | |
| 546 | + case rest do | |
| 547 | + [type, message | rest] -> | |
| 548 | + acc = | |
| 549 | + Enum.reduce(paths, acc, fn path, acc -> | |
| 550 | + Map.update(acc, path, [{type, message}], &(&1 ++ [{type, message}])) | |
| 551 | + end) | |
| 552 | + | |
| 553 | + parse_messages(rest, acc) | |
| 554 | + | |
| 555 | + _ -> | |
| 556 | + acc | |
| 557 | + end | |
| 558 | + | |
| 559 | + _ -> | |
| 560 | + # A record we don't understand (the trailing empty one, say): | |
| 561 | + # skip it rather than abandoning the rest. | |
| 562 | + parse_messages(rest, acc) | |
| 563 | + end | |
| 564 | + end | |
| 565 | + | |
| 566 | + defp build_conflict({path, entries}, messages) do | |
| 567 | + by_stage = Map.new(entries, fn {_p, stage, sha, mode} -> {stage, {sha, mode}} end) | |
| 568 | + | |
| 569 | + %{ | |
| 570 | + path: path, | |
| 571 | + type: conflict_type(Map.get(messages, path, [])), | |
| 572 | + base: stage_sha(by_stage, "1"), | |
| 573 | + ours: stage_sha(by_stage, "2"), | |
| 574 | + theirs: stage_sha(by_stage, "3"), | |
| 575 | + modes: | |
| 576 | + %{ | |
| 577 | + base: stage_mode(by_stage, "1"), | |
| 578 | + ours: stage_mode(by_stage, "2"), | |
| 579 | + theirs: stage_mode(by_stage, "3") | |
| 580 | + } | |
| 581 | + |> Enum.reject(fn {_k, v} -> is_nil(v) end) | |
| 582 | + |> Map.new() | |
| 583 | + } | |
| 584 | + end | |
| 585 | + | |
| 586 | + # "content", "add/add", "modify/delete" — git's own vocabulary, since | |
| 587 | + # the editor offers different things for each. The message is more | |
| 588 | + # specific than the type where they differ, so it wins. | |
| 589 | + defp conflict_type(records) do | |
| 590 | + records | |
| 591 | + |> Enum.flat_map(fn {type, message} -> [message, type] end) | |
| 592 | + |> Enum.find_value("content", fn text -> | |
| 593 | + case Regex.run(~r/^CONFLICT \(([^)]+)\)/, text) do | |
| 594 | + [_, "contents"] -> nil | |
| 595 | + [_, kind] -> kind | |
| 596 | + _ -> nil | |
| 597 | + end | |
| 598 | + end) | |
| 599 | + end | |
| 600 | + | |
| 601 | + defp stage_sha(by_stage, stage), do: by_stage |> Map.get(stage) |> then(&(&1 && elem(&1, 0))) | |
| 602 | + defp stage_mode(by_stage, stage), do: by_stage |> Map.get(stage) |> then(&(&1 && elem(&1, 1))) | |
| 603 | + | |
| 604 | + @impl true | |
| 605 | + def write_blob(path, bytes) when is_binary(bytes) do | |
| 606 | + tmp = Path.join(System.tmp_dir!(), "gitgud-blob-#{System.unique_integer([:positive])}") | |
| 607 | + File.write!(tmp, bytes) | |
| 608 | + | |
| 609 | + try do | |
| 610 | + case git(path, ["hash-object", "-w", "--", tmp]) do | |
| 611 | + {:ok, out} -> out |> String.trim() |> Git.from_hex() |> wrap_or_error() | |
| 612 | + err -> err | |
| 613 | + end | |
| 614 | + after | |
| 615 | + File.rm(tmp) | |
| 616 | + end | |
| 617 | + end | |
| 618 | + | |
| 619 | + # A temporary index, so nothing touches the repository's own: read the | |
| 620 | + # base tree into it, apply the edits, write the result back out. | |
| 621 | + @impl true | |
| 622 | + def write_tree_with(_path, base_tree, changes) when map_size(changes) == 0, | |
| 623 | + do: {:ok, base_tree} | |
| 624 | + | |
| 625 | + def write_tree_with(path, base_tree, changes) do | |
| 626 | + index = Path.join(System.tmp_dir!(), "gitgud-idx-#{System.unique_integer([:positive])}") | |
| 627 | + | |
| 628 | + # `update-index --force-remove` refuses to run without a work tree, | |
| 629 | + # even though it only touches the index. Point it at an empty | |
| 630 | + # directory: nothing is ever written there, and the deletion lands | |
| 631 | + # in the index where the rest of the edits do. | |
| 632 | + work_tree = Path.join(System.tmp_dir!(), "gitgud-wt-#{System.unique_integer([:positive])}") | |
| 633 | + File.mkdir_p!(work_tree) | |
| 634 | + | |
| 635 | + env = [{"GIT_INDEX_FILE", index}, {"GIT_WORK_TREE", work_tree}] | |
| 636 | + | |
| 637 | + try do | |
| 638 | + with :ok <- index_cmd(path, ["read-tree", Git.to_hex(base_tree)], env), | |
| 639 | + :ok <- apply_changes(path, changes, env), | |
| 640 | + {:ok, out} <- index_out(path, ["write-tree"], env) do | |
| 641 | + out |> String.trim() |> Git.from_hex() |> wrap_or_error() | |
| 642 | + end | |
| 643 | + after | |
| 644 | + File.rm(index) | |
| 645 | + File.rmdir(work_tree) | |
| 646 | + end | |
| 647 | + end | |
| 648 | + | |
| 649 | + defp apply_changes(path, changes, env) do | |
| 650 | + Enum.reduce_while(changes, :ok, fn change, :ok -> | |
| 651 | + case apply_change(path, change, env) do | |
| 652 | + :ok -> {:cont, :ok} | |
| 653 | + err -> {:halt, err} | |
| 654 | + end | |
| 655 | + end) | |
| 656 | + end | |
| 657 | + | |
| 658 | + defp apply_change(path, {file_path, :delete}, env), | |
| 659 | + do: index_cmd(path, ["update-index", "--force-remove", "--", file_path], env) | |
| 660 | + | |
| 661 | + defp apply_change(path, {file_path, {sha, mode}}, env) do | |
| 662 | + cacheinfo = "#{mode || "100644"},#{Git.to_hex(sha)},#{file_path}" | |
| 663 | + index_cmd(path, ["update-index", "--add", "--cacheinfo", cacheinfo], env) | |
| 664 | + end | |
| 665 | + | |
| 666 | + defp index_cmd(path, args, env) do | |
| 667 | + case index_out(path, args, env) do | |
| 668 | + {:ok, _} -> :ok | |
| 669 | + err -> err | |
| 670 | + end | |
| 671 | + end | |
| 672 | + | |
| 673 | + defp index_out(path, args, env) do | |
| 674 | + case System.cmd("git", ["-C", path | args], stderr_to_stdout: true, env: env) do | |
| 675 | + {out, 0} -> {:ok, out} | |
| 676 | + {out, code} -> {:error, {:git_exit, code, out}} | |
| 677 | + end | |
| 678 | + end | |
| 679 | + | |
| 466 | 680 | @impl true |
| 467 | 681 | def commit_tree(path, tree_sha, parents, message, opts) do |
| 468 | 682 | {author_name, author_email, dt} = |
modified
lib/git_gud/git/nif.ex
+14
−0
| 67 | 67 | @impl true |
| 68 | 68 | def merge_tree(_path, _base, _a, _b), do: nif_error() |
| 69 | 69 | |
| 70 | + # gix reports conflicts structurally, but mapping its `Conflict` shape | |
| 71 | + # across the boundary is its own piece of work — `git merge-tree -z` | |
| 72 | + # already answers this exactly, so the Cli backend owns it for now. | |
| 73 | + @impl true | |
| 74 | + def merge_analysis(_path, _base, _ours, _theirs), do: {:error, :unsupported} | |
| 75 | + | |
| 76 | + # Both are index plumbing in git terms, and the Cli backend expresses | |
| 77 | + # them directly with a temporary index. | |
| 78 | + @impl true | |
| 79 | + def write_blob(_path, _bytes), do: {:error, :unsupported} | |
| 80 | + | |
| 81 | + @impl true | |
| 82 | + def write_tree_with(_path, _base_tree, _changes), do: {:error, :unsupported} | |
| 83 | + | |
| 70 | 84 | @doc """ |
| 71 | 85 | `%DateTime{}` is an Elixir struct the NIF has no business decoding, so |
| 72 | 86 | the author identity is flattened here and the native function takes |
added
lib/git_gud/merges.ex
+294
−0
| 1 | +defmodule GitGud.Merges do | |
| 2 | + @moduledoc """ | |
| 3 | + Three-way merges that a human still has to finish. | |
| 4 | + | |
| 5 | + Everything else in the forge asks a merge one question — did it | |
| 6 | + work? — and `GitGud.Git.merge_tree/4` answers it. This module answers | |
| 7 | + the follow-up: *what* is in the way, and what would it take to resolve | |
| 8 | + it in the browser. | |
| 9 | + | |
| 10 | + Two callers want that, and they are the same shape underneath: | |
| 11 | + | |
| 12 | + * a pull request whose merge into its target conflicts | |
| 13 | + * a branch that moved both here and upstream (see | |
| 14 | + `GitGud.Repositories.upstream_status/2`), where the reconcile | |
| 15 | + sync deliberately stops | |
| 16 | + | |
| 17 | + So neither owns this. Both hand over two commits — ours and theirs — | |
| 18 | + and get back the merge base, a written tree, and one entry per | |
| 19 | + unresolved path carrying all three sides. | |
| 20 | + | |
| 21 | + Nothing here writes a ref or a commit. `analyze/3` is a read: it | |
| 22 | + writes blobs and a tree into the object database (unreachable until | |
| 23 | + something references them, and collectable if nothing ever does), and | |
| 24 | + that is the whole of its effect. | |
| 25 | + """ | |
| 26 | + | |
| 27 | + alias GitGud.BranchProtections | |
| 28 | + alias GitGud.Git | |
| 29 | + alias GitGud.Repositories | |
| 30 | + alias GitGud.Repositories.Repository | |
| 31 | + | |
| 32 | + # Both sides of a conflict are rendered in a browser. Past this, the | |
| 33 | + # editor isn't the right tool and the answer is to resolve it locally, | |
| 34 | + # so the content is left out rather than shipped to a page that would | |
| 35 | + # choke on it. | |
| 36 | + @max_editable_bytes 1_000_000 | |
| 37 | + | |
| 38 | + @typedoc """ | |
| 39 | + One path the merge couldn't settle. | |
| 40 | + | |
| 41 | + `base` is `nil` when both sides added the path, `ours`/`theirs` when | |
| 42 | + that side deleted it — the shape of the nils *is* the kind of | |
| 43 | + conflict, and `type` names it the way git does ("content", | |
| 44 | + "add/add", "modify/delete"). | |
| 45 | + | |
| 46 | + `merged` is the marked-up three-way merge git produced, and is what | |
| 47 | + the editor opens. It is absent for a conflict with no sensible text | |
| 48 | + form: a binary file, or one side deleting what the other changed. | |
| 49 | + """ | |
| 50 | + @type file :: %{ | |
| 51 | + path: String.t(), | |
| 52 | + type: String.t(), | |
| 53 | + base: side() | nil, | |
| 54 | + ours: side() | nil, | |
| 55 | + theirs: side() | nil, | |
| 56 | + merged: side() | nil, | |
| 57 | + binary?: boolean() | |
| 58 | + } | |
| 59 | + | |
| 60 | + @typedoc "One side of one path. `text` is `nil` for binary or oversized blobs." | |
| 61 | + @type side :: %{sha: Git.sha(), mode: String.t() | nil, size: integer(), text: String.t() | nil} | |
| 62 | + | |
| 63 | + @doc """ | |
| 64 | + Work out what merging `theirs` into `ours` would take. | |
| 65 | + | |
| 66 | + Returns `{:ok, analysis}` where `analysis.status` is `:clean` (the | |
| 67 | + tree is ready to commit as-is) or `:conflict` (with `files`), or: | |
| 68 | + | |
| 69 | + * `{:error, :no_merge_base}` — unrelated histories, which is not a | |
| 70 | + merge anyone should be offered a UI for | |
| 71 | + * `{:error, reason}` from git | |
| 72 | + """ | |
| 73 | + @spec analyze(Repository.t(), Git.sha(), Git.sha()) :: {:ok, map()} | {:error, term()} | |
| 74 | + def analyze(%Repository{disk_path: path}, ours_sha, theirs_sha) do | |
| 75 | + with {:ok, base_sha} <- merge_base(path, ours_sha, theirs_sha), | |
| 76 | + {:ok, %{tree: tree, conflicts: conflicts}} <- | |
| 77 | + Git.merge_analysis(path, base_sha, ours_sha, theirs_sha) do | |
| 78 | + {:ok, | |
| 79 | + %{ | |
| 80 | + status: if(conflicts == [], do: :clean, else: :conflict), | |
| 81 | + base_sha: base_sha, | |
| 82 | + ours_sha: ours_sha, | |
| 83 | + theirs_sha: theirs_sha, | |
| 84 | + tree: tree, | |
| 85 | + files: Enum.map(conflicts, &describe(path, tree, &1)) | |
| 86 | + }} | |
| 87 | + end | |
| 88 | + end | |
| 89 | + | |
| 90 | + @doc """ | |
| 91 | + Finish a merge the analysis found conflicts in, and move a ref to it. | |
| 92 | + | |
| 93 | + `resolutions` is one entry per conflicted path: | |
| 94 | + | |
| 95 | + %{"lib/a.ex" => :ours, | |
| 96 | + "lib/b.ex" => :theirs, | |
| 97 | + "README.md" => {:text, "the edited file\n"}} | |
| 98 | + | |
| 99 | + Every conflicted path needs one. A partial resolution is refused | |
| 100 | + rather than guessed at — `{:error, {:unresolved, paths}}` — because | |
| 101 | + the alternative is committing a file with conflict markers in it, | |
| 102 | + which is the exact failure this whole flow exists to prevent. For the | |
| 103 | + same reason `{:text, _}` content is checked for markers before it is | |
| 104 | + written. | |
| 105 | + | |
| 106 | + `:ours`/`:theirs` on a path that side deleted resolves to a deletion, | |
| 107 | + which is what "take that side" means there. | |
| 108 | + | |
| 109 | + Branch protection applies exactly as it does to a push or a PR merge: | |
| 110 | + resolving a conflict in a browser is still a ref update, and a rule | |
| 111 | + that would refuse it there refuses it here. | |
| 112 | + | |
| 113 | + The ref update is conditional on `expected_old`, so a merge racing a | |
| 114 | + push loses cleanly (`{:error, :concurrent_update}`) instead of | |
| 115 | + overwriting it — same contract as `PullRequests.merge!/3`. | |
| 116 | + """ | |
| 117 | + @spec resolve(Repository.t(), map(), map(), keyword()) :: | |
| 118 | + {:ok, %{commit: Git.sha(), tree: Git.sha()}} | {:error, term()} | |
| 119 | + def resolve(%Repository{disk_path: path} = repo, analysis, resolutions, opts) do | |
| 120 | + ref = Keyword.fetch!(opts, :ref) | |
| 121 | + expected_old = Keyword.get(opts, :expected_old, analysis.ours_sha) | |
| 122 | + message = Keyword.get(opts, :message) || default_message(analysis) | |
| 123 | + author = Keyword.get(opts, :author) | |
| 124 | + | |
| 125 | + with :ok <- ensure_complete(analysis, resolutions), | |
| 126 | + :ok <- ensure_unmarked(resolutions), | |
| 127 | + :ok <- check_protection(repo, ref, expected_old, analysis, opts), | |
| 128 | + {:ok, changes} <- build_changes(path, analysis, resolutions), | |
| 129 | + {:ok, tree} <- Git.write_tree_with(path, analysis.tree, changes), | |
| 130 | + {:ok, commit} <- | |
| 131 | + Git.commit_tree( | |
| 132 | + path, | |
| 133 | + tree, | |
| 134 | + [analysis.ours_sha, analysis.theirs_sha], | |
| 135 | + message, | |
| 136 | + author: author | |
| 137 | + ), | |
| 138 | + :ok <- move_ref(repo, ref, commit, expected_old) do | |
| 139 | + {:ok, %{commit: commit, tree: tree}} | |
| 140 | + end | |
| 141 | + end | |
| 142 | + | |
| 143 | + defp check_protection(repo, "refs/heads/" <> branch, expected_old, analysis, opts) do | |
| 144 | + BranchProtections.check_ref_update( | |
| 145 | + repo, | |
| 146 | + branch, | |
| 147 | + expected_old, | |
| 148 | + analysis.theirs_sha, | |
| 149 | + Keyword.take(opts, [:pull_request_id]) | |
| 150 | + ) | |
| 151 | + end | |
| 152 | + | |
| 153 | + # Anything outside refs/heads/ has no branch rule to check. | |
| 154 | + defp check_protection(_repo, _ref, _expected_old, _analysis, _opts), do: :ok | |
| 155 | + | |
| 156 | + defp ensure_complete(%{files: files}, resolutions) do | |
| 157 | + case Enum.reject(files, &Map.has_key?(resolutions, &1.path)) do | |
| 158 | + [] -> :ok | |
| 159 | + missing -> {:error, {:unresolved, Enum.map(missing, & &1.path)}} | |
| 160 | + end | |
| 161 | + end | |
| 162 | + | |
| 163 | + # A resolution that still carries markers is the failure mode this | |
| 164 | + # flow exists to prevent, and it is cheap to refuse here rather than | |
| 165 | + # discover in a `git blame` a month later. | |
| 166 | + defp ensure_unmarked(resolutions) do | |
| 167 | + marked = | |
| 168 | + for {path, {:text, text}} <- resolutions, | |
| 169 | + String.contains?(text, ["<<<<<<< ", ">>>>>>> "]), | |
| 170 | + do: path | |
| 171 | + | |
| 172 | + case marked do | |
| 173 | + [] -> :ok | |
| 174 | + paths -> {:error, {:conflict_markers, paths}} | |
| 175 | + end | |
| 176 | + end | |
| 177 | + | |
| 178 | + defp build_changes(path, %{files: files}, resolutions) do | |
| 179 | + Enum.reduce_while(files, {:ok, %{}}, fn file, {:ok, acc} -> | |
| 180 | + case change_for(path, file, Map.fetch!(resolutions, file.path)) do | |
| 181 | + {:ok, change} -> {:cont, {:ok, Map.put(acc, file.path, change)}} | |
| 182 | + err -> {:halt, err} | |
| 183 | + end | |
| 184 | + end) | |
| 185 | + end | |
| 186 | + | |
| 187 | + defp change_for(_path, file, :ours), do: {:ok, side_change(file.ours)} | |
| 188 | + defp change_for(_path, file, :theirs), do: {:ok, side_change(file.theirs)} | |
| 189 | + | |
| 190 | + defp change_for(path, file, {:text, text}) do | |
| 191 | + case Git.write_blob(path, text) do | |
| 192 | + {:ok, sha} -> {:ok, {sha, mode_of(file)}} | |
| 193 | + err -> err | |
| 194 | + end | |
| 195 | + end | |
| 196 | + | |
| 197 | + defp change_for(_path, file, other), | |
| 198 | + do: {:error, {:bad_resolution, file.path, other}} | |
| 199 | + | |
| 200 | + # Taking a side that deleted the path means deleting it. | |
| 201 | + defp side_change(nil), do: :delete | |
| 202 | + defp side_change(%{sha: sha, mode: mode}), do: {sha, mode || "100644"} | |
| 203 | + | |
| 204 | + # Keep whichever mode the sides agree on; an executable bit shouldn't | |
| 205 | + # be lost to a resolution. | |
| 206 | + defp mode_of(file) do | |
| 207 | + [file.ours, file.theirs, file.base, file.merged] | |
| 208 | + |> Enum.find_value("100644", fn side -> side && side.mode end) | |
| 209 | + end | |
| 210 | + | |
| 211 | + defp default_message(%{theirs_sha: theirs}), | |
| 212 | + do: "Merge #{Git.short(theirs)}, resolving conflicts" | |
| 213 | + | |
| 214 | + defp move_ref(%Repository{disk_path: path} = repo, ref, commit, expected_old) do | |
| 215 | + case Git.update_ref(path, ref, commit, expected_old) do | |
| 216 | + :ok -> | |
| 217 | + # Keep the ref cache in step without waiting for post-receive, | |
| 218 | + # the way the fork sync and PR merge paths do. | |
| 219 | + :ok = Repositories.apply_ref_update(repo, ref, expected_old, commit) | |
| 220 | + :ok | |
| 221 | + | |
| 222 | + {:error, _} -> | |
| 223 | + # git refuses the update when the ref has moved since; that is | |
| 224 | + # the only expected failure here, and it is not ours to force. | |
| 225 | + {:error, :concurrent_update} | |
| 226 | + end | |
| 227 | + end | |
| 228 | + | |
| 229 | + defp merge_base(path, ours, theirs) do | |
| 230 | + case Git.merge_base(path, ours, theirs) do | |
| 231 | + {:ok, base} -> {:ok, base} | |
| 232 | + {:error, :not_found} -> {:error, :no_merge_base} | |
| 233 | + err -> err | |
| 234 | + end | |
| 235 | + end | |
| 236 | + | |
| 237 | + defp describe(path, tree, conflict) do | |
| 238 | + sides = | |
| 239 | + Map.new([:base, :ours, :theirs], fn side -> | |
| 240 | + {side, load_side(path, Map.get(conflict, side), get_in(conflict, [:modes, side]))} | |
| 241 | + end) | |
| 242 | + | |
| 243 | + merged = load_merged(path, tree, conflict.path) | |
| 244 | + | |
| 245 | + # "Binary" is a property of the conflict, not of one side: a side | |
| 246 | + # that is absent says nothing, and one binary side is enough to make | |
| 247 | + # the text editor the wrong tool. | |
| 248 | + binary? = | |
| 249 | + [sides.base, sides.ours, sides.theirs, merged] | |
| 250 | + |> Enum.reject(&is_nil/1) | |
| 251 | + |> Enum.any?(&is_nil(&1.text)) | |
| 252 | + | |
| 253 | + %{ | |
| 254 | + path: conflict.path, | |
| 255 | + type: conflict.type, | |
| 256 | + base: sides.base, | |
| 257 | + ours: sides.ours, | |
| 258 | + theirs: sides.theirs, | |
| 259 | + merged: merged, | |
| 260 | + binary?: binary? | |
| 261 | + } | |
| 262 | + end | |
| 263 | + | |
| 264 | + defp load_side(_path, nil, _mode), do: nil | |
| 265 | + | |
| 266 | + defp load_side(path, sha, mode) do | |
| 267 | + case Git.blob_meta(path, sha) do | |
| 268 | + {:ok, %{size: size, is_binary: is_binary}} -> | |
| 269 | + %{sha: sha, mode: mode, size: size, text: text_of(path, sha, size, is_binary)} | |
| 270 | + | |
| 271 | + _ -> | |
| 272 | + nil | |
| 273 | + end | |
| 274 | + end | |
| 275 | + | |
| 276 | + # The conflicted path inside the written tree: git's own marked-up | |
| 277 | + # merge, which beats reconstructing markers ourselves. | |
| 278 | + defp load_merged(path, tree, file_path) do | |
| 279 | + case Git.tree_entry_at(path, tree, file_path) do | |
| 280 | + {:ok, %{sha: sha, mode: mode}} -> load_side(path, sha, mode) | |
| 281 | + _ -> nil | |
| 282 | + end | |
| 283 | + end | |
| 284 | + | |
| 285 | + defp text_of(_path, _sha, size, _binary) when size > @max_editable_bytes, do: nil | |
| 286 | + defp text_of(_path, _sha, _size, true), do: nil | |
| 287 | + | |
| 288 | + defp text_of(path, sha, _size, false) do | |
| 289 | + case Git.blob_bytes(path, sha) do | |
| 290 | + {:ok, bytes} -> if String.valid?(bytes), do: bytes, else: nil | |
| 291 | + _ -> nil | |
| 292 | + end | |
| 293 | + end | |
| 294 | +end |
modified
lib/git_gud_web/live/pr_live/show.ex
+9
−0
Click to load diff…
added
lib/git_gud_web/live/repo_live/merge.ex
+432
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/settings.ex
+13
−4
Click to load diff…
modified
lib/git_gud_web/router.ex
+3
−0
Click to load diff…
added
test/git_gud/merges_test.exs
+310
−0
Click to load diff…
added
test/git_gud_web/live/repo_live/merge_test.exs
+208
−0
Click to load diff…
modified
test/git_gud_web/live/repo_live/upstream_panel_test.exs
+3
−0
Click to load diff…
Parents: 570765b