Display commit diffs

Commit
f2ce19515eb6919f6e7a239576d721dce5195a3b
Author
Codex AI Agent <codex-ai@openai.invalid>
Author date
Committer
Codex AI Agent <codex-ai@openai.invalid>
Committer date
Changed files
lib/handlers.ml
index 9837f034..39597dfb 100644..100644
@@ -30,7 +30,8 @@
30 30
31 31 let commit_id repo id =
32 32 let* commit = Resolvers.Commit.of_id repo id in
33 Removed: Views.Repo.commit repo commit
33 Added: let* diff = Resolvers.Diff.of_commit repo commit in
34 Added: Views.Repo.commit repo commit diff
34 35
35 36 let files_at_head repo =
36 37 let* tree = Resolvers.Tree.head repo in
lib/resolvers.ml
index b223194e..ec9bef85 100644..100644
@@ -131,6 +131,7 @@
131 131
132 132 type t = {
133 133 hash : string;
134 Added: tree : string;
134 135 parents : string list;
135 136 author : user;
136 137 message : string option;
@@ -140,6 +141,7 @@
140 141 Store.
141 142 {
142 143 hash = Value.Commit.digest c |> Hash.to_hex;
144 Added: tree = Value.Commit.tree c |> Hash.to_hex;
143 145 parents = Value.Commit.parents c |> List.map Hash.to_hex;
144 146 author = Value.Commit.author c;
145 147 message = Value.Commit.message c;
@@ -230,21 +232,21 @@
230 232 | None -> Lwt_result.fail @@ `Msg ("no reference matches id " ^ id)
231 233 end
232 234
235 Added: let mode_of_perm : Git.Tree.perm -> int = function
236 Added: | `Commit -> 0o160000
237 Added: | `Dir -> 0o040000
238 Added: | `Everybody -> 0o100664
239 Added: | `Exec -> 0o100755
240 Added: | `Link -> 0o120000
241 Added: | `Normal -> 0o100644
242 Added:
233 243 module Entry = struct
234 244 type t = { hash : string; name : string; perm : int }
235 245
236 246 let to_t (entry : Store.Value.Tree.entry) =
237 247 let hash = Store.Hash.to_hex entry.node in
238 248 let name = entry.name in
239 Removed: let perm =
240 Removed: match entry.perm with
241 Removed: | `Commit -> 0o160000
242 Removed: | `Dir -> 0o040000
243 Removed: | `Everybody -> 0o100664
244 Removed: | `Exec -> 0o100755
245 Removed: | `Link -> 0o120000
246 Removed: | `Normal -> 0o100644
247 Removed: in
249 Added: let perm = mode_of_perm entry.perm in
248 250 { hash; name; perm }
249 251
250 252 let is_readme { name; _ } =
@@ -288,6 +290,265 @@
288 290 Lwt_result.bind (Store.read store hash) @@ function
289 291 | Git.Value.Blob blob -> Lwt_result.return (to_t blob)
290 292 | _ -> Lwt_result.fail @@ `Msg ("no blob matches id " ^ id)
293 Added: end
294 Added:
295 Added: module Diff = struct
296 Added: module Path_map = Map.Make (String)
297 Added:
298 Added: type line_kind = Context | Addition | Deletion
299 Added:
300 Added: type line = {
301 Added: kind : line_kind;
302 Added: old_number : int option;
303 Added: new_number : int option;
304 Added: text : string;
305 Added: }
306 Added:
307 Added: type hunk = {
308 Added: old_start : int;
309 Added: old_count : int;
310 Added: new_start : int;
311 Added: new_count : int;
312 Added: lines : line list;
313 Added: }
314 Added:
315 Added: type file = {
316 Added: path : string;
317 Added: old_hash : string option;
318 Added: new_hash : string option;
319 Added: old_mode : int option;
320 Added: new_mode : int option;
321 Added: binary : bool;
322 Added: hunks : hunk list;
323 Added: }
324 Added:
325 Added: type tree_file = { hash : string; perm : Git.Tree.perm }
326 Added:
327 Added: let split_lines content =
328 Added: match String.split_on_char '\n' content with
329 Added: | [] -> []
330 Added: | lines ->
331 Added: if content = "" then []
332 Added: else if String.ends_with ~suffix:"\n" content then
333 Added: List.rev lines |> List.tl |> List.rev
334 Added: else lines
335 Added:
336 Added: let line_diff old_content new_content =
337 Added: let old_lines = Array.of_list (split_lines old_content) in
338 Added: let new_lines = Array.of_list (split_lines new_content) in
339 Added: let old_length = Array.length old_lines in
340 Added: let new_length = Array.length new_lines in
341 Added: let matrix_size = old_length * new_length in
342 Added: let rec all_deletions index acc =
343 Added: if index = old_length then List.rev acc
344 Added: else
345 Added: all_deletions (index + 1)
346 Added: ({
347 Added: kind = Deletion;
348 Added: old_number = Some (index + 1);
349 Added: new_number = None;
350 Added: text = old_lines.(index);
351 Added: }
352 Added: :: acc)
353 Added: in
354 Added: let rec all_additions index acc =
355 Added: if index = new_length then List.rev acc
356 Added: else
357 Added: all_additions (index + 1)
358 Added: ({
359 Added: kind = Addition;
360 Added: old_number = None;
361 Added: new_number = Some (index + 1);
362 Added: text = new_lines.(index);
363 Added: }
364 Added: :: acc)
365 Added: in
366 Added: if matrix_size > 4_000_000 then
367 Added: all_deletions 0 [] @ all_additions 0 []
368 Added: else
369 Added: let lengths = Array.make_matrix (old_length + 1) (new_length + 1) 0 in
370 Added: for old_index = old_length - 1 downto 0 do
371 Added: for new_index = new_length - 1 downto 0 do
372 Added: lengths.(old_index).(new_index) <-
373 Added: if old_lines.(old_index) = new_lines.(new_index) then
374 Added: lengths.(old_index + 1).(new_index + 1) + 1
375 Added: else
376 Added: max lengths.(old_index + 1).(new_index)
377 Added: lengths.(old_index).(new_index + 1)
378 Added: done
379 Added: done;
380 Added: let rec build old_index new_index acc =
381 Added: if old_index = old_length then
382 Added: List.rev acc @ all_additions new_index []
383 Added: else if new_index = new_length then
384 Added: List.rev acc @ all_deletions old_index []
385 Added: else if old_lines.(old_index) = new_lines.(new_index) then
386 Added: build (old_index + 1) (new_index + 1)
387 Added: ({
388 Added: kind = Context;
389 Added: old_number = Some (old_index + 1);
390 Added: new_number = Some (new_index + 1);
391 Added: text = old_lines.(old_index);
392 Added: }
393 Added: :: acc)
394 Added: else if
395 Added: lengths.(old_index + 1).(new_index)
396 Added: >= lengths.(old_index).(new_index + 1)
397 Added: then
398 Added: build (old_index + 1) new_index
399 Added: ({
400 Added: kind = Deletion;
401 Added: old_number = Some (old_index + 1);
402 Added: new_number = None;
403 Added: text = old_lines.(old_index);
404 Added: }
405 Added: :: acc)
406 Added: else
407 Added: build old_index (new_index + 1)
408 Added: ({
409 Added: kind = Addition;
410 Added: old_number = None;
411 Added: new_number = Some (new_index + 1);
412 Added: text = new_lines.(new_index);
413 Added: }
414 Added: :: acc)
415 Added: in
416 Added: build 0 0 []
417 Added:
418 Added: let hunks ?(context = 3) lines =
419 Added: let lines = Array.of_list lines in
420 Added: let length = Array.length lines in
421 Added: let changed =
422 Added: Array.to_list (Array.mapi (fun index line -> (index, line.kind)) lines)
423 Added: |> List.filter_map (function
424 Added: | index, (Addition | Deletion) -> Some index
425 Added: | _, Context -> None)
426 Added: in
427 Added: let ranges =
428 Added: let add_range ranges index =
429 Added: let first = max 0 (index - context) in
430 Added: let last = min (length - 1) (index + context) in
431 Added: match ranges with
432 Added: | (range_first, range_last) :: rest when first <= range_last + 1 ->
433 Added: (range_first, max range_last last) :: rest
434 Added: | _ -> (first, last) :: ranges
435 Added: in
436 Added: List.fold_left add_range [] changed |> List.rev
437 Added: in
438 Added: let number_or_zero get_number slice =
439 Added: List.find_map get_number slice |> Option.value ~default:0
440 Added: in
441 Added: let make_hunk (first, last) =
442 Added: let rec slice index acc =
443 Added: if index > last then List.rev acc
444 Added: else slice (index + 1) (lines.(index) :: acc)
445 Added: in
446 Added: let lines = slice first [] in
447 Added: {
448 Added: old_start = number_or_zero (fun line -> line.old_number) lines;
449 Added: old_count =
450 Added: List.fold_left
451 Added: (fun count line ->
452 Added: if Option.is_some line.old_number then count + 1 else count)
453 Added: 0 lines;
454 Added: new_start = number_or_zero (fun line -> line.new_number) lines;
455 Added: new_count =
456 Added: List.fold_left
457 Added: (fun count line ->
458 Added: if Option.is_some line.new_number then count + 1 else count)
459 Added: 0 lines;
460 Added: lines;
461 Added: }
462 Added: in
463 Added: List.map make_hunk ranges
464 Added:
465 Added: let rec flatten_tree store prefix tree_hash files =
466 Added: let* hash = hash_of_hex tree_hash in
467 Added: Lwt_result.bind (Store.read store hash) @@ function
468 Added: | Git.Value.Tree tree ->
469 Added: let rec add_entries files = function
470 Added: | [] -> Lwt_result.return files
471 Added: | (entry : Store.Value.Tree.entry) :: entries ->
472 Added: let path =
473 Added: if prefix = "" then entry.name
474 Added: else Filename.concat prefix entry.name
475 Added: in
476 Added: let hash = Store.Hash.to_hex entry.node in
477 Added: let* files =
478 Added: match entry.perm with
479 Added: | `Dir -> flatten_tree store path hash files
480 Added: | (`Commit | `Everybody | `Exec | `Link | `Normal) as perm ->
481 Added: Lwt_result.return (Path_map.add path { hash; perm } files)
482 Added: in
483 Added: add_entries files entries
484 Added: in
485 Added: add_entries files (Store.Value.Tree.to_list tree)
486 Added: | _ -> Lwt_result.fail (`Msg ("no tree matches id " ^ tree_hash))
487 Added:
488 Added: let read_file store = function
489 Added: | None -> Lwt_result.return ""
490 Added: | Some { hash; perm = `Commit } ->
491 Added: Lwt_result.return ("Subproject commit " ^ hash ^ "\n")
492 Added: | Some { hash; _ } ->
493 Added: let* hash = hash_of_hex hash in
494 Added: Lwt_result.bind (Store.read store hash) @@ function
495 Added: | Git.Value.Blob blob ->
496 Added: Lwt_result.return (Store.Value.Blob.to_string blob)
497 Added: | _ -> Lwt_result.fail (`Msg "file entry does not point to a blob")
498 Added:
499 Added: let of_commit repo (commit : Commit.t) =
500 Added: let* store = store repo in
501 Added: let* new_files = flatten_tree store "" commit.tree Path_map.empty in
502 Added: let* old_files =
503 Added: match commit.parents with
504 Added: | [] -> Lwt_result.return Path_map.empty
505 Added: | parent :: _ ->
506 Added: let* parent_hash = hash_of_hex parent in
507 Added: Lwt_result.bind (Store.read store parent_hash) @@ function
508 Added: | Git.Value.Commit parent_commit ->
509 Added: let tree =
510 Added: Store.Value.Commit.tree parent_commit |> Store.Hash.to_hex
511 Added: in
512 Added: flatten_tree store "" tree Path_map.empty
513 Added: | _ -> Lwt_result.fail (`Msg ("parent is not a commit " ^ parent))
514 Added: in
515 Added: let changed_files =
516 Added: Path_map.merge
517 Added: (fun _ old_file new_file ->
518 Added: match (old_file, new_file) with
519 Added: | Some old_file, Some new_file
520 Added: when old_file.hash = new_file.hash
521 Added: && old_file.perm = new_file.perm ->
522 Added: None
523 Added: | None, None -> None
524 Added: | _ -> Some (old_file, new_file))
525 Added: old_files new_files
526 Added: |> Path_map.bindings
527 Added: in
528 Added: let rec build acc = function
529 Added: | [] -> Lwt_result.return (List.rev acc)
530 Added: | (path, (old_file, new_file)) :: files ->
531 Added: let* old_content = read_file store old_file in
532 Added: let* new_content = read_file store new_file in
533 Added: let binary =
534 Added: String.contains old_content '\x00'
535 Added: || String.contains new_content '\x00'
536 Added: in
537 Added: let file =
538 Added: {
539 Added: path;
540 Added: old_hash = Option.map (fun file -> file.hash) old_file;
541 Added: new_hash = Option.map (fun file -> file.hash) new_file;
542 Added: old_mode = Option.map (fun file -> mode_of_perm file.perm) old_file;
543 Added: new_mode = Option.map (fun file -> mode_of_perm file.perm) new_file;
544 Added: binary;
545 Added: hunks =
546 Added: if binary then [] else line_diff old_content new_content |> hunks;
547 Added: }
548 Added: in
549 Added: build (file :: acc) files
550 Added: in
551 Added: build [] changed_files
291 552 end
292 553
293 554 let blob_or_tree repo id =
lib/static/styles.css
index 8bf104f7..a1e38f4b 100644..100644
@@ -110,6 +110,88 @@
110 110 white-space: preserve-spaces;
111 111 }
112 112
113 Added: .commit-meta {
114 Added: display: grid;
115 Added: grid-template-columns: max-content minmax(0, 1fr);
116 Added: gap: 0.35em 1em;
117 Added: }
118 Added:
119 Added: .commit-meta dt {
120 Added: color: #aaa;
121 Added: }
122 Added:
123 Added: .commit-meta dd {
124 Added: margin: 0;
125 Added: overflow-wrap: anywhere;
126 Added: }
127 Added:
128 Added: .diff-file {
129 Added: margin: 1.5em 0;
130 Added: border: 1px solid #3a3a3a;
131 Added: border-radius: 0.25rem;
132 Added: overflow-x: auto;
133 Added: }
134 Added:
135 Added: .diff-file-header,
136 Added: .diff-meta,
137 Added: .hunk-header {
138 Added: margin: 0;
139 Added: padding: 0.5rem 0.75rem;
140 Added: font-family: monospace;
141 Added: }
142 Added:
143 Added: .diff-file-header {
144 Added: background: #242424;
145 Added: }
146 Added:
147 Added: .diff-meta {
148 Added: color: #aaa;
149 Added: border-top: 1px solid #3a3a3a;
150 Added: }
151 Added:
152 Added: .hunk-header {
153 Added: color: #b7c9ff;
154 Added: background: #252535;
155 Added: }
156 Added:
157 Added: .diff-line {
158 Added: display: grid;
159 Added: grid-template-columns: 4em 4em 1.25em minmax(max-content, 1fr);
160 Added: min-height: 1.35em;
161 Added: font-family: monospace;
162 Added: }
163 Added:
164 Added: .diff-line .line-number {
165 Added: padding-right: 0.6em;
166 Added: color: #888;
167 Added: text-align: right;
168 Added: user-select: none;
169 Added: border-right: 1px solid #3a3a3a;
170 Added: }
171 Added:
172 Added: .diff-marker {
173 Added: text-align: center;
174 Added: user-select: none;
175 Added: }
176 Added:
177 Added: .diff-text {
178 Added: padding-right: 0.75em;
179 Added: white-space: pre;
180 Added: }
181 Added:
182 Added: .diff-line.addition {
183 Added: background: #17351f;
184 Added: }
185 Added:
186 Added: .diff-line.deletion {
187 Added: background: #3b1d1d;
188 Added: }
189 Added:
190 Added: .binary-diff {
191 Added: margin: 0;
192 Added: padding: 0.75em;
193 Added: }
194 Added:
113 195 footer {
114 196 margin: 1em;
115 197 text-align: center;
@@ -124,5 +206,9 @@
124 206 column-gap: 1em;
125 207 row-gap: 0em;
126 208 grid-template-columns: max-content auto;
209 Added: }
210 Added:
211 Added: .diff-line {
212 Added: grid-template-columns: 3em 3em 1em minmax(max-content, 1fr);
127 213 }
128 214 }
lib/views.ml
index 43133e2d..15cea4dc 100644..100644
@@ -232,8 +232,68 @@
232 232 content = HTML.[ div [ id "blob" ] formatted_blob ];
233 233 }
234 234
235 Removed: let commit repo (commit : Resolvers.Commit.t) =
235 Added: let commit repo (commit : Resolvers.Commit.t) diff =
236 236 let message = match commit.message with Some msg -> msg | None -> "" in
237 Added: let number = function Some number -> string_of_int number | None -> "" in
238 Added: let line (line : Resolvers.Diff.line) =
239 Added: let class_name, marker =
240 Added: match line.kind with
241 Added: | Resolvers.Diff.Context -> ("context", " ")
242 Added: | Resolvers.Diff.Addition -> ("addition", "+")
243 Added: | Resolvers.Diff.Deletion -> ("deletion", "-")
244 Added: in
245 Added: HTML.
246 Added: (div [ class_ "diff-line %s" class_name ]
247 Added: [
248 Added: span [ class_ "line-number" ] [ txt "%s" (number line.old_number) ];
249 Added: span [ class_ "line-number" ] [ txt "%s" (number line.new_number) ];
250 Added: span [ class_ "diff-marker" ] [ txt "%s" marker ];
251 Added: span [ class_ "diff-text" ] [ txt "%s" line.text ];
252 Added: ])
253 Added: in
254 Added: let hunk (hunk : Resolvers.Diff.hunk) =
255 Added: HTML.
256 Added: [
257 Added: div [ class_ "hunk-header" ]
258 Added: [
259 Added: txt "@@ -%d,%d +%d,%d @@" hunk.old_start hunk.old_count
260 Added: hunk.new_start hunk.new_count;
261 Added: ];
262 Added: null (List.map line hunk.lines);
263 Added: ]
264 Added: in
265 Added: let mode = function
266 Added: | None -> "000000"
267 Added: | Some mode -> Printf.sprintf "%06o" mode
268 Added: in
269 Added: let hash = function
270 Added: | None -> "00000000"
271 Added: | Some hash -> Resolvers.short_hash hash
272 Added: in
273 Added: let file (file : Resolvers.Diff.file) =
274 Added: let file_body =
275 Added: if file.binary then
276 Added: HTML.[ p [ class_ "binary-diff" ] [ txt "Binary files differ" ] ]
277 Added: else List.concat_map hunk file.hunks
278 Added: in
279 Added: HTML.
280 Added: (section [ class_ "diff-file" ]
281 Added: ([
282 Added: h4 [ class_ "diff-file-header" ] [ txt "%s" file.path ];
283 Added: div [ class_ "diff-meta" ]
284 Added: [
285 Added: txt "index %s..%s %s..%s" (hash file.old_hash)
286 Added: (hash file.new_hash) (mode file.old_mode)
287 Added: (mode file.new_mode);
288 Added: ];
289 Added: ]
290 Added: @ file_body))
291 Added: in
292 Added: let diff_content =
293 Added: match diff with
294 Added: | [] -> HTML.[ p [] [ txt "No file changes in this commit." ] ]
295 Added: | files -> List.map file files
296 Added: in
237 297 respond
238 298 @@ Page.render ~page_title:(page_title repo)
239 299 {
@@ -242,7 +302,20 @@
242 302 Printf.sprintf "%s : %s" repo @@ Resolvers.short_hash commit.hash;
243 303 subtitle = Resolvers.repo_description repo;
244 304 active = Summary;
245 Removed: content = HTML.[ h3 [] [ txt "%s" message ] ];
305 Added: content =
306 Added: HTML.
307 Added: ([
308 Added: h3 [] [ txt "%s" message ];
309 Added: dl [ class_ "commit-meta" ]
310 Added: [
311 Added: dt [] [ txt "Commit" ];
312 Added: dd [] [ txt "%s" commit.hash ];
313 Added: dt [] [ txt "Author" ];
314 Added: dd []
315 Added: [ txt "%s <%s>" commit.author.name commit.author.email ];
316 Added: ];
317 Added: ]
318 Added: @ diff_content);
246 319 }
247 320
248 321 let branches repo branches =
test/test_ogit.ml
index 93ba20b8..4a55721c 100644..100644
@@ -75,6 +75,58 @@
75 75 assert (List.mem "master" names);
76 76 assert (List.length (List.sort_uniq String.compare names) = List.length names)
77 77
78 Added: let test_line_diff () =
79 Added: let open Ogit.Resolvers.Diff in
80 Added: match line_diff "first\nold\nlast\n" "first\nnew\nlast\n" with
81 Added: | [
82 Added: {
83 Added: kind = Context;
84 Added: old_number = Some 1;
85 Added: new_number = Some 1;
86 Added: text = "first";
87 Added: };
88 Added: {
89 Added: kind = Deletion;
90 Added: old_number = Some 2;
91 Added: new_number = None;
92 Added: text = "old";
93 Added: };
94 Added: {
95 Added: kind = Addition;
96 Added: old_number = None;
97 Added: new_number = Some 2;
98 Added: text = "new";
99 Added: };
100 Added: {
101 Added: kind = Context;
102 Added: old_number = Some 3;
103 Added: new_number = Some 3;
104 Added: text = "last";
105 Added: };
106 Added: ] ->
107 Added: ()
108 Added: | _ -> failwith "unexpected line diff"
109 Added:
110 Added: let test_diff_hunks () =
111 Added: let open Ogit.Resolvers.Diff in
112 Added: let old_content = List.init 12 (fun index -> string_of_int (index + 1)) in
113 Added: let new_content =
114 Added: List.mapi
115 Added: (fun index line -> if index = 5 then "changed" else line)
116 Added: old_content
117 Added: in
118 Added: match
119 Added: hunks
120 Added: (line_diff (String.concat "\n" old_content)
121 Added: (String.concat "\n" new_content))
122 Added: with
123 Added: | [ hunk ] ->
124 Added: assert (hunk.old_start = 3);
125 Added: assert (hunk.old_count = 7);
126 Added: assert (hunk.new_start = 3);
127 Added: assert (hunk.new_count = 7)
128 Added: | _ -> failwith "expected one diff hunk"
129 Added:
78 130 let () =
79 131 assert (Ogit.Resolvers.is_valid_repo_name "project.git");
80 132 assert (Ogit.Resolvers.is_valid_repo_name "project");
@@ -104,4 +156,6 @@
104 156 test_config_location ();
105 157 test_description_reader ();
106 158 test_repository_layout ();
107 Removed: test_fallback_branch_candidates ()
159 Added: test_fallback_branch_candidates ();
160 Added: test_line_diff ();
161 Added: test_diff_hunks ()