|
1
|
+ |
defmodule OpenAgents.Diff do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
A unified diff, decomposed into files, hunks, and lines.
|
|
4
|
+ |
|
|
5
|
+ |
The model is adapted from Pierre's `@pierre/diffs` (Apache 2.0,
|
|
6
|
+ |
`pierrecomputer/pierre`), which is the part of that library worth taking:
|
|
7
|
+ |
what a diff *is* once you stop treating it as text. See
|
|
8
|
+ |
`docs/2026-08-20-pierre-code-surfaces-port.md`.
|
|
9
|
+ |
|
|
10
|
+ |
A diff arrives from `git diff-tree -p -M` as one string, and rendering it as
|
|
11
|
+ |
one string is what the commit page used to do. The whole value of parsing it
|
|
12
|
+ |
is that each line then carries **both** line numbers -- where it sits in the
|
|
13
|
+ |
old file and in the new one -- which is what lets a reader point at a line,
|
|
14
|
+ |
and what a `<pre>` blob can never provide.
|
|
15
|
+ |
|
|
16
|
+ |
The parser is deliberately tolerant. A diff is a report about somebody else's
|
|
17
|
+ |
repository: it can be truncated mid-hunk by an upstream cap, describe a
|
|
18
|
+ |
binary file, record a rename with no content change, or use headers this code
|
|
19
|
+ |
has not seen. None of that should raise on a page whose job is to show what
|
|
20
|
+ |
happened. Anything unrecognised inside a file becomes a `:meta` line, which
|
|
21
|
+ |
renders as plain text and is honest about being unparsed.
|
|
22
|
+ |
"""
|
|
23
|
+ |
|
|
24
|
+ |
defmodule Line do
|
|
25
|
+ |
@moduledoc "One line of a hunk, carrying its position in both files."
|
|
26
|
+ |
|
|
27
|
+ |
@type kind :: :context | :insert | :delete | :meta
|
|
28
|
+ |
|
|
29
|
+ |
@type t :: %__MODULE__{
|
|
30
|
+ |
kind: kind(),
|
|
31
|
+ |
text: String.t(),
|
|
32
|
+ |
old_number: pos_integer() | nil,
|
|
33
|
+ |
new_number: pos_integer() | nil
|
|
34
|
+ |
}
|
|
35
|
+ |
|
|
36
|
+ |
@enforce_keys [:kind, :text]
|
|
37
|
+ |
defstruct [:kind, :text, :old_number, :new_number]
|
|
38
|
+ |
end
|
|
39
|
+ |
|
|
40
|
+ |
defmodule Hunk do
|
|
41
|
+ |
@moduledoc """
|
|
42
|
+ |
One contiguous run of changes.
|
|
43
|
+ |
|
|
44
|
+ |
`heading` is the text git puts after the `@@` marker -- usually the
|
|
45
|
+ |
enclosing function -- which is the cheapest orientation a reader gets and
|
|
46
|
+ |
the reason the header is worth rendering rather than discarding.
|
|
47
|
+ |
"""
|
|
48
|
+ |
|
|
49
|
+ |
@type t :: %__MODULE__{
|
|
50
|
+ |
old_start: non_neg_integer(),
|
|
51
|
+ |
old_count: non_neg_integer(),
|
|
52
|
+ |
new_start: non_neg_integer(),
|
|
53
|
+ |
new_count: non_neg_integer(),
|
|
54
|
+ |
heading: String.t() | nil,
|
|
55
|
+ |
lines: [Line.t()]
|
|
56
|
+ |
}
|
|
57
|
+ |
|
|
58
|
+ |
@enforce_keys [:old_start, :old_count, :new_start, :new_count]
|
|
59
|
+ |
defstruct [:old_start, :old_count, :new_start, :new_count, :heading, lines: []]
|
|
60
|
+ |
end
|
|
61
|
+ |
|
|
62
|
+ |
defmodule File do
|
|
63
|
+ |
@moduledoc """
|
|
64
|
+ |
One file's worth of a diff.
|
|
65
|
+ |
|
|
66
|
+ |
`status` distinguishes the cases a header can describe: `:added`,
|
|
67
|
+ |
`:deleted`, `:renamed`, or `:modified`. `binary?` is its own flag rather
|
|
68
|
+ |
than an absence of hunks, because "no textual change to show" and "this
|
|
69
|
+ |
file cannot be shown as text" are different statements and a reader should
|
|
70
|
+ |
be told which one they are looking at.
|
|
71
|
+ |
"""
|
|
72
|
+ |
|
|
73
|
+ |
@type status :: :added | :deleted | :renamed | :modified
|
|
74
|
+ |
|
|
75
|
+ |
@type t :: %__MODULE__{
|
|
76
|
+ |
path: String.t(),
|
|
77
|
+ |
old_path: String.t() | nil,
|
|
78
|
+ |
status: status(),
|
|
79
|
+ |
binary?: boolean(),
|
|
80
|
+ |
hunks: [Hunk.t()],
|
|
81
|
+ |
insertions: non_neg_integer(),
|
|
82
|
+ |
deletions: non_neg_integer()
|
|
83
|
+ |
}
|
|
84
|
+ |
|
|
85
|
+ |
@enforce_keys [:path]
|
|
86
|
+ |
defstruct [
|
|
87
|
+ |
:path,
|
|
88
|
+ |
:old_path,
|
|
89
|
+ |
status: :modified,
|
|
90
|
+ |
binary?: false,
|
|
91
|
+ |
hunks: [],
|
|
92
|
+ |
insertions: 0,
|
|
93
|
+ |
deletions: 0
|
|
94
|
+ |
]
|
|
95
|
+ |
end
|
|
96
|
+ |
|
|
97
|
+ |
@doc """
|
|
98
|
+ |
Parse a unified diff into `%File{}` structs, in the order git emitted them.
|
|
99
|
+ |
|
|
100
|
+ |
Returns `[]` for empty or unparseable input rather than raising: a commit
|
|
101
|
+ |
page that shows nothing is a worse answer than one that shows the files it
|
|
102
|
+ |
understood, but both are better than a crash.
|
|
103
|
+ |
"""
|
|
104
|
+ |
@spec parse(String.t() | nil) :: [File.t()]
|
|
105
|
+ |
def parse(nil), do: []
|
|
106
|
+ |
def parse(""), do: []
|
|
107
|
+ |
|
|
108
|
+ |
def parse(diff) when is_binary(diff) do
|
|
109
|
+ |
diff
|
|
110
|
+ |
|> lines()
|
|
111
|
+ |
|> collect_files(nil, [])
|
|
112
|
+ |
|> Enum.map(&finalize_file/1)
|
|
113
|
+ |
end
|
|
114
|
+ |
|
|
115
|
+ |
# A newline-terminated diff splits to a trailing empty element, which is the
|
|
116
|
+ |
# terminator rather than a line. Left in, it became a phantom context line on
|
|
117
|
+ |
# the last hunk of every file and shifted that hunk's line count by one. Only
|
|
118
|
+ |
# the final one is dropped: an empty element anywhere else is a real line
|
|
119
|
+ |
# from a generator that writes bare blank lines instead of `" "`.
|
|
120
|
+ |
defp lines(diff) do
|
|
121
|
+ |
case String.split(diff, "\n") do
|
|
122
|
+ |
[] -> []
|
|
123
|
+ |
parts -> if List.last(parts) == "", do: Enum.drop(parts, -1), else: parts
|
|
124
|
+ |
end
|
|
125
|
+ |
end
|
|
126
|
+ |
|
|
127
|
+ |
@doc """
|
|
128
|
+ |
Totals across a parsed diff: how many files, and how many lines each way.
|
|
129
|
+ |
|
|
130
|
+ |
Reported from the parsed lines rather than from git's own summary, so the
|
|
131
|
+ |
number under a diff always describes the diff above it -- including when the
|
|
132
|
+ |
input was truncated and the tail is missing.
|
|
133
|
+ |
"""
|
|
134
|
+ |
@spec totals([File.t()]) :: %{
|
|
135
|
+ |
files: non_neg_integer(),
|
|
136
|
+ |
insertions: non_neg_integer(),
|
|
137
|
+ |
deletions: non_neg_integer()
|
|
138
|
+ |
}
|
|
139
|
+ |
def totals(files) when is_list(files) do
|
|
140
|
+ |
Enum.reduce(files, %{files: 0, insertions: 0, deletions: 0}, fn file, acc ->
|
|
141
|
+ |
%{
|
|
142
|
+ |
files: acc.files + 1,
|
|
143
|
+ |
insertions: acc.insertions + file.insertions,
|
|
144
|
+ |
deletions: acc.deletions + file.deletions
|
|
145
|
+ |
}
|
|
146
|
+ |
end)
|
|
147
|
+ |
end
|
|
148
|
+ |
|
|
149
|
+ |
# ── file boundaries ───────────────────────────────────────────────────────
|
|
150
|
+ |
|
|
151
|
+ |
defp collect_files([], nil, done), do: Enum.reverse(done)
|
|
152
|
+ |
defp collect_files([], current, done), do: Enum.reverse([current | done])
|
|
153
|
+ |
|
|
154
|
+ |
defp collect_files(["diff --git " <> paths | rest], current, done) do
|
|
155
|
+ |
file = %File{path: path_from_header(paths)}
|
|
156
|
+ |
collect_files(rest, file, if(current, do: [current | done], else: done))
|
|
157
|
+ |
end
|
|
158
|
+ |
|
|
159
|
+ |
# Lines before the first `diff --git` are the commit's own headers, not a
|
|
160
|
+ |
# file's, and are dropped rather than attached to something they precede.
|
|
161
|
+ |
defp collect_files([_line | rest], nil, done), do: collect_files(rest, nil, done)
|
|
162
|
+ |
|
|
163
|
+ |
defp collect_files([line | rest], current, done) do
|
|
164
|
+ |
collect_files(rest, absorb(current, line), done)
|
|
165
|
+ |
end
|
|
166
|
+ |
|
|
167
|
+ |
# `diff --git a/x b/x`, where either side may be quoted and contain spaces.
|
|
168
|
+ |
# The b-side is preferred: it is the path the file has now.
|
|
169
|
+ |
defp path_from_header(paths) do
|
|
170
|
+ |
case Regex.run(~r|^"?a/(.*?)"? "?b/(.*?)"?$|, String.trim(paths), capture: :all_but_first) do
|
|
171
|
+ |
[_old, new] -> new
|
|
172
|
+ |
nil -> String.trim(paths)
|
|
173
|
+ |
end
|
|
174
|
+ |
end
|
|
175
|
+ |
|
|
176
|
+ |
# ── headers and body ──────────────────────────────────────────────────────
|
|
177
|
+ |
|
|
178
|
+ |
defp absorb(file, "new file mode" <> _rest), do: %{file | status: :added}
|
|
179
|
+ |
defp absorb(file, "deleted file mode" <> _rest), do: %{file | status: :deleted}
|
|
180
|
+ |
|
|
181
|
+ |
defp absorb(file, "rename from " <> old),
|
|
182
|
+ |
do: %{file | status: :renamed, old_path: unquote_path(old)}
|
|
183
|
+ |
|
|
184
|
+ |
defp absorb(file, "rename to " <> new), do: %{file | path: unquote_path(new)}
|
|
185
|
+ |
|
|
186
|
+ |
defp absorb(file, "Binary files " <> _rest), do: %{file | binary?: true}
|
|
187
|
+ |
defp absorb(file, "GIT binary patch" <> _rest), do: %{file | binary?: true}
|
|
188
|
+ |
|
|
189
|
+ |
# Dropped: they restate the paths already in the `diff --git` header, and
|
|
190
|
+ |
# `/dev/null` on either side restates the status.
|
|
191
|
+ |
defp absorb(file, "--- " <> _rest), do: file
|
|
192
|
+ |
defp absorb(file, "+++ " <> _rest), do: file
|
|
193
|
+ |
defp absorb(file, "index " <> _rest), do: file
|
|
194
|
+ |
defp absorb(file, "old mode " <> _rest), do: file
|
|
195
|
+ |
defp absorb(file, "new mode " <> _rest), do: file
|
|
196
|
+ |
defp absorb(file, "similarity index " <> _rest), do: file
|
|
197
|
+ |
defp absorb(file, "dissimilarity index " <> _rest), do: file
|
|
198
|
+ |
|
|
199
|
+ |
defp absorb(file, "@@" <> _rest = line) do
|
|
200
|
+ |
case parse_hunk_header(line) do
|
|
201
|
+ |
{:ok, hunk} -> %{file | hunks: [hunk | file.hunks]}
|
|
202
|
+ |
:error -> push_line(file, %Line{kind: :meta, text: line})
|
|
203
|
+ |
end
|
|
204
|
+ |
end
|
|
205
|
+ |
|
|
206
|
+ |
defp absorb(%File{hunks: []} = file, _line), do: file
|
|
207
|
+ |
|
|
208
|
+ |
defp absorb(file, "+" <> text), do: push_line(file, :insert, text)
|
|
209
|
+ |
defp absorb(file, "-" <> text), do: push_line(file, :delete, text)
|
|
210
|
+ |
defp absorb(file, " " <> text), do: push_line(file, :context, text)
|
|
211
|
+ |
defp absorb(file, ""), do: push_line(file, :context, "")
|
|
212
|
+ |
|
|
213
|
+ |
# "\ No newline at end of file", and anything else that turns up inside a
|
|
214
|
+ |
# hunk. Stated rather than swallowed.
|
|
215
|
+ |
defp absorb(file, line), do: push_line(file, %Line{kind: :meta, text: line})
|
|
216
|
+ |
|
|
217
|
+ |
defp unquote_path(path), do: path |> String.trim() |> String.trim("\"")
|
|
218
|
+ |
|
|
219
|
+ |
# `@@ -old,count +new,count @@ optional heading`, where either count may be
|
|
220
|
+ |
# omitted and means 1.
|
|
221
|
+ |
defp parse_hunk_header(line) do
|
|
222
|
+ |
case Regex.run(~r/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/, line,
|
|
223
|
+ |
capture: :all_but_first
|
|
224
|
+ |
) do
|
|
225
|
+ |
[old_start, old_count, new_start, new_count | heading] ->
|
|
226
|
+ |
{:ok,
|
|
227
|
+ |
%Hunk{
|
|
228
|
+ |
old_start: String.to_integer(old_start),
|
|
229
|
+ |
old_count: count(old_count),
|
|
230
|
+ |
new_start: String.to_integer(new_start),
|
|
231
|
+ |
new_count: count(new_count),
|
|
232
|
+ |
heading: heading |> List.first() |> presence()
|
|
233
|
+ |
}}
|
|
234
|
+ |
|
|
235
|
+ |
nil ->
|
|
236
|
+ |
:error
|
|
237
|
+ |
end
|
|
238
|
+ |
end
|
|
239
|
+ |
|
|
240
|
+ |
defp count(""), do: 1
|
|
241
|
+ |
defp count(value), do: String.to_integer(value)
|
|
242
|
+ |
|
|
243
|
+ |
defp presence(nil), do: nil
|
|
244
|
+ |
defp presence(""), do: nil
|
|
245
|
+ |
defp presence(value), do: value
|
|
246
|
+ |
|
|
247
|
+ |
# ── line numbering ────────────────────────────────────────────────────────
|
|
248
|
+ |
|
|
249
|
+ |
# Lines are appended unnumbered and numbered once, in `finalize_file/1`.
|
|
250
|
+ |
# Numbering on the way in means re-counting the hunk for every line, which is
|
|
251
|
+ |
# quadratic in the hunk's length -- fine for the four-line hunk in a test and
|
|
252
|
+ |
# not fine for the thousand-line hunk in a real reformatting commit.
|
|
253
|
+ |
defp push_line(file, kind, text) do
|
|
254
|
+ |
[hunk | rest] = file.hunks
|
|
255
|
+ |
line = %Line{kind: kind, text: text}
|
|
256
|
+ |
|
|
257
|
+ |
%{
|
|
258
|
+ |
file
|
|
259
|
+ |
| hunks: [%{hunk | lines: [line | hunk.lines]} | rest],
|
|
260
|
+ |
insertions: file.insertions + if(kind == :insert, do: 1, else: 0),
|
|
261
|
+ |
deletions: file.deletions + if(kind == :delete, do: 1, else: 0)
|
|
262
|
+ |
}
|
|
263
|
+ |
end
|
|
264
|
+ |
|
|
265
|
+ |
defp push_line(%File{hunks: []} = file, %Line{} = line) do
|
|
266
|
+ |
# A meta line before any hunk has nowhere to sit; the file header already
|
|
267
|
+ |
# said everything it could say.
|
|
268
|
+ |
_ = line
|
|
269
|
+ |
file
|
|
270
|
+ |
end
|
|
271
|
+ |
|
|
272
|
+ |
defp push_line(file, %Line{} = line) do
|
|
273
|
+ |
[hunk | rest] = file.hunks
|
|
274
|
+ |
%{file | hunks: [%{hunk | lines: [line | hunk.lines]} | rest]}
|
|
275
|
+ |
end
|
|
276
|
+ |
|
|
277
|
+ |
defp finalize_file(file) do
|
|
278
|
+ |
hunks =
|
|
279
|
+ |
file.hunks
|
|
280
|
+ |
|> Enum.reverse()
|
|
281
|
+ |
|> Enum.map(&number_hunk/1)
|
|
282
|
+ |
|
|
283
|
+ |
%{file | hunks: hunks}
|
|
284
|
+ |
end
|
|
285
|
+ |
|
|
286
|
+ |
# One pass, carrying the next number on each side. A deletion advances only
|
|
287
|
+ |
# the old side and an insertion only the new one, which is the whole reason a
|
|
288
|
+ |
# line needs two numbers: they stop agreeing at the first change.
|
|
289
|
+ |
defp number_hunk(hunk) do
|
|
290
|
+ |
{numbered, _old, _new} =
|
|
291
|
+ |
hunk.lines
|
|
292
|
+ |
|> Enum.reverse()
|
|
293
|
+ |
|> Enum.reduce({[], hunk.old_start, hunk.new_start}, fn line, {acc, old, new} ->
|
|
294
|
+ |
case line.kind do
|
|
295
|
+ |
:context ->
|
|
296
|
+ |
{[%{line | old_number: old, new_number: new} | acc], old + 1, new + 1}
|
|
297
|
+ |
|
|
298
|
+ |
:delete ->
|
|
299
|
+ |
{[%{line | old_number: old} | acc], old + 1, new}
|
|
300
|
+ |
|
|
301
|
+ |
:insert ->
|
|
302
|
+ |
{[%{line | new_number: new} | acc], old, new + 1}
|
|
303
|
+ |
|
|
304
|
+ |
:meta ->
|
|
305
|
+ |
{[line | acc], old, new}
|
|
306
|
+ |
end
|
|
307
|
+ |
end)
|
|
308
|
+ |
|
|
309
|
+ |
%{hunk | lines: Enum.reverse(numbered)}
|
|
310
|
+ |
end
|
|
311
|
+ |
end
|