Resolve merge conflicts in the browser

62dda47 · Gabriel Morell · 2026-09-12 05:22

24 files +1565 -4
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/git.ex
+60 −0
@@ -50,6 +50,38 @@ defmodule GitGud.Git do
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,6 +214,34 @@ defmodule GitGud.Git do
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,6 +29,11 @@ defmodule GitGud.Git.Backend do
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,6 +463,220 @@ defmodule GitGud.Git.Cli do
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,6 +67,20 @@ defmodule GitGud.Git.Nif do
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
@@ -0,0 +1,294 @@
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