feat support nested repos with multi-segment path routing

Replace the PPX-based per-route registration with a catch-all dispatcher that parses URL paths to find the boundary between the repo name and the action suffix. Changes: - routes.ml: new dispatch function splits path segments at known action keywords (summary, commits, files, etc.) - resolvers.ml: is_valid_repo_name now allows '/' for nested paths, validating each segment individually - handlers.ml: single '/**' catch-all route delegates to dispatcher - root.ml: passes full prefix path (dir/repo) to Routes.Repo - static_handler.ml: adapted to plain Dream.get handler signature - test: 'nested/repo' is now valid; '.hidden/repo' still rejected A repo at git_project_root/subdir/myrepo now routes correctly at /subdir/myrepo/summary/, /subdir/myrepo/commits/, etc.

Commit
e7b550a1245b0640c42ec9b4c86d339f9747eb2d
Author
Claude Sonnet 4 <claude@anthropic.invalid>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/handlers.ml
index 5d165219..dc9b8334 100644..100644
@@ -205,20 +205,48 @@
205 205 end
206 206
207 207 let routes config =
208 Removed: let open Dream_html in
208 Added: let repo_dispatcher request =
209 Added: let path = Dream.target request in
210 Added: (* Strip leading slash and query string *)
211 Added: let path =
212 Added: if String.starts_with ~prefix:"/" path then
213 Added: String.sub path 1 (String.length path - 1)
214 Added: else path
215 Added: in
216 Added: let path =
217 Added: match String.index_opt path '?' with
218 Added: | None -> path
219 Added: | Some i -> String.sub path 0 i
220 Added: in
221 Added: match Routes.dispatch path with
222 Added: | None -> error_response (Not_found ("not found: " ^ Dream.target request))
223 Added: | Some (name, action) -> (
224 Added: match action with
225 Added: | Routes.Summary ->
226 Added: Repo.with_repository config name (Repo.summary config)
227 Added: | Routes.Commits_page ->
228 Added: Repo.with_repository config name (fun repository context ->
229 Added: Repo.commits config request repository context)
230 Added: | Routes.Commits_for_branch branch ->
231 Added: Repo.with_repository config name (fun repository context ->
232 Added: Repo.commits_branch config repository context branch)
233 Added: | Routes.Commit_detail hash ->
234 Added: Repo.with_repository config name (fun repository context ->
235 Added: Repo.commit_id repository context hash)
236 Added: | Routes.Files_page ->
237 Added: Repo.with_repository config name Repo.files_at_head
238 Added: | Routes.File_detail hash ->
239 Added: Repo.with_repository config name (fun repository context ->
240 Added: Repo.file_id repository context hash)
241 Added: | Routes.Raw hash ->
242 Added: Repo.with_repository config name (fun repository context ->
243 Added: Repo.raw_file repository context hash)
244 Added: | Routes.Branches_page -> Repo.with_repository config name Repo.branches
245 Added: | Routes.Tags_page -> Repo.with_repository config name Repo.tags
246 Added: | Routes.Readme_page -> Repo.with_repository config name Repo.readme)
247 Added: in
209 248 [
210 Removed: get Routes.root_path (root config);
211 Removed: get Routes.repo_root_path (Repo.handle config (Repo.summary config));
212 Removed: get Routes.repo_path (Repo.handle config (Repo.summary config));
213 Removed: get Routes.commits_path (Repo.handle_request config (Repo.commits config));
214 Removed: get Routes.commits_branch_path
215 Removed: (Repo.handle_id config (Repo.commits_branch config));
216 Removed: get Routes.commit_path (Repo.handle_id config Repo.commit_id);
217 Removed: get Routes.files_path (Repo.handle config Repo.files_at_head);
218 Removed: get Routes.file_path (Repo.handle_id config Repo.file_id);
219 Removed: get Routes.raw_file_path (Repo.handle_id config Repo.raw_file);
220 Removed: get Routes.branches_path (Repo.handle config Repo.branches);
221 Removed: get Routes.tags_path (Repo.handle config Repo.tags);
222 Removed: get Routes.readme_path (Repo.handle config Repo.readme);
223 Removed: get Routes.static_path Static_handler.handler;
249 Added: Dream.get "/" (root config);
250 Added: Dream.get "/static/**" Static_handler.handler;
251 Added: Dream.get "/**" repo_dispatcher;
224 252 ]
lib/resolvers.ml
index 57123535..b37e5d11 100644..100644
@@ -30,10 +30,13 @@
30 30 else Lwt_result.fail (Bad_request ("invalid object id " ^ hash))
31 31
32 32 let is_valid_repo_name repo =
33 Removed: let invalid_char = function '/' | '\\' | '\x00' -> true | _ -> false in
34 Removed: repo <> "" && repo <> "." && repo <> ".."
35 Removed: && (not (String.starts_with ~prefix:"." repo))
36 Removed: && not (String.exists invalid_char repo)
33 Added: let invalid_char = function '\\' | '\x00' -> true | _ -> false in
34 Added: let valid_segment s =
35 Added: s <> "" && s <> "." && s <> ".." && not (String.starts_with ~prefix:"." s)
36 Added: in
37 Added: repo <> ""
38 Added: && (not (String.exists invalid_char repo))
39 Added: && String.split_on_char '/' repo |> List.for_all valid_segment
37 40
38 41 let validate_repo_name repo =
39 42 if is_valid_repo_name repo then Lwt_result.return repo
lib/routes.ml
index 5f6aedc3..40fb0ef9 100644..100644
@@ -13,35 +13,85 @@
13 13 | Readme of string
14 14 | Raw_file of string * string
15 15
16 Removed: let%path root_path = "/"
17 Removed: let%path repo_root_path = "/%s/"
18 Removed: let%path repo_path = "/%s/summary/"
19 Removed: let%path commits_path = "/%s/commits/"
20 Removed: let%path commits_branch_path = "/%s/commits/%s"
21 Removed: let%path commit_path = "/%s/commit/%s"
22 Removed: let%path files_path = "/%s/files/"
23 Removed: let%path file_path = "/%s/file/%s"
24 Removed: let%path branches_path = "/%s/branches/"
25 Removed: let%path tags_path = "/%s/tags/"
26 Removed: let%path readme_path = "/%s/README"
27 Removed: let%path raw_file_path = "/%s/raw/%s"
28 Removed: let%path static_path = "/static/%*s"
16 Added: let root_path = "/"
17 Added: let static_prefix = "/static/"
29 18
19 Added: (* Generate URL paths for routes *)
20 Added: let path_of = function
21 Added: | Root -> "/"
22 Added: | Repo repo -> "/" ^ repo ^ "/summary/"
23 Added: | Commits repo -> "/" ^ repo ^ "/commits/"
24 Added: | Commits_branch (repo, branch) -> "/" ^ repo ^ "/commits/" ^ branch
25 Added: | Commit (repo, hash) -> "/" ^ repo ^ "/commit/" ^ hash
26 Added: | Files repo -> "/" ^ repo ^ "/files/"
27 Added: | File (repo, hash) -> "/" ^ repo ^ "/file/" ^ hash
28 Added: | Branches repo -> "/" ^ repo ^ "/branches/"
29 Added: | Tags repo -> "/" ^ repo ^ "/tags/"
30 Added: | Readme repo -> "/" ^ repo ^ "/README"
31 Added: | Raw_file (repo, hash) -> "/" ^ repo ^ "/raw/" ^ hash
32 Added:
30 33 let link_to route ?(other_attrs = []) contents =
31 34 let open Dream_html in
32 35 let open HTML in
33 Removed: let path = function
34 Removed: | Root -> path_attr href root_path
35 Removed: | Repo repo -> path_attr href repo_path repo
36 Removed: | Commits repo -> path_attr href commits_path repo
37 Removed: | Commits_branch (repo, branch) ->
38 Removed: path_attr href commits_branch_path repo branch
39 Removed: | Commit (repo, commit) -> path_attr href commit_path repo commit
40 Removed: | Files repo -> path_attr href files_path repo
41 Removed: | File (repo, hash) -> path_attr href file_path repo hash
42 Removed: | Branches repo -> path_attr href branches_path repo
43 Removed: | Tags repo -> path_attr href tags_path repo
44 Removed: | Readme repo -> path_attr href readme_path repo
45 Removed: | Raw_file (repo, hash) -> path_attr href raw_file_path repo hash
36 Added: a (href "%s" (path_of route) :: other_attrs) [ contents ]
37 Added:
38 Added: (** Dispatch a request path (without leading slash) into a route. Returns
39 Added: [(repo_name, action, param)] where action identifies what to do. *)
40 Added: type action =
41 Added: | Summary
42 Added: | Commits_page
43 Added: | Commits_for_branch of string
44 Added: | Commit_detail of string
45 Added: | Files_page
46 Added: | File_detail of string
47 Added: | Branches_page
48 Added: | Tags_page
49 Added: | Readme_page
50 Added: | Raw of string
51 Added:
52 Added: let known_actions =
53 Added: [
54 Added: "summary";
55 Added: "commits";
56 Added: "commit";
57 Added: "files";
58 Added: "file";
59 Added: "branches";
60 Added: "tags";
61 Added: "README";
62 Added: "raw";
63 Added: ]
64 Added:
65 Added: let dispatch path =
66 Added: (* path is the full URL path without leading slash, e.g. "sub/repo/commits/" *)
67 Added: let segments =
68 Added: String.split_on_char '/' path |> List.filter (fun s -> s <> "")
46 69 in
47 Removed: a (path route :: other_attrs) [ contents ]
70 Added: (* Try to find the split point: the last segment that matches a known action *)
71 Added: let rec find_split repo_acc = function
72 Added: | [] ->
73 Added: (* No action found — treat the whole path as repo with implicit summary *)
74 Added: let repo = String.concat "/" (List.rev repo_acc) in
75 Added: if repo = "" then None else Some (repo, Summary)
76 Added: | seg :: rest when List.mem seg known_actions ->
77 Added: let repo = String.concat "/" (List.rev repo_acc) in
78 Added: if repo = "" then None
79 Added: else
80 Added: let action =
81 Added: match (seg, rest) with
82 Added: | "summary", _ -> Summary
83 Added: | "commits", [] -> Commits_page
84 Added: | "commits", [ branch ] -> Commits_for_branch branch
85 Added: | "commit", [ hash ] -> Commit_detail hash
86 Added: | "files", _ -> Files_page
87 Added: | "file", [ hash ] -> File_detail hash
88 Added: | "branches", _ -> Branches_page
89 Added: | "tags", _ -> Tags_page
90 Added: | "README", _ -> Readme_page
91 Added: | "raw", [ hash ] -> Raw hash
92 Added: | _ -> Summary
93 Added: in
94 Added: Some (repo, action)
95 Added: | seg :: rest -> find_split (seg :: repo_acc) rest
96 Added: in
97 Added: find_split [] segments
lib/static_handler.ml
index bd83674a..3b5af955 100644..100644
@@ -9,7 +9,14 @@
9 9 | ".ico" -> "image/x-icon"
10 10 | _ -> "application/octet-stream"
11 11
12 Removed: let handler _request _captured_length path =
12 Added: let handler request =
13 Added: let target = Dream.target request in
14 Added: (* Strip "/static/" prefix *)
15 Added: let path =
16 Added: if String.starts_with ~prefix:"/static/" target then
17 Added: String.sub target 8 (String.length target - 8)
18 Added: else ""
19 Added: in
13 20 match Static_assets.read path with
14 21 | Some content ->
15 22 let content_type = Filename.extension path |> content_type_of_ext in
lib/views/root.ml
index 5cd408db..44e6b020 100644..100644
@@ -2,12 +2,18 @@
2 2
3 3 open Dream_html
4 4
5 Removed: let rec li_of_fs_node node =
5 Added: let rec li_of_fs_node ~prefix node =
6 6 match node with
7 7 | Resolvers.Repo repo_name ->
8 Added: let full_path =
9 Added: if prefix = "" then repo_name else prefix ^ "/" ^ repo_name
10 Added: in
8 11 HTML.(
9 Removed: li [] [ Routes.link_to (Routes.Repo repo_name) (txt "%s" repo_name) ])
12 Added: li [] [ Routes.link_to (Routes.Repo full_path) (txt "%s" repo_name) ])
10 13 | Resolvers.Directory (dir_name, children) ->
14 Added: let child_prefix =
15 Added: if prefix = "" then dir_name else prefix ^ "/" ^ dir_name
16 Added: in
11 17 HTML.(
12 18 li
13 19 [ class_ "tree-dir" ]
@@ -20,13 +26,18 @@
20 26 span [ class_ "tree-chevron" ] [ txt "\xe2\x80\xba" ];
21 27 span [] [ txt "%s/" dir_name ];
22 28 ];
23 Removed: ul [ class_ "tree-nested" ] (List.map li_of_fs_node children);
29 Added: ul
30 Added: [ class_ "tree-nested" ]
31 Added: (List.map (li_of_fs_node ~prefix:child_prefix) children);
24 32 ];
25 33 ])
26 34
27 35 let render ~user ~root_title nodes =
28 36 let tree =
29 Removed: HTML.(div [ id "repositories" ] [ ul [] (List.map li_of_fs_node nodes) ])
37 Added: HTML.(
38 Added: div
39 Added: [ id "repositories" ]
40 Added: [ ul [] (List.map (li_of_fs_node ~prefix:"") nodes) ])
30 41 in
31 42 respond
32 43 @@ Layout.render ~user ~root_title
test/test_validation.ml
index bb2a2a6d..f967ce2f 100644..100644
@@ -6,7 +6,10 @@
6 6 (Ogit.Resolvers.is_valid_repo_name "project.git");
7 7 Alcotest.(check bool)
8 8 "project" true
9 Removed: (Ogit.Resolvers.is_valid_repo_name "project")
9 Added: (Ogit.Resolvers.is_valid_repo_name "project");
10 Added: Alcotest.(check bool)
11 Added: "nested/repo" true
12 Added: (Ogit.Resolvers.is_valid_repo_name "nested/repo")
10 13
11 14 let test_invalid_repo_names () =
12 15 List.iter
@@ -21,7 +24,8 @@
21 24 ".";
22 25 "..";
23 26 "../outside";
24 Removed: "nested/repo";
27 Added: ".hidden/repo";
28 Added: "nested/.hidden";
25 29 "nested\\repo";
26 30 "bad\x00repo";
27 31 ]