[OCaml] Mobile-friendly clone of cgit.
Display commit diffs
lib/handlers.ml
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ()