ref:main
Coverage: 90.3%
121/134 lines covered
1
# Copyright 2026 Cole Christensen
2
#
3
# Licensed under the Apache License, Version 2.0 (the "License");
4
# you may not use this file except in compliance with the License.
5
# You may obtain a copy of the License at
6
#
7
# http://www.apache.org/licenses/LICENSE-2.0
8
#
9
# Unless required by applicable law or agreed to in writing, software
10
# distributed under the License is distributed on an "AS IS" BASIS,
11
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
# See the License for the specific language governing permissions and
13
# limitations under the License.
14
15
defmodule ExGitObjectstore.Diff do
16
@moduledoc """
17
Tree-level diff and line-level diff with hunk formatting.
18
"""
19
20
alias ExGitObjectstore.Diff.Myers
21
alias ExGitObjectstore.Object.{Blob, Tree}
22
alias ExGitObjectstore.{ObjectResolver, Repo}
23
24
@max_tree_depth 64
25
26
@type file_change :: %{
27
path: String.t(),
28
status: :added | :deleted | :modified | :renamed,
29
old_sha: String.t() | nil,
30
new_sha: String.t() | nil,
31
old_mode: String.t() | nil,
32
new_mode: String.t() | nil
33
}
34
35
@type hunk :: %{
36
old_start: non_neg_integer(),
37
old_count: non_neg_integer(),
38
new_start: non_neg_integer(),
39
new_count: non_neg_integer(),
40
lines: [{:context | :add | :del, String.t()}]
41
}
42
43
@type file_diff :: %{
44
path: String.t(),
45
status: :added | :deleted | :modified,
46
hunks: [hunk()]
47
}
48
49
@doc """
50
Compute tree-level diff between two tree SHAs.
51
Returns a list of file changes.
52
"""
53
@spec diff_trees(Repo.t(), String.t() | nil, String.t() | nil) ::
54
{:ok, [file_change()]} | {:error, term()}
55
def diff_trees(repo, old_tree_sha, new_tree_sha) do
56
old_entries = load_tree_entries(repo, old_tree_sha)
57
new_entries = load_tree_entries(repo, new_tree_sha)
58
59
try do
60
changes = diff_tree_entries(repo, old_entries, new_entries, "", 0)
61
{:ok, List.flatten(changes)}
62
catch
63
:throw, {:error, _} = err -> err
64
end
65
end
66
67
@doc """
68
Compute line-level diff for a file change.
69
Returns hunks with context lines.
70
"""
71
@spec diff_blobs(Repo.t(), String.t() | nil, String.t() | nil, keyword()) ::
72
{:ok, [hunk()]}
73
| {:ok, :binary, %{old_size: non_neg_integer(), new_size: non_neg_integer()}}
74
| {:error, term()}
75
def diff_blobs(repo, old_sha, new_sha, opts \\ []) do
76
context = Keyword.get(opts, :context, 3)
77
78
old_content = load_blob_content(repo, old_sha)
79
new_content = load_blob_content(repo, new_sha)
80
81
if binary?(old_content) or binary?(new_content) do
82
{:ok, :binary, %{old_size: byte_size(old_content), new_size: byte_size(new_content)}}
83
else
84
edits = Myers.diff_lines(old_content, new_content)
85
hunks = edits_to_hunks(edits, context)
86
{:ok, hunks}
87
end
88
end
89
90
@doc """
91
Compute full diff (tree changes + line diffs) between two commits.
92
"""
93
@spec diff_commits(Repo.t(), String.t(), String.t(), keyword()) ::
94
{:ok, [file_diff()]} | {:error, term()}
95
def diff_commits(repo, old_commit_sha, new_commit_sha, opts \\ []) do
96
with {:ok, old_commit} <- ObjectResolver.read(repo, old_commit_sha),
97
{:ok, new_commit} <- ObjectResolver.read(repo, new_commit_sha),
98
{:ok, changes} <- diff_trees(repo, old_commit.tree, new_commit.tree) do
99
file_diffs = Enum.map(changes, &build_file_diff(repo, &1, opts))
100
{:ok, file_diffs}
101
end
102
end
103
104
defp build_file_diff(repo, change, opts) do
105
case diff_blobs(repo, change.old_sha, change.new_sha, opts) do
106
{:ok, :binary, sizes} ->
107
%{
108
path: change.path,
109
status: change.status,
110
hunks: [],
111
binary: true,
112
old_size: sizes.old_size,
113
new_size: sizes.new_size
114
}
115
116
{:ok, hunks} ->
117
%{path: change.path, status: change.status, hunks: hunks}
118
end
119
end
120
121
@doc """
122
Format hunks into unified diff text.
123
"""
124
@spec format_unified(String.t(), [hunk()], keyword()) :: String.t()
125
def format_unified(path, hunks, opts \\ []) do
126
old_content = Keyword.get(opts, :old_content)
127
new_content = Keyword.get(opts, :new_content)
128
129
no_newline_old =
130
old_content != nil and old_content != "" and not String.ends_with?(old_content, "\n")
131
132
no_newline_new =
133
new_content != nil and new_content != "" and not String.ends_with?(new_content, "\n")
134
135
header = "--- a/#{path}\n+++ b/#{path}\n"
136
137
body =
138
Enum.map(hunks, fn hunk ->
139
hunk_header =
140
"@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@\n"
141
142
lines = format_hunk_lines(hunk.lines, no_newline_old, no_newline_new)
143
144
[hunk_header | lines]
145
end)
146
147
IO.iodata_to_binary([header | body])
148
end
149
150
defp format_hunk_lines(lines, no_newline_old, no_newline_new) do
151
lines
152
|> Enum.with_index()
153
|> Enum.flat_map(fn {{type, line}, idx} ->
154
remaining = Enum.drop(lines, idx + 1)
155
formatted = format_line_by_type(type, line)
156
markers = no_newline_markers(type, remaining, no_newline_old, no_newline_new)
157
[formatted | markers]
158
end)
159
end
160
161
defp format_line_by_type(:context, line), do: " #{line}\n"
162
defp format_line_by_type(:add, line), do: "+#{line}\n"
163
defp format_line_by_type(:del, line), do: "-#{line}\n"
164
165
defp no_newline_markers(type, remaining, no_newline_old, no_newline_new) do
166
last_old? = type in [:del, :context] and no_more_old_lines?(remaining)
167
last_new? = type in [:add, :context] and no_more_new_lines?(remaining)
168
169
if (last_old? and no_newline_old) or (last_new? and no_newline_new) do
170
["\\ No newline at end of file\n"]
171
else
172
[]
173
end
174
end
175
176
defp no_more_old_lines?(remaining) do
177
not Enum.any?(remaining, fn {t, _} -> t in [:del, :context] end)
178
end
179
180
defp no_more_new_lines?(remaining) do
181
not Enum.any?(remaining, fn {t, _} -> t in [:add, :context] end)
182
end
183
184
# -- Private --
185
186
defp load_tree_entries(_repo, nil), do: %{}
187
188
defp load_tree_entries(repo, tree_sha) do
189
case ObjectResolver.read(repo, tree_sha) do
190
{:ok, %Tree{entries: entries}} ->
191
Map.new(entries, fn entry -> {entry.name, entry} end)
192
193
_ ->
194
%{}
195
end
196
end
197
198
defp binary?(content) when byte_size(content) == 0, do: false
199
200
defp binary?(content) do
201
chunk = binary_part(content, 0, min(byte_size(content), 8192))
202
:binary.match(chunk, <<0>>) != :nomatch
203
end
204
205
defp load_blob_content(_repo, nil), do: ""
206
207
defp load_blob_content(repo, sha) do
208
case ObjectResolver.read(repo, sha) do
209
{:ok, %Blob{content: content}} -> content
210
_ -> ""
211
end
212
end
213
214
defp diff_tree_entries(_repo, _old_entries, _new_entries, _prefix, depth)
215
when depth > @max_tree_depth do
216
throw({:error, :max_tree_depth_exceeded})
217
end
218
219
defp diff_tree_entries(repo, old_entries, new_entries, prefix, depth) do
220
all_names =
221
MapSet.union(
222
MapSet.new(Map.keys(old_entries)),
223
MapSet.new(Map.keys(new_entries))
224
)
225
|> Enum.sort()
226
227
Enum.flat_map(all_names, fn name ->
228
path = if prefix == "", do: name, else: "#{prefix}/#{name}"
229
old = Map.get(old_entries, name)
230
new = Map.get(new_entries, name)
231
diff_single_entry(repo, old, new, path, depth)
232
end)
233
end
234
235
defp diff_single_entry(repo, nil, new, path, depth) do
236
diff_added_entry(repo, new, path, depth)
237
end
238
239
defp diff_single_entry(repo, old, nil, path, depth) do
240
diff_deleted_entry(repo, old, path, depth)
241
end
242
243
defp diff_single_entry(_repo, old, new, _path, _depth)
244
when old.sha == new.sha and old.mode == new.mode do
245
[]
246
end
247
248
defp diff_single_entry(repo, old, new, path, depth)
249
when old.mode == "40000" and new.mode == "40000" do
250
old_sub = load_tree_entries(repo, old.sha)
251
new_sub = load_tree_entries(repo, new.sha)
252
diff_tree_entries(repo, old_sub, new_sub, path, depth + 1)
253
end
254
255
defp diff_single_entry(_repo, old, new, path, _depth) do
256
[
257
%{
258
path: path,
259
status: :modified,
260
old_sha: old.sha,
261
new_sha: new.sha,
262
old_mode: old.mode,
263
new_mode: new.mode
264
}
265
]
266
end
267
268
defp diff_added_entry(repo, %{mode: "40000"} = new, path, depth) do
269
new_sub = load_tree_entries(repo, new.sha)
270
diff_tree_entries(repo, %{}, new_sub, path, depth + 1)
271
end
272
273
defp diff_added_entry(_repo, new, path, _depth) do
274
[
275
%{
276
path: path,
277
status: :added,
278
old_sha: nil,
279
new_sha: new.sha,
280
old_mode: nil,
281
new_mode: new.mode
282
}
283
]
284
end
285
286
defp diff_deleted_entry(repo, %{mode: "40000"} = old, path, depth) do
287
old_sub = load_tree_entries(repo, old.sha)
288
diff_tree_entries(repo, old_sub, %{}, path, depth + 1)
289
end
290
291
defp diff_deleted_entry(_repo, old, path, _depth) do
292
[
293
%{
294
path: path,
295
status: :deleted,
296
old_sha: old.sha,
297
new_sha: nil,
298
old_mode: old.mode,
299
new_mode: nil
300
}
301
]
302
end
303
304
defp edits_to_hunks(edits, context) do
305
indexed = index_edits(edits)
306
indexed_vec = :array.from_list(indexed)
307
total = :array.size(indexed_vec)
308
309
# Find indices of all change (add/del) lines
310
change_indices =
311
indexed
312
|> Enum.with_index()
313
|> Enum.filter(fn {{type, _, _, _}, _idx} -> type in [:add, :del] end)
314
|> Enum.map(fn {_, idx} -> idx end)
315
316
if change_indices == [] do
317
[]
318
else
319
# Expand each change index into a range with context, then merge overlapping ranges
320
ranges =
321
change_indices
322
|> Enum.map(fn idx -> {max(0, idx - context), min(total - 1, idx + context)} end)
323
|> merge_ranges()
324
325
# Build a hunk from each merged range
326
Enum.map(ranges, &range_to_hunk(&1, indexed_vec))
327
end
328
end
329
330
defp range_to_hunk({start_idx, end_idx}, indexed_vec) do
331
lines = for i <- start_idx..end_idx, do: :array.get(i, indexed_vec)
332
build_hunk(lines)
333
end
334
335
defp merge_ranges([]), do: []
336
337
defp merge_ranges([first | rest]) do
338
Enum.reduce(rest, [first], fn {s, e}, [{prev_s, prev_e} | acc] ->
339
if s <= prev_e + 1 do
340
[{prev_s, max(prev_e, e)} | acc]
341
else
342
[{s, e}, {prev_s, prev_e} | acc]
343
end
344
end)
345
|> Enum.reverse()
346
end
347
348
defp index_edits(edits) do
349
{result, _old_line, _new_line} =
350
Enum.reduce(edits, {[], 1, 1}, fn
351
{:eq, line}, {acc, old, new} ->
352
{[{:context, line, old, new} | acc], old + 1, new + 1}
353
354
{:del, line}, {acc, old, new} ->
355
{[{:del, line, old, new} | acc], old + 1, new}
356
357
{:ins, line}, {acc, old, new} ->
358
{[{:add, line, old, new} | acc], old, new + 1}
359
end)
360
361
Enum.reverse(result)
362
end
363
364
defp build_hunk(lines) do
365
# Filter out trailing empty context that's just the last empty-string split artifact
366
lines = drop_trailing_empty_context(lines)
367
368
{old_lines, new_lines, formatted} =
369
Enum.reduce(lines, {0, 0, []}, fn
370
{:context, line, _old, _new}, {old_c, new_c, acc} ->
371
{old_c + 1, new_c + 1, [{:context, line} | acc]}
372
373
{:del, line, _old, _new}, {old_c, new_c, acc} ->
374
{old_c + 1, new_c, [{:del, line} | acc]}
375
376
{:add, line, _old, _new}, {old_c, new_c, acc} ->
377
{old_c, new_c + 1, [{:add, line} | acc]}
378
end)
379
380
# Find the starting line numbers
381
first_old = find_first_old_line(lines)
382
first_new = find_first_new_line(lines)
383
384
%{
385
old_start: first_old,
386
old_count: old_lines,
387
new_start: first_new,
388
new_count: new_lines,
389
lines: Enum.reverse(formatted)
390
}
391
end
392
393
defp find_first_old_line(lines) do
394
lines
395
|> Enum.find(fn
396
{:context, _, _, _} -> true
397
{:del, _, _, _} -> true
398
_ -> false
399
end)
400
|> case do
401
{:context, _, old, _} -> old
402
{:del, _, old, _} -> old
403
nil -> 1
404
end
405
end
406
407
defp find_first_new_line(lines) do
408
lines
409
|> Enum.find(fn
410
{:context, _, _, _} -> true
411
{:add, _, _, _} -> true
412
_ -> false
413
end)
414
|> case do
415
{:context, _, _, new} -> new
416
{:add, _, _, new} -> new
417
nil -> 1
418
end
419
end
420
421
defp drop_trailing_empty_context(lines) do
422
lines
423
|> Enum.reverse()
424
|> Enum.drop_while(fn
425
{:context, "", _, _} -> true
426
_ -> false
427
end)
428
|> Enum.reverse()
429
end
430
end
431
432