[OCaml] Mobile-friendly clone of cgit.
1
module Store = Git_unix.Store
2
open Lwt_result.Syntax
3
4
type error =
5
| Bad_request of string
6
| Not_found of string
7
| Store_error of Store.error
8
| Internal of string
9
10
let pp_error formatter = function
11
| Bad_request message -> Format.fprintf formatter "%s" message
12
| Not_found message -> Format.fprintf formatter "%s" message
13
| Store_error error -> Store.pp_error formatter error
14
| Internal message -> Format.fprintf formatter "%s" message
15
16
let map_store promise =
17
Lwt.map (Result.map_error (fun error -> Store_error error)) promise
18
19
let is_hex_digit = function
20
| '0' .. '9' | 'a' .. 'f' | 'A' .. 'F' -> true
21
| _ -> false
22
23
let is_valid_hash_hex hash =
24
String.length hash = Store.Hash.length * 2 && String.for_all is_hex_digit hash
25
26
let hash_of_hex hash =
27
if is_valid_hash_hex hash then Lwt_result.return (Store.Hash.of_hex hash)
28
else Lwt_result.fail (Bad_request ("invalid object id " ^ hash))
29
30
let is_valid_repo_name repo =
31
let invalid_char = function '\\' | '\x00' -> true | _ -> false in
32
let valid_segment s =
33
s <> "" && s <> "." && s <> ".." && not (String.starts_with ~prefix:"." s)
34
in
35
repo <> ""
36
&& (not (String.exists invalid_char repo))
37
&& String.split_on_char '/' repo |> List.for_all valid_segment
38
39
let validate_repo_name repo =
40
if is_valid_repo_name repo then Lwt_result.return repo
41
else Lwt_result.fail (Bad_request ("invalid repository name " ^ repo))
42
43
type repository_layout = { worktree : string; git_dir : string }
44
45
let filesystem_error path error =
46
Internal (Printf.sprintf "%s: %s" path (Unix.error_message error))
47
48
(* These report filesystem failures rather than hiding them, so that a directory
49
made unreadable by permissions is distinguishable from one that is simply not
50
a repository. A missing path is not a failure: it answers the question with
51
[false].
52
53
The plain names belong to these error-preserving functions. It is
54
[repository_layout_or_none] below, which discards the distinction, that
55
carries the qualifier — the surprising behaviour is the one worth naming. *)
56
57
let is_directory path =
58
try Ok ((Unix.stat path).st_kind = Unix.S_DIR) with
59
| Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> Ok false
60
| Unix.Unix_error (error, _, _) -> Error (filesystem_error path error)
61
62
let file_exists path =
63
try
64
ignore (Unix.stat path);
65
Ok true
66
with
67
| Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> Ok false
68
| Unix.Unix_error (error, _, _) -> Error (filesystem_error path error)
69
70
(* A directory is a Git directory when it holds both HEAD and objects/. *)
71
let is_git_directory path =
72
let ( let* ) = Result.bind in
73
let* directory = is_directory path in
74
if not directory then Ok false
75
else
76
let* has_head = file_exists (Filename.concat path "HEAD") in
77
let* has_objects = is_directory (Filename.concat path "objects") in
78
Ok (has_head && has_objects)
79
80
(* [Ok None] means "readable, but not a repository"; [Error _] means "could not
81
tell". Keeping them apart is the whole point of this layer. *)
82
let repository_layout path =
83
let ( let* ) = Result.bind in
84
let dotgit = Filename.concat path ".git" in
85
let* worktree = is_directory path in
86
if not worktree then Ok None
87
else
88
let* non_bare = is_git_directory dotgit in
89
if non_bare then Ok (Some { worktree = path; git_dir = dotgit })
90
else
91
let* bare = is_git_directory path in
92
if bare then Ok (Some { worktree = path; git_dir = path }) else Ok None
93
94
(** Collapse an unreadable path to [None], for the callers that cannot act on
95
the difference anyway. Prefer {!repository_layout} where the distinction
96
between "not a repository" and "could not tell" matters. *)
97
let repository_layout_or_none path =
98
match repository_layout path with Ok layout -> layout | Error _ -> None
99
100
let is_repository path = Option.is_some (repository_layout_or_none path)
101
102
let repositories config =
103
try
104
let names = Sys.readdir config.Config.git_project_root |> Array.to_list in
105
let ( let* ) = Result.bind in
106
let rec collect repositories = function
107
| [] -> Ok (List.sort String.compare repositories)
108
| name :: rest when String.starts_with ~prefix:"." name ->
109
collect repositories rest
110
| name :: rest ->
111
let path = Filename.concat config.Config.git_project_root name in
112
let* layout = repository_layout path in
113
collect
114
(if Option.is_some layout then name :: repositories
115
else repositories)
116
rest
117
in
118
collect [] names
119
with Sys_error message -> Error (Internal message)
120
121
type repo_info = { repo_name : string; description : string }
122
type fs_node = Repo of repo_info | Directory of string * fs_node list
123
124
let default_repo_description = "Unnamed repository"
125
126
let read_description_file description_path =
127
try
128
match
129
In_channel.with_open_text description_path In_channel.input_all
130
|> String.trim
131
with
132
| "" -> default_repo_description
133
| description -> description
134
with Sys_error _ -> default_repo_description
135
136
let description_of_layout { git_dir; _ } =
137
Filename.concat git_dir "description" |> read_description_file
138
139
let scan_directory root_path =
140
let ( let* ) = Result.bind in
141
let rec scan dir_path =
142
try
143
let names =
144
Sys.readdir dir_path |> Array.to_list |> List.sort String.compare
145
in
146
let rec collect acc = function
147
| [] -> Ok (List.rev acc)
148
| name :: rest when String.starts_with ~prefix:"." name ->
149
collect acc rest
150
| name :: rest -> (
151
let path = Filename.concat dir_path name in
152
let* is_dir = is_directory path in
153
if not is_dir then collect acc rest
154
else
155
let* layout = repository_layout path in
156
match layout with
157
| Some layout ->
158
let description = description_of_layout layout in
159
collect (Repo { repo_name = name; description } :: acc) rest
160
| None ->
161
let* children = scan path in
162
if children = [] then collect acc rest
163
else collect (Directory (name, children) :: acc) rest)
164
in
165
collect [] names
166
with Sys_error message -> Error (Internal message)
167
in
168
scan root_path
169
170
let scan_project_root config = scan_directory config.Config.git_project_root
171
172
type repository = {
173
name : string;
174
store : Store.t;
175
description : string;
176
default_branch : string;
177
}
178
179
let open_repository config name =
180
let* name = validate_repo_name name in
181
let path = Filename.concat config.Config.git_project_root name in
182
match repository_layout path with
183
| Error error -> Lwt_result.fail error
184
| Ok None -> Lwt_result.fail (Not_found ("not a Git repository " ^ name))
185
| Ok (Some ({ worktree; git_dir } as layout)) ->
186
(* ocaml-git's Store.v expects a tmp/ directory inside the git directory
187
and fails if it cannot create one. Since ogit only reads repositories,
188
we ensure the directory exists beforehand so that Store.v succeeds even
189
when the process lacks write access to the repository root. *)
190
(let tmp = Filename.concat git_dir "tmp" in
191
try Unix.mkdir tmp 0o755 with Unix.Unix_error _ -> ());
192
let* store =
193
map_store (Store.v ~dotgit:(Fpath.v git_dir) (Fpath.v worktree))
194
in
195
Lwt_result.return
196
{
197
name;
198
store;
199
description = description_of_layout layout;
200
default_branch = config.Config.default_branch;
201
}
202
203
let repository_name repository = repository.name
204
let repository_description repository = repository.description
205
let close_repository repository = Store.close_pack_files repository.store
206
let short_hash hash = String.sub hash 0 (min 8 (String.length hash))
207
let branch_ref name = Git.Reference.v ("refs/heads/" ^ name)
208
209
let fallback_branch_candidates_for default_branch =
210
List.fold_left
211
(fun candidates name ->
212
if List.mem name candidates then candidates else candidates @ [ name ])
213
[]
214
[ default_branch; "main"; "master" ]
215
216
let fallback_branch_candidates config =
217
fallback_branch_candidates_for config.Config.default_branch
218
219
(** Parse a [packed-refs] file and return all entries as [(hex, refname)]
220
pairs. This is the shared primitive used both by [resolve_head_hash] (which
221
needs branch references for its fallback) and by [Reference.refs_by_prefix]
222
(which enumerates branches and tags for the UI).
223
224
[Store.Ref.list] only walks the filesystem for loose reference files; when a
225
repository has been garbage-collected or was received as a pack, all refs
226
live exclusively in [packed-refs] and are invisible to it. *)
227
let read_packed_refs_at git_dir =
228
let path = Filename.concat git_dir "packed-refs" in
229
try
230
In_channel.with_open_text path @@ fun ic ->
231
let rec collect acc =
232
match In_channel.input_line ic with
233
| None -> List.rev acc
234
| Some line when String.length line = 0 -> collect acc
235
| Some line when line.[0] = '#' || line.[0] = '^' -> collect acc
236
| Some line -> (
237
match String.index_opt line ' ' with
238
| None -> collect acc
239
| Some i ->
240
let hex = String.sub line 0 i in
241
let refname =
242
String.sub line (i + 1) (String.length line - i - 1)
243
in
244
collect ((hex, refname) :: acc))
245
in
246
collect []
247
with Sys_error _ -> []
248
249
let read_packed_refs store =
250
read_packed_refs_at (Fpath.to_string (Store.dotgit store))
251
252
(** Cheaply resolve the commit hash that HEAD names, using plain file reads and
253
no object store: HEAD itself, then one loose reference file, then
254
[packed-refs]. [None] means "could not tell cheaply" — callers must fall
255
back to opening the repository rather than conclude anything. *)
256
let head_hash_hint config name =
257
let path = Filename.concat config.Config.git_project_root name in
258
match repository_layout_or_none path with
259
| None -> None
260
| Some { git_dir; _ } -> (
261
let first_line file =
262
try In_channel.with_open_text file In_channel.input_line
263
with Sys_error _ -> None
264
in
265
match first_line (Filename.concat git_dir "HEAD") with
266
| None -> None
267
| Some line -> (
268
let line = String.trim line in
269
if String.starts_with ~prefix:"ref: " line then
270
let refname =
271
String.trim (String.sub line 5 (String.length line - 5))
272
in
273
match first_line (Filename.concat git_dir refname) with
274
| Some hex when is_valid_hash_hex (String.trim hex) ->
275
Some (String.trim hex)
276
| _ ->
277
read_packed_refs_at git_dir
278
|> List.find_opt (fun (_, packed_name) ->
279
packed_name = refname)
280
|> Option.map fst
281
else if is_valid_hash_hex line then Some line
282
else None))
283
284
(** Branch entries from [packed-refs], returned as [(full_refname, reference)]
285
pairs suitable for [try_references]. *)
286
let packed_refs_branches store =
287
read_packed_refs store
288
|> List.filter_map (fun (_hex, refname) ->
289
if String.starts_with ~prefix:"refs/heads/" refname then
290
Some (refname, Git.Reference.v refname)
291
else None)
292
293
let resolve_head_hash repository =
294
let resolve reference =
295
map_store (Store.Ref.resolve repository.store reference)
296
in
297
let rec try_references = function
298
| [] ->
299
let open Lwt.Syntax in
300
let* references = Store.Ref.list repository.store in
301
let branches =
302
references |> List.map fst
303
|> List.filter_map (fun reference ->
304
let name = Git.Reference.to_string reference in
305
if String.starts_with ~prefix:"refs/heads/" name then
306
Some (name, reference)
307
else None)
308
in
309
(* When the store lists no loose branches, also consult packed-refs
310
so that repositories with only packed references are handled. *)
311
let branches =
312
if branches = [] then packed_refs_branches repository.store
313
else branches
314
in
315
let branches =
316
List.sort
317
(fun (left, _) (right, _) -> String.compare left right)
318
branches
319
in
320
let rec try_branches = function
321
| [] ->
322
Lwt_result.fail
323
(Not_found "no branch could be resolved for repository")
324
| (_, reference) :: rest -> (
325
Lwt.bind (resolve reference) @@ function
326
| Ok hash -> Lwt_result.return hash
327
| Error (Store_error (`Reference_not_found _)) ->
328
try_branches rest
329
| Error error -> Lwt_result.fail error)
330
in
331
try_branches branches
332
| reference :: rest -> (
333
Lwt.bind (resolve reference) @@ function
334
| Ok hash -> Lwt_result.return hash
335
| Error (Store_error (`Reference_not_found _)) -> try_references rest
336
| Error error -> Lwt_result.fail error)
337
in
338
Lwt.bind (resolve Git.Reference.head) @@ function
339
| Ok hash -> Lwt_result.return hash
340
| Error (Store_error (`Reference_not_found _)) ->
341
fallback_branch_candidates_for repository.default_branch
342
|> List.map branch_ref |> try_references
343
| Error error -> Lwt_result.fail error
344
345
let read_value repository hash =
346
Lwt.bind (Store.read repository.store hash) @@ function
347
| Error (`Not_found _) ->
348
Lwt_result.fail
349
(Not_found ("no object matches id " ^ Store.Hash.to_hex hash))
350
| Error error -> Lwt_result.fail (Store_error error)
351
| Ok value -> Lwt_result.return value
352
353
let head_commit_date repository =
354
let open Lwt.Syntax in
355
let* result =
356
Lwt_result.bind (resolve_head_hash repository) @@ fun hash ->
357
Lwt.bind (Store.read repository.store hash) @@ function
358
| Error _ -> Lwt_result.return None
359
| Ok value -> (
360
match value with
361
| Git.Value.Commit commit ->
362
let author = Store.Value.Commit.author commit in
363
Lwt_result.return (Some author.Git.User.date)
364
| _ -> Lwt_result.return None)
365
in
366
Lwt.return (Result.value result ~default:None)
367
368
module Commit = struct
369
type user = Git.User.t
370
371
type t = {
372
hash : string;
373
tree : string;
374
parents : string list;
375
author : user;
376
committer : user;
377
message : string option;
378
}
379
380
let to_t commit =
381
Store.
382
{
383
hash = Value.Commit.digest commit |> Hash.to_hex;
384
tree = Value.Commit.tree commit |> Hash.to_hex;
385
parents = Value.Commit.parents commit |> List.map Hash.to_hex;
386
author = Value.Commit.author commit;
387
committer = Value.Commit.committer commit;
388
message = Value.Commit.message commit;
389
}
390
391
let of_hash repository hash =
392
Lwt_result.bind (read_value repository hash) @@ function
393
| Git.Value.Commit commit -> Lwt_result.return (to_t commit)
394
| _ ->
395
Store.Hash.to_hex hash |> Printf.sprintf "no commit matches id %s"
396
|> fun message -> Lwt_result.fail (Not_found message)
397
398
let of_id repository id =
399
let* hash = hash_of_hex id in
400
of_hash repository hash
401
402
(* A filtered walk with a rare predicate would otherwise traverse the whole
403
history on every request. The cap bounds that work; callers learn through
404
the second component of the result that older matches may exist beyond
405
it. *)
406
let default_max_examined = 5_000
407
408
let recent_matching_from ?(max_examined = default_max_examined) repository
409
hash count predicate =
410
(* BFS traversal following all parents, ordered by author date descending.
411
The queue is kept sorted via List.merge on insertion. This is O(n) per
412
insert, but the queue length is bounded by the branch factor × count,
413
which is small in practice (most commits have 1-2 parents). *)
414
let module S = Set.Make (String) in
415
let rec walk collected seen queue remaining examined =
416
if remaining <= 0 then Lwt_result.return (List.rev collected, false)
417
else if examined >= max_examined then
418
(* Unexplored parents remain: the history was not exhausted. *)
419
Lwt_result.return (List.rev collected, queue <> [])
420
else
421
match queue with
422
| [] -> Lwt_result.return (List.rev collected, false)
423
| (_, h) :: rest ->
424
if S.mem h seen then walk collected seen rest remaining examined
425
else
426
let seen = S.add h seen in
427
let* commit = of_id repository h in
428
(* Enqueue all parents *)
429
let new_queue =
430
List.filter_map
431
(fun p ->
432
if S.mem p seen then None
433
else Some (commit.author.Git.User.date, p))
434
commit.parents
435
in
436
(* Merge into queue sorted by date descending *)
437
let queue =
438
List.merge
439
(fun ((a_ts, _), _) ((b_ts, _), _) -> Int64.compare b_ts a_ts)
440
rest new_queue
441
in
442
let collected, remaining =
443
if predicate commit then (commit :: collected, remaining - 1)
444
else (collected, remaining)
445
in
446
walk collected seen queue remaining (examined + 1)
447
in
448
walk [] S.empty [ ((Int64.max_int, None), hash) ] count 0
449
450
let recent_from repository hash count =
451
Lwt_result.map fst
452
(recent_matching_from repository hash count (Fun.const true))
453
454
let recent_matching ?max_examined repository count predicate =
455
let* head_hash = resolve_head_hash repository in
456
recent_matching_from ?max_examined repository
457
(Store.Hash.to_hex head_hash)
458
count predicate
459
end
460
461
module Reference = struct
462
type t = { name : string; hash : string }
463
464
let drop_prefix ~prefix name =
465
if String.starts_with ~prefix name then
466
Some
467
(String.sub name (String.length prefix)
468
(String.length name - String.length prefix))
469
else None
470
471
let branch_name name = drop_prefix ~prefix:"refs/heads/" name
472
let tag_name name = drop_prefix ~prefix:"refs/tags/" name
473
let to_t_with_name name (_, hash) = { name; hash = Store.Hash.to_hex hash }
474
475
(** Enumerate references matching a given prefix, combining both loose
476
references (from [Store.Ref.list]) and packed references read directly
477
from the [packed-refs] file. Duplicates are removed by preferring the
478
loose entry (which takes precedence in git's resolution order). *)
479
let refs_by_prefix repository name_of_reference =
480
let open Lwt.Syntax in
481
let* references = Store.Ref.list repository.store in
482
let from_loose =
483
references
484
|> List.filter_map (fun ((reference, _) as raw) ->
485
Git.Reference.to_string reference
486
|> name_of_reference
487
|> Option.map (fun name -> to_t_with_name name raw))
488
in
489
let from_packed =
490
read_packed_refs repository.store
491
|> List.filter_map (fun (hex, refname) ->
492
name_of_reference refname
493
|> Option.map (fun name -> { name; hash = hex }))
494
in
495
(* Merge: loose refs win over packed refs for the same name *)
496
let loose_names = List.map (fun r -> r.name) from_loose in
497
let merged =
498
from_loose
499
@ List.filter (fun r -> not (List.mem r.name loose_names)) from_packed
500
in
501
merged
502
|> List.sort (fun left right -> String.compare left.name right.name)
503
|> Lwt_result.return
504
505
let branches repository = refs_by_prefix repository branch_name
506
507
let of_id repository id =
508
let* branches = branches repository in
509
match List.find_opt (fun branch -> branch.name = id) branches with
510
| Some branch -> Lwt_result.return branch
511
| None -> Lwt_result.fail (Not_found ("no reference matches id " ^ id))
512
end
513
514
let mode_of_perm : Git.Tree.perm -> int = function
515
| `Commit -> 0o160000
516
| `Dir -> 0o040000
517
| `Everybody -> 0o100664
518
| `Exec -> 0o100755
519
| `Link -> 0o120000
520
| `Normal -> 0o100644
521
522
module Entry = struct
523
type perm = Dir | File | Exec | Link | Submodule
524
type t = { hash : string; name : string; perm : perm }
525
526
let perm_of_git : Git.Tree.perm -> perm = function
527
| `Dir -> Dir
528
| `Exec -> Exec
529
| `Link -> Link
530
| `Commit -> Submodule
531
| `Normal | `Everybody -> File
532
533
let to_t (entry : Store.Value.Tree.entry) =
534
{
535
hash = Store.Hash.to_hex entry.node;
536
name = entry.name;
537
perm = perm_of_git entry.perm;
538
}
539
540
let is_readme { name; _ } =
541
String.(lowercase_ascii name |> starts_with ~prefix:"readme")
542
end
543
544
module Tree = struct
545
type t = { entries : Entry.t list }
546
type tree_node = { entry : Entry.t; children : tree_node list option }
547
548
let to_t tree =
549
{ entries = Store.Value.Tree.to_list tree |> List.map Entry.to_t }
550
551
let of_hash repository hash =
552
Lwt_result.bind (read_value repository hash) @@ function
553
| Git.Value.Tree tree -> Lwt_result.return (to_t tree)
554
| _ ->
555
Store.Hash.to_hex hash |> Printf.sprintf "no tree matches id %s"
556
|> fun message -> Lwt_result.fail (Not_found message)
557
558
let head_tree_hash repository =
559
let* hash = resolve_head_hash repository in
560
Lwt_result.bind (read_value repository hash) @@ function
561
| Git.Value.Commit commit ->
562
Lwt_result.return (Store.Value.Commit.tree commit)
563
| _ -> Lwt_result.fail (Internal "HEAD does not point to a commit")
564
565
let head repository =
566
let* hash = head_tree_hash repository in
567
of_hash repository hash
568
569
let expand repository tree =
570
let rec expand_entry (entry : Entry.t) =
571
if entry.perm <> Entry.Dir then
572
Lwt_result.return { entry; children = None }
573
else collapse_single_subdirs entry.name entry.hash
574
and collapse_single_subdirs prefix hash_hex =
575
let* hash = hash_of_hex hash_hex in
576
let* subtree =
577
Lwt_result.bind (read_value repository hash) @@ function
578
| Git.Value.Tree t -> Lwt_result.return (to_t t)
579
| _ -> Lwt_result.return { entries = [] }
580
in
581
let entries = subtree.entries in
582
match entries with
583
| [ single ] when single.perm = Entry.Dir ->
584
(* Single subdir — collapse into parent name and recurse *)
585
let combined_name = prefix ^ "/" ^ single.name in
586
collapse_single_subdirs combined_name single.hash
587
| [ single ] when single.perm <> Entry.Dir ->
588
(* Single file — present as a file with combined path name *)
589
let combined_entry =
590
{
591
Entry.hash = single.hash;
592
name = prefix ^ "/" ^ single.name;
593
perm = single.perm;
594
}
595
in
596
Lwt_result.return { entry = combined_entry; children = None }
597
| _ ->
598
let collapsed_entry =
599
{ Entry.hash = hash_hex; name = prefix; perm = Entry.Dir }
600
in
601
let* children = expand_entries entries in
602
Lwt_result.return
603
{ entry = collapsed_entry; children = Some children }
604
and expand_entries entries =
605
let is_hidden (e : Entry.t) =
606
String.length e.name > 0 && e.name.[0] = '.'
607
in
608
let sorted =
609
List.sort
610
(fun (a : Entry.t) (b : Entry.t) ->
611
match (a.perm, b.perm) with
612
| Entry.Dir, Entry.Dir ->
613
let ha = is_hidden a and hb = is_hidden b in
614
if ha = hb then String.compare a.name b.name
615
else if ha then 1
616
else -1
617
| Entry.Dir, _ -> -1
618
| _, Entry.Dir -> 1
619
| _, _ ->
620
let ha = is_hidden a and hb = is_hidden b in
621
if ha = hb then String.compare a.name b.name
622
else if ha then 1
623
else -1)
624
entries
625
in
626
let rec go acc = function
627
| [] -> Lwt_result.return (List.rev acc)
628
| e :: rest ->
629
let* pe = expand_entry e in
630
go (pe :: acc) rest
631
in
632
go [] sorted
633
in
634
expand_entries tree.entries
635
636
let find_path repository target_hash =
637
let* target = hash_of_hex target_hash in
638
let* root = head_tree_hash repository in
639
if Store.Hash.equal root target then Lwt_result.return []
640
else
641
let rec search trail tree_hash =
642
Lwt_result.bind (read_value repository tree_hash) @@ function
643
| Git.Value.Tree tree ->
644
let rec try_entries = function
645
| [] -> Lwt_result.return None
646
| (entry : Store.Value.Tree.entry) :: rest ->
647
let step = (entry.name, Store.Hash.to_hex entry.node) in
648
if Store.Hash.equal entry.node target then
649
Lwt_result.return (Some (List.rev (step :: trail)))
650
else if entry.perm = `Dir then
651
let* found = search (step :: trail) entry.node in
652
match found with
653
| Some _ -> Lwt_result.return found
654
| None -> try_entries rest
655
else try_entries rest
656
in
657
try_entries (Store.Value.Tree.to_list tree)
658
| _ -> Lwt_result.return None
659
in
660
let* result = search [] root in
661
match result with
662
| Some trail -> Lwt_result.return trail
663
| None ->
664
Lwt_result.fail
665
(Not_found ("object is not reachable from HEAD: " ^ target_hash))
666
end
667
668
module Blob = struct
669
type t = { content : string }
670
671
let to_t blob = { content = Store.Value.Blob.to_string blob }
672
end
673
674
module Diff = struct
675
module Path_map = Map.Make (String)
676
include Line_diff
677
678
type tree_file = { hash : string; perm : Git.Tree.perm }
679
680
let rec flatten_tree repository prefix tree_hash files =
681
let* hash = hash_of_hex tree_hash in
682
Lwt_result.bind (read_value repository hash) @@ function
683
| Git.Value.Tree tree ->
684
let rec add_entries files = function
685
| [] -> Lwt_result.return files
686
| (entry : Store.Value.Tree.entry) :: entries ->
687
let path =
688
if prefix = "" then entry.name
689
else Filename.concat prefix entry.name
690
in
691
let hash = Store.Hash.to_hex entry.node in
692
let* files =
693
match entry.perm with
694
| `Dir -> flatten_tree repository path hash files
695
| (`Commit | `Everybody | `Exec | `Link | `Normal) as perm ->
696
Lwt_result.return (Path_map.add path { hash; perm } files)
697
in
698
add_entries files entries
699
in
700
add_entries files (Store.Value.Tree.to_list tree)
701
| _ -> Lwt_result.fail (Not_found ("no tree matches id " ^ tree_hash))
702
703
let read_file repository = function
704
| None -> Lwt_result.return ""
705
| Some { hash; perm = `Commit } ->
706
Lwt_result.return ("Subproject commit " ^ hash ^ "\n")
707
| Some { hash; _ } -> (
708
let* hash = hash_of_hex hash in
709
Lwt_result.bind (read_value repository hash) @@ function
710
| Git.Value.Blob blob ->
711
Lwt_result.return (Store.Value.Blob.to_string blob)
712
| _ -> Lwt_result.fail (Internal "file entry does not point to a blob"))
713
714
let of_commit repository (commit : Commit.t) =
715
let* new_files = flatten_tree repository "" commit.tree Path_map.empty in
716
let* old_files =
717
match commit.parents with
718
| [] -> Lwt_result.return Path_map.empty
719
| parent :: _ -> (
720
let* parent_hash = hash_of_hex parent in
721
Lwt_result.bind (read_value repository parent_hash) @@ function
722
| Git.Value.Commit parent_commit ->
723
Store.Value.Commit.tree parent_commit |> Store.Hash.to_hex
724
|> fun tree -> flatten_tree repository "" tree Path_map.empty
725
| _ -> Lwt_result.fail (Internal ("parent is not a commit " ^ parent))
726
)
727
in
728
let changed_files =
729
Path_map.merge
730
(fun _ old_file new_file ->
731
match (old_file, new_file) with
732
| Some old_file, Some new_file
733
when old_file.hash = new_file.hash && old_file.perm = new_file.perm
734
->
735
None
736
| None, None -> None
737
| _ -> Some (old_file, new_file))
738
old_files new_files
739
|> Path_map.bindings
740
in
741
let rec build files = function
742
| [] -> Lwt_result.return (List.rev files)
743
| (path, (old_file, new_file)) :: rest ->
744
let* old_content = read_file repository old_file in
745
let* new_content = read_file repository new_file in
746
let binary =
747
String.contains old_content '\x00'
748
|| String.contains new_content '\x00'
749
in
750
let file =
751
{
752
path;
753
old_hash = Option.map (fun file -> file.hash) old_file;
754
new_hash = Option.map (fun file -> file.hash) new_file;
755
old_mode =
756
Option.map (fun file -> mode_of_perm file.perm) old_file;
757
new_mode =
758
Option.map (fun file -> mode_of_perm file.perm) new_file;
759
binary;
760
hunks =
761
(if binary then []
762
else
763
Line_diff.of_contents old_content new_content
764
|> Line_diff.hunks);
765
}
766
in
767
build (file :: files) rest
768
in
769
build [] changed_files
770
end
771
772
let blob_or_tree repository id =
773
let* hash = hash_of_hex id in
774
Lwt_result.bind (read_value repository hash) @@ function
775
| Git.Value.Tree tree -> Lwt_result.return (`Tree (Tree.to_t tree))
776
| Git.Value.Blob blob -> Lwt_result.return (`Blob (Blob.to_t blob))
777
| _ -> Lwt_result.fail (Not_found ("no tree or blob matches id " ^ id))
778
779
(* Resolving by path walks at most one tree per segment, unlike
780
[Tree.find_path], which searches the whole tree for a hash. *)
781
let object_at_path repository path =
782
let segments =
783
String.split_on_char '/' path |> List.filter (fun s -> s <> "")
784
in
785
let missing () = Lwt_result.fail (Not_found ("no such path: " ^ path)) in
786
if segments = [] then
787
Lwt_result.fail (Bad_request "empty path in repository")
788
else
789
let* root = Tree.head_tree_hash repository in
790
let rec walk trail tree_hash = function
791
| [] -> missing ()
792
| segment :: rest -> (
793
Lwt_result.bind (read_value repository tree_hash) @@ function
794
| Git.Value.Tree tree -> (
795
let entries = Store.Value.Tree.to_list tree in
796
match
797
List.find_opt
798
(fun (entry : Store.Value.Tree.entry) ->
799
entry.name = segment)
800
entries
801
with
802
| None -> missing ()
803
| Some entry ->
804
let step = (entry.name, Store.Hash.to_hex entry.node) in
805
let trail = step :: trail in
806
if rest = [] then
807
Lwt_result.bind (read_value repository entry.node)
808
@@ function
809
| Git.Value.Blob blob ->
810
Lwt_result.return
811
(`Blob (Blob.to_t blob), List.rev trail)
812
| Git.Value.Tree tree ->
813
Lwt_result.return
814
(`Tree (Tree.to_t tree), List.rev trail)
815
| _ -> missing ()
816
else walk trail entry.node rest)
817
| _ -> missing ())
818
in
819
walk [] root segments
820
821
module Readme = struct
822
type t = { name : string; content : string }
823
end
824
825
module Repo = struct
826
let readme repository =
827
let* tree = Tree.head repository in
828
match List.find_opt Entry.is_readme tree.entries with
829
| None -> Lwt_result.return None
830
| Some entry -> (
831
let* hash = hash_of_hex entry.hash in
832
Lwt_result.bind (read_value repository hash) @@ function
833
| Git.Value.Blob blob ->
834
Lwt_result.return
835
(Some
836
Readme.
837
{
838
name = entry.name;
839
content = Store.Value.Blob.to_string blob;
840
})
841
| _ -> Lwt_result.fail (Internal ("could not read file " ^ entry.name)))
842
end
843
844
let read_readme_in dir =
845
let readme_candidates =
846
[ "README"; "README.md"; "README.org"; "README.txt" ]
847
in
848
let rec try_candidates = function
849
| [] -> None
850
| name :: rest -> (
851
let path = Filename.concat dir name in
852
try
853
let content = In_channel.with_open_text path In_channel.input_all in
854
Some { Blob.content }
855
with Sys_error _ -> try_candidates rest)
856
in
857
try_candidates readme_candidates
858
859
let read_root_readme config = read_readme_in config.Config.git_project_root
860
861
let scan_subdirectory config subdir =
862
let full_path = Filename.concat config.Config.git_project_root subdir in
863
if not (Sys.file_exists full_path && Sys.is_directory full_path) then
864
Error (Not_found ("directory not found: " ^ subdir))
865
else scan_directory full_path
866
867
let read_subdir_readme config subdir =
868
read_readme_in (Filename.concat config.Config.git_project_root subdir)
869