···12121313let logging_term =
1414 let verbose_http_term =
1515- Term.(const (fun ws -> ws.Requests.Cmd.value) $ Requests.Cmd.verbose_http_term "monopam")
1515+ Term.(
1616+ const (fun ws -> ws.Requests.Cmd.value)
1717+ $ Requests.Cmd.verbose_http_term "monopam")
1618 in
1717- Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level () $ verbose_http_term)
1919+ Term.(
2020+ const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ()
2121+ $ verbose_http_term)
18221923let package_arg =
2024 let doc = "Package name. If not specified, operates on all packages." in
2125 Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
22262323-(* Load config from opamverse.toml and convert to Monopam.Config *)
2727+(* Load config from opamverse.toml *)
2428let load_config env =
2529 let fs = Eio.Stdenv.fs env in
2626- match Monopam.Verse_config.load ~fs () with
2727- | Error msg -> Error msg
2828- | Ok verse_config ->
2929- (* Convert Verse_config to Monopam.Config *)
3030- let opam_repo = Monopam.Verse_config.opam_repo_path verse_config in
3131- let checkouts = Monopam.Verse_config.src_path verse_config in
3232- let monorepo = Monopam.Verse_config.mono_path verse_config in
3333- let default_branch = Monopam.Verse_config.default_branch in
3434- Ok (Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch ())
3030+ Monopam.Config.load ~fs ()
35313632let with_config env f =
3733 match load_config env with
3834 | Ok config -> f config
3935 | Error msg ->
4036 Fmt.epr "Error loading config: %s@." msg;
4141- Fmt.epr "Run 'monopam verse init' first to create a workspace.@.";
3737+ Fmt.epr "Run 'monopam init' first to create a workspace.@.";
4238 `Error (false, "configuration error")
43394440(* Status command *)
···5753 `I ("remote:", "Sync between your checkout (src/) and upstream git remote");
5854 `S "LOCAL SYNC INDICATORS";
5955 `I ("local:=", "Monorepo and checkout are in sync");
6060- `I ("local:+N", "Monorepo has N commits not yet in checkout (run $(b,monopam sync))");
6161- `I ("local:-N", "Checkout has N commits not yet in monorepo (run $(b,monopam sync))");
5656+ `I
5757+ ( "local:+N",
5858+ "Monorepo has N commits not yet in checkout (run $(b,monopam sync))"
5959+ );
6060+ `I
6161+ ( "local:-N",
6262+ "Checkout has N commits not yet in monorepo (run $(b,monopam sync))"
6363+ );
6264 `I ("local:sync", "Trees differ, needs sync (run $(b,monopam sync))");
6365 `S "REMOTE SYNC INDICATORS";
6466 `I ("remote:=", "Checkout and upstream remote are in sync");
6565- `I ("remote:+N", "Checkout has N commits to push (run $(b,monopam sync --remote))");
6767+ `I
6868+ ( "remote:+N",
6969+ "Checkout has N commits to push (run $(b,monopam sync --remote))" );
6670 `I ("remote:-N", "Upstream has N commits to pull (run $(b,monopam sync))");
6771 `I ("remote:+N/-M", "Diverged: checkout +N ahead, upstream +M ahead");
6872 `S "FORK ANALYSIS";
···9094 let proc = Eio.Stdenv.process_mgr env in
9195 match Monopam.status ~proc ~fs ~config () with
9296 | Ok statuses ->
9393- Fmt.pr "%a" Monopam.Status.pp_summary statuses;
9797+ (* Load sources.toml for origin indicators *)
9898+ let sources =
9999+ let mono_path = Monopam.Config.Paths.monorepo config in
100100+ let sources_path = Fpath.(mono_path / "sources.toml") in
101101+ match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
102102+ | Ok s -> Some s
103103+ | Error _ -> None
104104+ in
105105+ Fmt.pr "%a" (Monopam.Status.pp_summary ?sources) statuses;
94106 (* Check for unregistered opam files *)
95107 (match Monopam.discover_packages ~fs ~config () with
96108 | Ok pkgs ->
9797- let unregistered = Monopam.find_unregistered_opam_files ~fs ~config pkgs in
109109+ let unregistered =
110110+ Monopam.find_unregistered_opam_files ~fs ~config pkgs
111111+ in
98112 if unregistered <> [] then begin
99113 (* Get local handle abbreviation *)
100100- let handle_abbrev = match Monopam.Verse_config.load ~fs () with
101101- | Ok vc ->
114114+ let handle_abbrev =
115115+ match Monopam.Verse_config.load ~fs () with
116116+ | Ok vc -> (
102117 let h = Monopam.Verse_config.handle vc in
103103- (match String.split_on_char '.' h with
104104- | first :: _ -> if String.length first <= 4 then first else String.sub first 0 3
105105- | [] -> h)
118118+ match String.split_on_char '.' h with
119119+ | first :: _ ->
120120+ if String.length first <= 4 then first
121121+ else String.sub first 0 3
122122+ | [] -> h)
106123 | Error _ -> "local"
107124 in
108125 Fmt.pr "%a %a\n"
109109- Fmt.(styled `Bold string) "Unregistered:"
110110- Fmt.(styled `Faint int) (List.length unregistered);
111111- List.iter (fun (_r, p) ->
112112- Fmt.pr " %-22s %a\n" p Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s)) handle_abbrev)
126126+ Fmt.(styled `Bold string)
127127+ "Unregistered:"
128128+ Fmt.(styled `Faint int)
129129+ (List.length unregistered);
130130+ List.iter
131131+ (fun (_r, p) ->
132132+ Fmt.pr " %-22s %a\n" p
133133+ Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
134134+ handle_abbrev)
113135 unregistered
114136 end
115137 | Error _ -> ());
···118140 | Error _ -> ()
119141 | Ok verse_config ->
120142 let forks =
121121- Monopam.Forks.compute ~proc ~fs ~verse_config ~monopam_config:config ()
143143+ Monopam.Forks.compute ~proc ~fs ~verse_config
144144+ ~monopam_config:config ()
122145 in
123146 if forks.repos <> [] then
124147 Fmt.pr "%a" (Monopam.Forks.pp_summary' ~show_all) forks);
···160183 `S "PHASES";
161184 `P "The sync command executes these phases in order:";
162185 `I ("1. Validate", "Abort if the monorepo has uncommitted changes");
163163- `I ("2. Push", "Export monorepo changes to checkouts (parallel) [--skip-push skips]");
186186+ `I
187187+ ( "2. Push",
188188+ "Export monorepo changes to checkouts (parallel) [--skip-push skips]"
189189+ );
164190 `I ("3. Fetch", "Clone/fetch from remotes (parallel) [--skip-pull skips]");
165191 `I ("4. Merge", "Fast-forward merge checkouts [--skip-pull skips]");
166192 `I ("5. Subtree", "Pull subtrees into monorepo [--skip-pull skips]");
167193 `I ("6. Finalize", "Update README.md, CLAUDE.md, and dune-project");
168194 `I ("7. Remote", "Push to upstream remotes if --remote (parallel)");
169195 `S "SKIP OPTIONS";
170170- `I ("--skip-push", "Skip exporting monorepo changes to checkouts. Use when \
171171- you know you have no local changes to export.");
172172- `I ("--skip-pull", "Skip fetching and pulling from remotes. Use when you \
173173- only want to export local changes without pulling remote updates.");
196196+ `I
197197+ ( "--skip-push",
198198+ "Skip exporting monorepo changes to checkouts. Use when you know you \
199199+ have no local changes to export." );
200200+ `I
201201+ ( "--skip-pull",
202202+ "Skip fetching and pulling from remotes. Use when you only want to \
203203+ export local changes without pulling remote updates." );
174204 `S "PREREQUISITES";
175205 `P "Before running sync:";
176176- `I ("-", "Commit all changes in the monorepo: $(b,git add -A && git commit)");
206206+ `I
207207+ ( "-",
208208+ "Commit all changes in the monorepo: $(b,git add -A && git commit)" );
177209 `I ("-", "For --remote: ensure git credentials/SSH keys are configured");
178210 ]
179211 in
···197229 with_config env @@ fun config ->
198230 let fs = Eio.Stdenv.fs env in
199231 let proc = Eio.Stdenv.process_mgr env in
200200- match Monopam.sync ~proc ~fs ~config ?package ~remote ~skip_push ~skip_pull () with
232232+ match
233233+ Monopam.sync ~proc ~fs ~config ?package ~remote ~skip_push ~skip_pull ()
234234+ with
201235 | Ok summary ->
202202- if summary.errors = [] then
203203- `Ok ()
236236+ if summary.errors = [] then `Ok ()
204237 else begin
205205- Fmt.epr "Sync completed with %d errors.@." (List.length summary.errors);
238238+ Fmt.epr "Sync completed with %d errors.@."
239239+ (List.length summary.errors);
206240 `Ok ()
207241 end
208242 | Error e ->
···210244 `Error (false, "sync failed")
211245 in
212246 Cmd.v info
213213- Term.(ret (const run $ package_arg $ remote_arg $ skip_push_arg $ skip_pull_arg $ logging_term))
247247+ Term.(
248248+ ret
249249+ (const run $ package_arg $ remote_arg $ skip_push_arg $ skip_pull_arg
250250+ $ logging_term))
214251215252(* Changes command *)
216253···223260 `P
224261 "By default, generates weekly entries. Use --daily to generate daily \
225262 entries instead.";
226226- `P
227227- "Changes are stored in the .changes directory at the monorepo root:";
263263+ `P "Changes are stored in the .changes directory at the monorepo root:";
228264 `I (".changes/<repo>.json", "Weekly changelog entries");
229265 `I (".changes/<repo>-daily.json", "Daily changelog entries");
230230- `I (".changes/YYYYMMDD.json", "Aggregated daily entries (default with --daily)");
231231- `P
232232- "Also generates aggregated markdown files at the monorepo root:";
266266+ `I
267267+ ( ".changes/YYYYMMDD.json",
268268+ "Aggregated daily entries (default with --daily)" );
269269+ `P "Also generates aggregated markdown files at the monorepo root:";
233270 `I ("CHANGES.md", "Aggregated weekly changelog");
234271 `I ("DAILY-CHANGES.md", "Aggregated daily changelog");
235272 `P "Each entry includes:";
···245282 (empty summary and changes) rather than 'no changes' text.";
246283 `P
247284 "When using --daily, an aggregated JSON file is generated by default \
248248- for the poe Zulip bot broadcasting system. Use --no-aggregate to skip.";
285285+ for the poe Zulip bot broadcasting system. Use --no-aggregate to \
286286+ skip.";
249287 `P
250288 "If a per-repo-per-day JSON file already exists for a past day, that \
251289 repo is skipped for that day to avoid redundant Claude API calls.";
···257295 Arg.(value & flag & info [ "daily"; "d" ] ~doc)
258296 in
259297 let weeks =
260260- let doc = "Number of past weeks to analyze (default: 1, current week only). Ignored if --daily is set." in
298298+ let doc =
299299+ "Number of past weeks to analyze (default: 1, current week only). \
300300+ Ignored if --daily is set."
301301+ in
261302 Arg.(value & opt int 1 & info [ "w"; "weeks" ] ~doc)
262303 in
263304 let days =
264264- let doc = "Number of past days to analyze when using --daily (default: 1, today only)" in
305305+ let doc =
306306+ "Number of past days to analyze when using --daily (default: 1, today \
307307+ only)"
308308+ in
265309 Arg.(value & opt int 1 & info [ "days" ] ~doc)
266310 in
267311 let history =
268268- let doc = "Number of recent entries to include in aggregated markdown (default: 12 for weekly, 30 for daily)" in
312312+ let doc =
313313+ "Number of recent entries to include in aggregated markdown (default: 12 \
314314+ for weekly, 30 for daily)"
315315+ in
269316 Arg.(value & opt int 12 & info [ "history" ] ~doc)
270317 in
271318 let dry_run =
···273320 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
274321 in
275322 let no_aggregate =
276276- let doc = "Skip generating .changes/YYYYMMDD.json aggregated file (--daily generates it by default)" in
323323+ let doc =
324324+ "Skip generating .changes/YYYYMMDD.json aggregated file (--daily \
325325+ generates it by default)"
326326+ in
277327 Arg.(value & flag & info [ "no-aggregate" ] ~doc)
278328 in
279329 let run package daily weeks days history dry_run no_aggregate () =
···288338 let history = if history = 12 then 30 else history in
289339 (* Aggregate by default for daily, unless --no-aggregate is passed *)
290340 let aggregate = not no_aggregate in
291291- Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history ~dry_run ~aggregate ()
341341+ Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history
342342+ ~dry_run ~aggregate ()
292343 end
293344 else
294294- Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history ~dry_run ()
345345+ Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history
346346+ ~dry_run ()
295347 in
296348 match result with
297349 | Ok () ->
···318370 `S Manpage.s_description;
319371 `P
320372 "Copies .opam files from monorepo subtrees to the opam-repo overlay. \
321321- This ensures your opam overlay reflects any changes you made to \
322322- .opam files in the monorepo.";
373373+ This ensures your opam overlay reflects any changes you made to .opam \
374374+ files in the monorepo.";
323375 `S "HOW IT WORKS";
324376 `P "For each package in your opam overlay:";
325325- `I ("1.", "Reads the .opam file from the monorepo subtree (e.g., mono/eio/eio.opam)");
326326- `I ("2.", "Compares with the opam-repo version (e.g., opam-repo/packages/eio/eio.dev/opam)");
377377+ `I
378378+ ( "1.",
379379+ "Reads the .opam file from the monorepo subtree (e.g., \
380380+ mono/eio/eio.opam)" );
381381+ `I
382382+ ( "2.",
383383+ "Compares with the opam-repo version (e.g., \
384384+ opam-repo/packages/eio/eio.dev/opam)" );
327385 `I ("3.", "If different, copies monorepo → opam-repo");
328386 `I ("4.", "Stages and commits changes in opam-repo");
329387 `S "PRECEDENCE";
···343401 let proc = Eio.Stdenv.process_mgr env in
344402 match Monopam.sync_opam_files ~proc ~fs ~config ?package () with
345403 | Ok result ->
346346- if result.synced = [] then
347347- Fmt.pr "All opam files already in sync.@."
348348- else
349349- Fmt.pr "Synced %d opam files.@." (List.length result.synced);
404404+ if result.synced = [] then Fmt.pr "All opam files already in sync.@."
405405+ else Fmt.pr "Synced %d opam files.@." (List.length result.synced);
350406 `Ok ()
351407 | Error e ->
352408 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
···369425 let info = Cmd.info "opam" ~doc ~man in
370426 Cmd.group info [ opam_sync_cmd ]
371427372372-(* Verse commands *)
428428+(* Init command - initialize a new monopam workspace *)
373429374374-(* Helper to load verse config from XDG *)
375375-let with_verse_config env f =
376376- let fs = Eio.Stdenv.fs env in
377377- match Monopam.Verse_config.load ~fs () with
378378- | Ok config -> f config
379379- | Error msg ->
380380- Fmt.epr "Error loading opamverse config: %s@." msg;
381381- Fmt.epr "Run 'monopam verse init' to create a workspace.@.";
382382- `Error (false, "configuration error")
383383-384384-let verse_root_arg =
385385- let doc = "Path to workspace root directory. Defaults to current directory." in
430430+let init_root_arg =
431431+ let doc =
432432+ "Path to workspace root directory. Defaults to current directory."
433433+ in
386434 Arg.(
387435 value
388436 & opt (some (conv (Fpath.of_string, Fpath.pp))) None
389437 & info [ "root" ] ~docv:"PATH" ~doc)
390438391391-let verse_handle_arg =
392392- let doc = "Tangled handle (e.g., alice.bsky.social)" in
393393- Arg.(required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
394394-395395-let verse_handle_opt_pos_arg =
396396- let doc = "Tangled handle. If not specified, operates on all tracked members." in
397397- Arg.(value & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
439439+let init_handle_arg =
440440+ let doc = "Your handle (e.g., alice.bsky.social)" in
441441+ Arg.(
442442+ required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
398443399399-let verse_init_cmd =
400400- let doc = "Initialize a new opamverse workspace" in
444444+let init_cmd =
445445+ let doc = "Initialize a new monopam workspace" in
401446 let man =
402447 [
403448 `S Manpage.s_description;
404449 `P
405405- "Creates a new opamverse workspace for federated monorepo collaboration. \
406406- An opamverse workspace lets you browse and track other developers' \
407407- monorepos alongside your own.";
450450+ "Creates a new monopam workspace for monorepo development. The workspace \
451451+ lets you manage your own monorepo and optionally browse and track other \
452452+ developers' monorepos.";
408453 `S "WORKSPACE STRUCTURE";
409409- `P "The init command creates the following directory structure at the workspace root:";
454454+ `P
455455+ "The init command creates the following directory structure at the \
456456+ workspace root:";
410457 `I ("mono/", "Your monorepo - use with standard monopam commands");
411458 `I ("src/", "Your source checkouts - individual git repos");
412459 `I ("verse/", "Other users' monorepos, organized by handle");
413460 `P "Configuration and data are stored in XDG directories:";
414461 `I ("~/.config/monopam/opamverse.toml", "Workspace configuration");
415415- `I ("~/.local/share/monopam/opamverse-registry/", "Git clone of the community registry");
462462+ `I
463463+ ( "~/.local/share/monopam/opamverse-registry/",
464464+ "Git clone of the community registry" );
416465 `S "CONFIGURATION FILE";
417466 `P "The opamverse.toml file has the following structure:";
418418- `Pre "[workspace]\n\
419419- root = \"/path/to/workspace\"\n\
420420- default_branch = \"main\"\n\n\
421421- [paths]\n\
422422- mono = \"mono\"\n\
423423- src = \"src\"\n\
424424- verse = \"verse\"\n\n\
425425- [identity]\n\
426426- handle = \"yourname.bsky.social\"";
427427- `S "AUTHENTICATION";
428428- `P
429429- "Before running init, you must authenticate with the tangled network:";
430430- `Pre "tangled auth login";
467467+ `Pre
468468+ "[workspace]\n\
469469+ root = \"/path/to/workspace\"\n\
470470+ default_branch = \"main\"\n\n\
471471+ [paths]\n\
472472+ mono = \"mono\"\n\
473473+ src = \"src\"\n\
474474+ verse = \"verse\"\n\n\
475475+ [identity]\n\
476476+ handle = \"yourname.bsky.social\"";
477477+ `S "HANDLE VALIDATION";
431478 `P
432432- "The handle you provide is validated against the AT Protocol identity \
433433- system to ensure it exists and you are authenticated.";
479479+ "The handle you provide identifies you in the community. \
480480+ It should be a valid domain name (e.g., yourname.bsky.social or \
481481+ your-domain.com).";
434482 `S "REGISTRY";
435483 `P
436436- "The opamverse registry is a git repository containing an opamverse.toml \
437437- file that lists community members and their monorepo URLs. The default \
484484+ "The registry is a git repository containing an opamverse.toml file \
485485+ that lists community members and their monorepo URLs. The default \
438486 registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
439487 `S Manpage.s_examples;
440440- `P "Initialize a workspace in ~/tangled:";
441441- `Pre "cd ~/tangled\n\
442442- monopam verse init --handle alice.bsky.social";
488488+ `P "Initialize a workspace in the current directory:";
489489+ `Pre "monopam init --handle alice.bsky.social";
443490 `P "Initialize with explicit root path:";
444444- `Pre "monopam verse init --root ~/my-workspace --handle alice.bsky.social";
491491+ `Pre "monopam init --root ~/my-workspace --handle alice.bsky.social";
445492 ]
446493 in
447494 let info = Cmd.info "init" ~doc ~man in
···452499 let root =
453500 match root with
454501 | Some r -> r
455455- | None ->
502502+ | None -> (
456503 let cwd_path = Eio.Stdenv.cwd env in
457504 let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
458505 match Fpath.of_string cwd_str with
459506 | Ok p -> p
460460- | Error (`Msg _) -> Fpath.v "."
507507+ | Error (`Msg _) -> Fpath.v ".")
461508 in
462509 match Monopam.Verse.init ~proc ~fs ~root ~handle () with
463510 | Ok () ->
464464- Fmt.pr "Monoverse workspace initialized at %a@." Fpath.pp root;
511511+ Fmt.pr "Workspace initialized at %a@." Fpath.pp root;
465512 `Ok ()
466513 | Error e ->
467514 Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
468515 `Error (false, "init failed")
469516 in
470470- Cmd.v info Term.(ret (const run $ verse_root_arg $ verse_handle_arg $ logging_term))
517517+ Cmd.v info
518518+ Term.(ret (const run $ init_root_arg $ init_handle_arg $ logging_term))
519519+520520+(* Verse commands *)
521521+522522+(* Helper to load verse config from XDG *)
523523+let with_verse_config env f =
524524+ let fs = Eio.Stdenv.fs env in
525525+ match Monopam.Verse_config.load ~fs () with
526526+ | Ok config -> f config
527527+ | Error msg ->
528528+ Fmt.epr "Error loading opamverse config: %s@." msg;
529529+ Fmt.epr "Run 'monopam init' to create a workspace.@.";
530530+ `Error (false, "configuration error")
471531472532let verse_members_cmd =
473533 let doc = "List registry members" in
···476536 `S Manpage.s_description;
477537 `P
478538 "Lists all members registered in the opamverse community registry. \
479479- This shows everyone who has published their monorepo for collaboration.";
539539+ This shows everyone who has published their monorepo for \
540540+ collaboration.";
480541 `P
481542 "The registry is automatically pulled (git pull) when running this \
482543 command to ensure you see the latest members.";
···484545 `P
485546 "The registry is a git repository containing an opamverse.toml file \
486547 with the following structure:";
487487- `Pre "[registry]\n\
488488- name = \"tangled-community\"\n\n\
489489- [[members]]\n\
490490- handle = \"alice.bsky.social\"\n\
491491- monorepo = \"https://github.com/alice/mono\"\n\n\
492492- [[members]]\n\
493493- handle = \"bob.example.com\"\n\
494494- monorepo = \"https://github.com/bob/mono\"";
548548+ `Pre
549549+ "[registry]\n\
550550+ name = \"tangled-community\"\n\n\
551551+ [[members]]\n\
552552+ handle = \"alice.bsky.social\"\n\
553553+ monorepo = \"https://github.com/alice/mono\"\n\n\
554554+ [[members]]\n\
555555+ handle = \"bob.example.com\"\n\
556556+ monorepo = \"https://github.com/bob/mono\"";
495557 `S "OUTPUT";
496558 `P "Each line shows a member's handle and their monorepo git URL:";
497497- `Pre "alice.bsky.social -> https://github.com/alice/mono\n\
498498- bob.example.com -> https://github.com/bob/mono";
559559+ `Pre
560560+ "alice.bsky.social -> https://github.com/alice/mono\n\
561561+ bob.example.com -> https://github.com/bob/mono";
499562 `S "ADDING YOURSELF";
500563 `P
501564 "To add yourself to the registry, submit a pull request to the \
···520583 in
521584 Cmd.v info Term.(ret (const run $ logging_term))
522585523523-let verse_pull_cmd =
524524- let doc = "Sync all registry members to local workspace" in
525525- let man =
526526- [
527527- `S Manpage.s_description;
528528- `P
529529- "Clones or pulls all members from the opamverse registry. For each \
530530- member, syncs both their monorepo and opam overlay repository.";
531531- `S "WHAT IT DOES";
532532- `P "For each member in the registry:";
533533- `I ("1.", "Clones or pulls their monorepo to verse/<handle>/");
534534- `I ("2.", "Clones or pulls their opam repo to verse/<handle>-opam/");
535535- `S "SCOPE";
536536- `P "With a handle argument: syncs only that specific member.";
537537- `P "Without arguments: syncs all members in the registry.";
538538- `S "ERROR HANDLING";
539539- `P
540540- "If a sync fails for one member (e.g., network error), the error \
541541- is reported but other members are still synced.";
542542- `S Manpage.s_examples;
543543- `Pre "# Sync all registry members\n\
544544- monopam verse pull\n\n\
545545- # Sync a specific member\n\
546546- monopam verse pull alice.bsky.social\n\n\
547547- # Browse their code\n\
548548- ls verse/alice.bsky.social/";
549549- ]
550550- in
551551- let info = Cmd.info "pull" ~doc ~man in
552552- let run handle () =
553553- Eio_main.run @@ fun env ->
554554- with_verse_config env @@ fun config ->
555555- let fs = Eio.Stdenv.fs env in
556556- let proc = Eio.Stdenv.process_mgr env in
557557- match Monopam.Verse.pull ~proc ~fs ~config ?handle () with
558558- | Ok () ->
559559- Fmt.pr "Sync completed.@.";
560560- `Ok ()
561561- | Error e ->
562562- Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
563563- `Error (false, "pull failed")
564564- in
565565- Cmd.v info Term.(ret (const run $ verse_handle_opt_pos_arg $ logging_term))
566566-567567-let verse_sync_cmd =
568568- let doc = "Sync the workspace" in
569569- let man =
570570- [
571571- `S Manpage.s_description;
572572- `P
573573- "Synchronizes your entire opamverse workspace with the latest upstream \
574574- changes. This is the command to run regularly to stay up to date.";
575575- `S "WHAT IT DOES";
576576- `P "The sync command performs two operations:";
577577- `I ("1.", "Updates the registry: git pull in ~/.local/share/monopam/opamverse-registry/");
578578- `I ("2.", "Pulls all tracked members: git pull in each verse/<handle>/");
579579- `S "USE CASES";
580580- `P "Run sync when you want to:";
581581- `I ("-", "See if any new members have joined the community");
582582- `I ("-", "Get the latest code from all tracked members");
583583- `I ("-", "Catch up after being away for a while");
584584- `S "COMPARISON WITH PULL";
585585- `P
586586- "'verse sync' updates the registry AND pulls members. \
587587- 'verse pull' only pulls members (skips registry update).";
588588- `S Manpage.s_examples;
589589- `Pre "# Daily sync routine\n\
590590- cd ~/tangled\n\
591591- monopam verse sync\n\
592592- monopam verse status";
593593- ]
594594- in
595595- let info = Cmd.info "sync" ~doc ~man in
596596- let run () =
597597- Eio_main.run @@ fun env ->
598598- with_verse_config env @@ fun config ->
599599- let fs = Eio.Stdenv.fs env in
600600- let proc = Eio.Stdenv.process_mgr env in
601601- match Monopam.Verse.sync ~proc ~fs ~config () with
602602- | Ok () ->
603603- Fmt.pr "Sync completed.@.";
604604- `Ok ()
605605- | Error e ->
606606- Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
607607- `Error (false, "sync failed")
608608- in
609609- Cmd.v info Term.(ret (const run $ logging_term))
610610-611586let verse_fork_cmd =
612587 let doc = "Fork a package from a verse member's repository" in
613588 let man =
···682657 (List.length result.packages_forked) result.source_handle;
683658 List.iter (fun p -> Fmt.pr " %s@." p) result.packages_forked
684659 end else begin
660660+ (* Update sources.toml with fork information *)
661661+ let mono_path = Monopam.Verse_config.mono_path config in
662662+ let sources_path = Fpath.(mono_path / "sources.toml") in
663663+ let sources =
664664+ match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
665665+ | Ok s -> s
666666+ | Error _ -> Monopam.Sources_registry.empty
667667+ in
668668+ let entry = Monopam.Sources_registry.{
669669+ url = result.fork_url;
670670+ upstream = Some result.upstream_url;
671671+ branch = None;
672672+ reason = Some (Fmt.str "Forked from %s" result.source_handle);
673673+ origin = Some Join; (* Forked from verse = joined *)
674674+ } in
675675+ let sources = Monopam.Sources_registry.add sources ~subtree:result.subtree_name entry in
676676+ (match Monopam.Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
677677+ | Ok () ->
678678+ Fmt.pr "Updated sources.toml with fork entry for %s@." result.subtree_name
679679+ | Error msg ->
680680+ Fmt.epr "Warning: Failed to update sources.toml: %s@." msg);
685681 Fmt.pr "Forked %d package(s): %a@."
686682 (List.length result.packages_forked)
687683 Fmt.(list ~sep:(any ", ") string) result.packages_forked;
···697693 Cmd.v info Term.(ret (const run $ package_arg $ from_arg $ url_arg $ dry_run_arg $ logging_term))
698694699695let verse_cmd =
700700- let doc = "Federated monorepo collaboration" in
696696+ let doc = "Verse member operations" in
701697 let man =
702698 [
703699 `S Manpage.s_description;
704700 `P
705705- "The opamverse system enables federated collaboration across multiple \
706706- developers' monorepos. Each developer maintains their own monorepo \
707707- (managed by standard monopam commands), and can track other developers' \
708708- monorepos for code browsing, learning, and collaboration.";
709709- `P
710710- "Members are identified by tangled handles - decentralized identities \
711711- from the AT Protocol network (the same system used by Bluesky).";
712712- `S "QUICK START FOR NEW USERS";
713713- `P "Run these commands in order to get started:";
714714- `Pre "# Step 1: Authenticate with tangled (one-time setup)\n\
715715- tangled auth login\n\n\
716716- # Step 2: Create and initialize your workspace\n\
717717- mkdir ~/tangled && cd ~/tangled\n\
718718- monopam verse init --handle yourname.bsky.social\n\n\
719719- # Step 3: Sync all community members\n\
720720- monopam verse pull\n\n\
721721- # Step 4: Browse their code\n\
722722- ls verse/\n\
723723- cd verse/alice.bsky.social && dune build\n\n\
724724- # Step 5: Keep everything updated (run daily/weekly)\n\
725725- monopam verse sync";
726726- `S "KEY CONCEPTS";
727727- `I ("Workspace", "A directory containing your monorepo plus all registry members' repos");
728728- `I ("Registry", "A git repository listing community members and their repo URLs");
729729- `I ("Handle", "A tangled identity like 'alice.bsky.social' validated via AT Protocol");
730730- `S "WORKSPACE STRUCTURE";
731731- `P "An opamverse workspace has this layout:";
732732- `Pre "~/tangled/ # workspace root\n\
733733- ├── mono/ # YOUR monorepo\n\
734734- ├── src/ # YOUR fork checkouts\n\
735735- ├── opam-repo/ # YOUR opam overlay\n\
736736- └── verse/\n\
737737- \ ├── alice.bsky.social/ # Alice's monorepo\n\
738738- \ ├── alice.bsky.social-opam/ # Alice's opam overlay\n\
739739- \ ├── bob.example.com/ # Bob's monorepo\n\
740740- \ └── bob.example.com-opam/ # Bob's opam overlay";
741741- `P "Configuration and data are stored in XDG directories:";
742742- `Pre "~/.config/monopam/\n\
743743- └── opamverse.toml # workspace configuration\n\n\
744744- ~/.local/share/monopam/\n\
745745- └── opamverse-registry/ # cloned registry git repo";
746746- `S "COMMAND FLOW";
747747- `P "The expected sequence of commands for typical workflows:";
748748- `P "$(b,First-time setup) (once per machine):";
749749- `Pre "tangled auth login # authenticate\n\
750750- monopam verse init --handle you.bsky.social # create workspace";
751751- `P "$(b,Syncing all members):";
752752- `Pre "monopam verse pull # clone/pull all members\n\
753753- monopam verse status # check status";
754754- `P "$(b,Daily maintenance):";
755755- `Pre "monopam verse sync # update everything\n\
756756- monopam verse status # check for changes";
757757- `P "$(b,Working in your own monorepo):";
758758- `Pre "cd ~/tangled/mono\n\
759759- monopam pull # fetch upstream changes\n\
760760- # ... make edits ...\n\
761761- monopam push # export to checkouts";
762762- `S "INTEGRATION WITH MONOPAM";
763763- `P
764764- "The verse system complements standard monopam commands. Your mono/ \
765765- directory works exactly like a normal monopam-managed monorepo:";
766766- `Pre "# Work in your monorepo\n\
767767- cd ~/tangled/mono\n\
768768- monopam status\n\
769769- monopam pull\n\
770770- # ... make changes ...\n\
771771- monopam push";
701701+ "Commands for working with verse community members. The verse system \
702702+ enables federated collaboration across multiple developers' monorepos.";
772703 `P
773773- "The verse/ directories are for reading and learning from others' code. \
774774- You generally don't push to them (unless you're a collaborator).";
775775- `S "REGISTRY FORMAT";
776776- `P
777777- "The registry is a git repository containing opamverse.toml:";
778778- `Pre "[registry]\n\
779779- name = \"tangled-community\"\n\n\
780780- [[members]]\n\
781781- handle = \"alice.bsky.social\"\n\
782782- monorepo = \"https://github.com/alice/mono\"";
704704+ "Members are identified by handles - typically domain names like \
705705+ 'yourname.bsky.social' or 'your-domain.com'.";
706706+ `S "NOTE";
783707 `P
784784- "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
785785- `S "COMMANDS REFERENCE";
786786- `I ("init", "Create a new workspace with config and directories");
787787- `I ("status", "Show members and their git status");
788788- `I ("members", "List all members in the registry");
789789- `I ("pull [<handle>]", "Clone/pull all members (or specific member)");
790790- `I ("sync", "Update registry and pull all members");
708708+ "The $(b,monopam init) command creates your workspace and \
709709+ $(b,monopam sync) automatically syncs verse members. These commands \
710710+ are for additional verse-specific operations.";
711711+ `S "COMMANDS";
712712+ `I ("members", "List all members in the community registry");
791713 `I ("fork <pkg> --from <handle> --url <url>", "Fork a package from a verse member");
792792- `S "AUTHENTICATION";
793793- `P
794794- "Handle validation uses the AT Protocol identity system. The tangled \
795795- CLI stores session credentials that monopam verse commands reuse.";
796796- `P "If you see 'Not authenticated', run:";
797797- `Pre "tangled auth login";
714714+ `S Manpage.s_examples;
715715+ `P "List all community members:";
716716+ `Pre "monopam verse members";
717717+ `P "Fork a package from another member:";
718718+ `Pre "monopam verse fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
798719 ]
799720 in
800721 let info = Cmd.info "verse" ~doc ~man in
801722 Cmd.group info
802723 [
803803- verse_init_cmd;
804724 verse_members_cmd;
805805- verse_pull_cmd;
806806- verse_sync_cmd;
807725 verse_fork_cmd;
808726 ]
809727728728+(* Diff command *)
729729+730730+let diff_cmd =
731731+ let doc = "Show diffs from verse members for repos needing attention" in
732732+ let man =
733733+ [
734734+ `S Manpage.s_description;
735735+ `P
736736+ "Shows commit diffs from verse members for repositories where they have \
737737+ commits you don't have. This helps you see what changes are available \
738738+ from collaborators.";
739739+ `S "OUTPUT";
740740+ `P "First shows the verse status summary, then for each repository where \
741741+ a verse member is ahead:";
742742+ `I ("Repository name", "With the handle and relationship");
743743+ `I ("Commits", "List of commits they have that you don't (max 20)");
744744+ `S "RELATIONSHIPS";
745745+ `I ("+N", "They have N commits you don't have");
746746+ `I ("+N/-M", "Diverged: they have N new commits, you have M new commits");
747747+ `S "CACHING";
748748+ `P "Remote fetches are cached for 1 hour to improve performance. \
749749+ Use $(b,--refresh) to force fresh fetches from all remotes.";
750750+ `S Manpage.s_examples;
751751+ `P "Show diffs for all repos needing attention (uses cache):";
752752+ `Pre "monopam diff";
753753+ `P "Show diff for a specific repository:";
754754+ `Pre "monopam diff ocaml-eio";
755755+ `P "Show patches for all commits:";
756756+ `Pre "monopam diff -p";
757757+ `P "Show patch for a specific commit (from diff output):";
758758+ `Pre "monopam diff abc1234";
759759+ `P "Force fresh fetches from all remotes:";
760760+ `Pre "monopam diff --refresh";
761761+ ]
762762+ in
763763+ let info = Cmd.info "diff" ~doc ~man in
764764+ let arg =
765765+ let doc = "Repository name or commit SHA. If a 7+ character hex string, shows \
766766+ the patch for that commit. Otherwise filters to that repository. \
767767+ If not specified, shows diffs for all repos needing attention." in
768768+ Arg.(value & pos 0 (some string) None & info [] ~docv:"REPO|SHA" ~doc)
769769+ in
770770+ let refresh_arg =
771771+ let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in
772772+ Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
773773+ in
774774+ let patch_arg =
775775+ let doc = "Show full patch content for each commit." in
776776+ Arg.(value & flag & info [ "patch"; "p" ] ~doc)
777777+ in
778778+ let run arg refresh patch () =
779779+ Eio_main.run @@ fun env ->
780780+ with_config env @@ fun config ->
781781+ with_verse_config env @@ fun verse_config ->
782782+ let fs = Eio.Stdenv.fs env in
783783+ let proc = Eio.Stdenv.process_mgr env in
784784+ (* Check if arg looks like a commit SHA *)
785785+ match arg with
786786+ | Some sha when Monopam.is_commit_sha sha ->
787787+ (* Show patch for specific commit *)
788788+ (match Monopam.diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh () with
789789+ | Some info ->
790790+ let short_hash = String.sub info.commit_hash 0 (min 7 (String.length info.commit_hash)) in
791791+ Fmt.pr "%a %s (%s/%s)@.@.%s@."
792792+ Fmt.(styled `Yellow string) short_hash
793793+ info.commit_subject
794794+ info.commit_repo info.commit_handle
795795+ info.commit_patch;
796796+ `Ok ()
797797+ | None ->
798798+ Fmt.epr "Commit %s not found in any verse diff@." sha;
799799+ `Error (false, "commit not found"))
800800+ | repo ->
801801+ let result = Monopam.diff ~proc ~fs ~config ~verse_config ?repo ~refresh ~patch () in
802802+ Fmt.pr "%a" (Monopam.pp_diff_result ~show_patch:patch) result;
803803+ `Ok ()
804804+ in
805805+ Cmd.v info Term.(ret (const run $ arg $ refresh_arg $ patch_arg $ logging_term))
806806+807807+(* Pull command - pull from verse members *)
808808+809809+let pull_cmd =
810810+ let doc = "Pull commits from a verse member's forks" in
811811+ let man =
812812+ [
813813+ `S Manpage.s_description;
814814+ `P
815815+ "Pulls commits from a verse member's forks into your local checkouts. \
816816+ This merges their changes into your checkout branches, making them \
817817+ ready to be synced to the monorepo via $(b,monopam sync).";
818818+ `S "WORKFLOW";
819819+ `P "The typical workflow for incorporating changes from collaborators:";
820820+ `I ("1.", "$(b,monopam diff) - See what changes are available");
821821+ `I ("2.", "$(b,monopam pull <handle>) - Pull changes from a collaborator");
822822+ `I ("3.", "$(b,monopam sync) - Sync changes into your monorepo");
823823+ `S "MERGING BEHAVIOR";
824824+ `P "When you're behind (they have commits you don't):";
825825+ `I ("Fast-forward", "If your branch has no new commits, a fast-forward merge is used.");
826826+ `P "When branches have diverged (both have new commits):";
827827+ `I ("Merge commit", "A merge commit is created to combine the histories.");
828828+ `S Manpage.s_examples;
829829+ `P "Pull all changes from a verse member:";
830830+ `Pre "monopam pull avsm.bsky.social";
831831+ `P "Pull changes for a specific repository:";
832832+ `Pre "monopam pull avsm.bsky.social eio";
833833+ `P "Force fresh fetches before pulling:";
834834+ `Pre "monopam pull --refresh avsm.bsky.social";
835835+ ]
836836+ in
837837+ let info = Cmd.info "pull" ~doc ~man in
838838+ let handle_arg =
839839+ let doc = "The verse member handle to pull from (e.g., avsm.bsky.social)." in
840840+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
841841+ in
842842+ let repo_arg =
843843+ let doc = "Optional repository to pull from. If not specified, pulls from all \
844844+ repositories where the handle has commits you don't have." in
845845+ Arg.(value & pos 1 (some string) None & info [] ~docv:"REPO" ~doc)
846846+ in
847847+ let refresh_arg =
848848+ let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in
849849+ Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
850850+ in
851851+ let run handle repo refresh () =
852852+ Eio_main.run @@ fun env ->
853853+ with_config env @@ fun config ->
854854+ with_verse_config env @@ fun verse_config ->
855855+ let fs = Eio.Stdenv.fs env in
856856+ let proc = Eio.Stdenv.process_mgr env in
857857+ match Monopam.pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ~refresh () with
858858+ | Ok result ->
859859+ Fmt.pr "%a" Monopam.pp_handle_pull_result result;
860860+ if result.repos_failed <> [] then
861861+ `Error (false, "some repos failed to pull")
862862+ else if result.repos_pulled = [] then begin
863863+ Fmt.pr "Nothing to pull from %s@." handle;
864864+ `Ok ()
865865+ end
866866+ else begin
867867+ Fmt.pr "@.Run $(b,monopam sync) to merge changes into your monorepo.@.";
868868+ `Ok ()
869869+ end
870870+ | Error e ->
871871+ Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
872872+ `Error (false, "pull failed")
873873+ in
874874+ Cmd.v info Term.(ret (const run $ handle_arg $ repo_arg $ refresh_arg $ logging_term))
875875+876876+(* Cherrypick command *)
877877+878878+let cherrypick_cmd =
879879+ let doc = "Cherry-pick a specific commit from a verse member's fork" in
880880+ let man =
881881+ [
882882+ `S Manpage.s_description;
883883+ `P
884884+ "Applies a specific commit from a verse member's fork to your local checkout. \
885885+ Use $(b,monopam diff) to see available commits and their hashes.";
886886+ `S "WORKFLOW";
887887+ `P "The typical workflow for cherry-picking specific commits:";
888888+ `I ("1.", "$(b,monopam diff) - See available commits with their hashes");
889889+ `I ("2.", "$(b,monopam diff <sha>) - View the full patch for a commit");
890890+ `I ("3.", "$(b,monopam cherrypick <sha>) - Apply that commit");
891891+ `I ("4.", "$(b,monopam sync) - Sync changes into your monorepo");
892892+ `S Manpage.s_examples;
893893+ `P "Cherry-pick a commit:";
894894+ `Pre "monopam cherrypick abc1234";
895895+ `P "View a commit's patch first, then cherry-pick:";
896896+ `Pre "monopam diff abc1234";
897897+ `Pre "monopam cherrypick abc1234";
898898+ ]
899899+ in
900900+ let info = Cmd.info "cherrypick" ~doc ~man in
901901+ let sha_arg =
902902+ let doc = "The commit SHA (or prefix) to cherry-pick. Must be at least 7 characters." in
903903+ Arg.(required & pos 0 (some string) None & info [] ~docv:"SHA" ~doc)
904904+ in
905905+ let refresh_arg =
906906+ let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in
907907+ Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
908908+ in
909909+ let run sha refresh () =
910910+ Eio_main.run @@ fun env ->
911911+ with_config env @@ fun config ->
912912+ with_verse_config env @@ fun verse_config ->
913913+ let fs = Eio.Stdenv.fs env in
914914+ let proc = Eio.Stdenv.process_mgr env in
915915+ match Monopam.cherrypick ~proc ~fs ~config ~verse_config ~sha ~refresh () with
916916+ | Ok result ->
917917+ Fmt.pr "%a" Monopam.pp_cherrypick_result result;
918918+ Fmt.pr "Run $(b,monopam sync) to merge changes into your monorepo.@.";
919919+ `Ok ()
920920+ | Error e ->
921921+ Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
922922+ `Error (false, "cherrypick failed")
923923+ in
924924+ Cmd.v info Term.(ret (const run $ sha_arg $ refresh_arg $ logging_term))
925925+810926(* Doctor command *)
811927812928let doctor_cmd =
···815931 [
816932 `S Manpage.s_description;
817933 `P
818818- "Analyzes your workspace health and provides actionable recommendations. \
819819- Uses Claude AI to analyze commits from verse collaborators, categorizing \
820820- them by type, priority, and risk level.";
934934+ "Analyzes your workspace health and provides actionable \
935935+ recommendations. Uses Claude AI to analyze commits from verse \
936936+ collaborators, categorizing them by type, priority, and risk level.";
821937 `S "WHAT IT DOES";
822938 `P "The doctor command:";
823939 `I ("1.", "Syncs the workspace (unless $(b,--no-sync) is specified)");
···826942 `I ("4.", "Analyzes fork relationships with verse members");
827943 `I ("5.", "Uses Claude to categorize and prioritize their commits");
828944 `I ("6.", "Generates actionable recommendations");
829829- `P "The status output from $(b,monopam status) is automatically included \
830830- in the prompt sent to Claude, so Claude doesn't need to run it separately.";
945945+ `P
946946+ "The status output from $(b,monopam status) is automatically included \
947947+ in the prompt sent to Claude, so Claude doesn't need to run it \
948948+ separately.";
831949 `S "OUTPUT FORMATS";
832950 `P "By default, outputs human-readable text with colors.";
833951 `P "Use $(b,--json) for JSON output suitable for tooling.";
···867985 Fmt.pr "Warning: sync failed: %a@." Monopam.pp_error_with_hint e;
868986 Fmt.pr "Continuing with analysis...@."
869987 end;
870870- let report = Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package ~no_sync () in
871871- if json then
872872- print_endline (Monopam.Doctor.to_json report)
873873- else
874874- Fmt.pr "%a@." Monopam.Doctor.pp_report report;
988988+ let report =
989989+ Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package
990990+ ~no_sync ()
991991+ in
992992+ if json then print_endline (Monopam.Doctor.to_json report)
993993+ else Fmt.pr "%a@." Monopam.Doctor.pp_report report;
875994 `Ok ()
876995 in
877877- Cmd.v info Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
996996+ Cmd.v info
997997+ Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
878998879999(* Feature commands *)
8801000···10381158 let info = Cmd.info "feature" ~doc ~man in
10391159 Cmd.group info [ feature_add_cmd; feature_remove_cmd; feature_list_cmd ]
1040116011611161+(* Devcontainer command *)
11621162+11631163+let default_devcontainer_url =
11641164+ "https://raw.githubusercontent.com/avsm/claude-ocaml-devcontainer/refs/heads/main/.devcontainer/devcontainer.json"
11651165+11661166+let devcontainer_cmd =
11671167+ let doc = "Setup and enter a devcontainer environment" in
11681168+ let man =
11691169+ [
11701170+ `S Manpage.s_description;
11711171+ `P
11721172+ "Creates and enters a devcontainer environment for OCaml development \
11731173+ with monopam and Claude. If the target directory doesn't have a \
11741174+ .devcontainer configuration, it will be created automatically.";
11751175+ `P
11761176+ "This is the recommended way to get started with monopam. The \
11771177+ devcontainer provides a consistent environment with OCaml, opam, \
11781178+ and all required tools pre-installed.";
11791179+ `S "WHAT IT DOES";
11801180+ `P "For a new directory (no .devcontainer/):";
11811181+ `I ("1.", "Creates the target directory if needed");
11821182+ `I ("2.", "Creates .devcontainer/ subdirectory");
11831183+ `I ("3.", "Downloads devcontainer.json from the template repository");
11841184+ `I ("4.", "Builds and starts the devcontainer");
11851185+ `I ("5.", "Opens an interactive shell inside the container");
11861186+ `P "For an existing directory with .devcontainer/:";
11871187+ `I ("1.", "Starts the devcontainer if not running");
11881188+ `I ("2.", "Opens an interactive shell inside the container");
11891189+ `S Manpage.s_options;
11901190+ `P "Use $(b,--url) to specify a custom devcontainer.json URL if you want \
11911191+ to use a different base configuration.";
11921192+ `S Manpage.s_examples;
11931193+ `P "Create a new devcontainer workspace:";
11941194+ `Pre "monopam devcontainer ~/my-ocaml-project";
11951195+ `P "Enter an existing devcontainer:";
11961196+ `Pre "monopam devcontainer ~/my-ocaml-project";
11971197+ `P "Use a custom devcontainer.json:";
11981198+ `Pre "monopam devcontainer --url https://example.com/devcontainer.json ~/project";
11991199+ ]
12001200+ in
12011201+ let info = Cmd.info "devcontainer" ~doc ~man in
12021202+ let path_arg =
12031203+ let doc = "Target directory for the devcontainer workspace." in
12041204+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PATH" ~doc)
12051205+ in
12061206+ let url_arg =
12071207+ let doc = "URL to fetch devcontainer.json from. Defaults to the claude-ocaml-devcontainer template." in
12081208+ Arg.(value & opt string default_devcontainer_url & info ["url"] ~docv:"URL" ~doc)
12091209+ in
12101210+ let run path url () =
12111211+ (* Resolve to absolute path *)
12121212+ let abs_path =
12131213+ if Filename.is_relative path then
12141214+ Filename.concat (Sys.getcwd ()) path
12151215+ else path
12161216+ in
12171217+ let devcontainer_dir = Filename.concat abs_path ".devcontainer" in
12181218+ let devcontainer_json = Filename.concat devcontainer_dir "devcontainer.json" in
12191219+ (* Check if .devcontainer exists *)
12201220+ let needs_init = not (Sys.file_exists devcontainer_dir && Sys.is_directory devcontainer_dir) in
12211221+ if needs_init then begin
12221222+ Fmt.pr "Initializing devcontainer in %s...@." abs_path;
12231223+ (* Create directories *)
12241224+ (try Unix.mkdir abs_path 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
12251225+ (try Unix.mkdir devcontainer_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
12261226+ (* Fetch devcontainer.json using curl *)
12271227+ Fmt.pr "Fetching devcontainer.json from %s...@." url;
12281228+ let curl_cmd = Printf.sprintf "curl -fsSL '%s' -o '%s'" url devcontainer_json in
12291229+ let ret = Sys.command curl_cmd in
12301230+ if ret <> 0 then begin
12311231+ Fmt.epr "Error: Failed to fetch devcontainer.json (curl exit code %d)@." ret;
12321232+ exit 1
12331233+ end;
12341234+ Fmt.pr "Created %s@." devcontainer_json;
12351235+ (* Build and start the devcontainer *)
12361236+ Fmt.pr "Building devcontainer (this may take a while on first run)...@.";
12371237+ let up_cmd = Printf.sprintf "npx @devcontainers/cli up --workspace-folder '%s' --remove-existing-container" abs_path in
12381238+ let ret = Sys.command up_cmd in
12391239+ if ret <> 0 then begin
12401240+ Fmt.epr "Error: Failed to start devcontainer (exit code %d)@." ret;
12411241+ exit 1
12421242+ end
12431243+ end;
12441244+ (* Exec into the devcontainer *)
12451245+ Fmt.pr "Entering devcontainer...@.";
12461246+ let exec_cmd = Printf.sprintf "npx @devcontainers/cli exec --workspace-folder '%s' bash -l" abs_path in
12471247+ let ret = Sys.command exec_cmd in
12481248+ if ret <> 0 then
12491249+ `Error (false, Printf.sprintf "devcontainer exec failed with code %d" ret)
12501250+ else
12511251+ `Ok ()
12521252+ in
12531253+ Cmd.v info Term.(ret (const run $ path_arg $ url_arg $ logging_term))
12541254+12551255+(* Confirmation prompt *)
12561256+let confirm prompt =
12571257+ Printf.printf "%s [y/N] %!" prompt;
12581258+ match In_channel.(input_line stdin) with
12591259+ | Some s -> String.lowercase_ascii (String.trim s) = "y"
12601260+ | None -> false
12611261+12621262+(* Prompt for optional string input *)
12631263+let prompt_string prompt =
12641264+ Printf.printf "%s %!" prompt;
12651265+ match In_channel.(input_line stdin) with
12661266+ | Some s ->
12671267+ let s = String.trim s in
12681268+ if s = "" then None else Some s
12691269+ | None -> None
12701270+12711271+(* Fork command *)
12721272+12731273+let fork_cmd =
12741274+ let doc = "Fork a monorepo subtree into its own repository" in
12751275+ let man =
12761276+ [
12771277+ `S Manpage.s_description;
12781278+ `P
12791279+ "Splits a monorepo subdirectory into its own git repository and \
12801280+ establishes a proper subtree relationship. This creates src/<name>/ \
12811281+ with the extracted history, then re-adds mono/<name>/ as a subtree.";
12821282+ `S "FORK MODES";
12831283+ `P "The fork command handles two scenarios:";
12841284+ `I ("Subtree with history", "For subtrees added via $(b,git subtree add) or \
12851285+ $(b,monopam join), the command uses $(b,git subtree split) to extract \
12861286+ the full commit history into the new repository.");
12871287+ `I ("Fresh package", "For packages created directly in mono/ without subtree \
12881288+ history, the command copies the files and creates an initial commit. \
12891289+ This is useful for new packages you've developed locally.");
12901290+ `S "WHAT IT DOES";
12911291+ `P "The fork command performs a complete workflow in one step:";
12921292+ `I ("1.", "Analyzes mono/<name>/ to detect fork mode");
12931293+ `I ("2.", "Builds an action plan and shows discovery details");
12941294+ `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
12951295+ `I ("4.", "Creates a new git repo at src/<name>/");
12961296+ `I ("5.", "Extracts history (subtree split) or copies files (fresh package)");
12971297+ `I ("6.", "Removes mono/<name>/ from git and commits");
12981298+ `I ("7.", "Re-adds mono/<name>/ as a proper subtree from src/<name>/");
12991299+ `I ("8.", "Updates sources.toml with $(b,origin = \"fork\")");
13001300+ `S "AFTER FORKING";
13011301+ `P "After forking, the subtree relationship is fully established:";
13021302+ `I ("-", "mono/<name>/ is now a proper git subtree of src/<name>/");
13031303+ `I ("-", "$(b,monopam sync) will push/pull changes correctly");
13041304+ `I ("-", "No need for manual $(b,git rm) or $(b,monopam rejoin)");
13051305+ `P "To push to a remote:";
13061306+ `Pre "cd src/<name> && git push -u origin main";
13071307+ `S Manpage.s_examples;
13081308+ `P "Fork a subtree with local-only repo:";
13091309+ `Pre "monopam fork my-lib";
13101310+ `P "Fork with a remote push URL:";
13111311+ `Pre "monopam fork my-lib git@github.com:me/my-lib.git";
13121312+ `P "Preview what would be done:";
13131313+ `Pre "monopam fork my-lib --dry-run";
13141314+ `P "Fork without confirmation:";
13151315+ `Pre "monopam fork my-lib --yes";
13161316+ ]
13171317+ in
13181318+ let info = Cmd.info "fork" ~doc ~man in
13191319+ let name_arg =
13201320+ let doc = "Name of the subtree to fork (directory name under mono/)" in
13211321+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
13221322+ in
13231323+ let url_arg =
13241324+ let doc = "Optional remote URL to add as 'origin' for pushing" in
13251325+ Arg.(value & pos 1 (some string) None & info [] ~docv:"URL" ~doc)
13261326+ in
13271327+ let dry_run_arg =
13281328+ let doc = "Show what would be done without making changes" in
13291329+ Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
13301330+ in
13311331+ let yes_arg =
13321332+ let doc = "Assume yes to all prompts (for automation)" in
13331333+ Arg.(value & flag & info [ "yes"; "y" ] ~doc)
13341334+ in
13351335+ let run name url dry_run yes () =
13361336+ Eio_main.run @@ fun env ->
13371337+ with_verse_config env @@ fun config ->
13381338+ let fs = Eio.Stdenv.fs env in
13391339+ let proc = Eio.Stdenv.process_mgr env in
13401340+ (* Get URL: use provided, or try to derive from dune-project, or prompt *)
13411341+ let url =
13421342+ match url with
13431343+ | Some _ -> url
13441344+ | None ->
13451345+ (* Try to get default from dune-project *)
13461346+ let mono_path = Monopam.Config.mono_path config in
13471347+ let subtree_path = Fpath.(mono_path / name) in
13481348+ let knot = Monopam.Config.knot config in
13491349+ let suggested = Monopam.Fork_join.suggest_push_url ~fs ~knot subtree_path in
13501350+ if yes || dry_run then
13511351+ suggested (* Use suggested or None without prompting *)
13521352+ else begin
13531353+ match suggested with
13541354+ | Some default_url ->
13551355+ Fmt.pr "Remote push URL [%s]: %!" default_url;
13561356+ (match prompt_string "" with
13571357+ | None -> Some default_url (* User pressed enter, use default *)
13581358+ | Some entered -> Some entered)
13591359+ | None ->
13601360+ Fmt.pr "Remote push URL (leave empty to skip): %!";
13611361+ prompt_string ""
13621362+ end
13631363+ in
13641364+ (* Build the plan *)
13651365+ match Monopam.Fork_join.plan_fork ~proc ~fs ~config ~name ?push_url:url ~dry_run () with
13661366+ | Error e ->
13671367+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
13681368+ `Error (false, "fork failed")
13691369+ | Ok plan ->
13701370+ (* Print discovery and actions *)
13711371+ Fmt.pr "Analyzing fork request for '%s'...@.@." name;
13721372+ Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery;
13731373+ (match url with
13741374+ | Some u -> Fmt.pr " Remote URL: %s@." u
13751375+ | None -> ());
13761376+ Fmt.pr "@.Actions to perform:@.";
13771377+ List.iteri (fun i action ->
13781378+ Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
13791379+ ) plan.actions;
13801380+ Fmt.pr "@.";
13811381+ (* Prompt for confirmation unless --yes or --dry-run *)
13821382+ let proceed =
13831383+ if dry_run then begin
13841384+ Fmt.pr "(dry-run mode - no changes will be made)@.";
13851385+ true
13861386+ end else if yes then
13871387+ true
13881388+ else
13891389+ confirm "Proceed?"
13901390+ in
13911391+ if not proceed then begin
13921392+ Fmt.pr "Cancelled.@.";
13931393+ `Ok ()
13941394+ end else begin
13951395+ (* Execute the plan *)
13961396+ match Monopam.Fork_join.execute_fork_plan ~proc ~fs plan with
13971397+ | Ok result ->
13981398+ if not dry_run then begin
13991399+ Fmt.pr "%a@." Monopam.Fork_join.pp_fork_result result;
14001400+ Fmt.pr "@.Next steps:@.";
14011401+ Fmt.pr " 1. Review the new repo: cd src/%s@." result.name;
14021402+ match url with
14031403+ | Some _ -> Fmt.pr " 2. Push to remote: git push -u origin main@."
14041404+ | None -> Fmt.pr " 2. Add a remote: git remote add origin <url>@."
14051405+ end;
14061406+ `Ok ()
14071407+ | Error e ->
14081408+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
14091409+ `Error (false, "fork failed")
14101410+ end
14111411+ in
14121412+ Cmd.v info Term.(ret (const run $ name_arg $ url_arg $ dry_run_arg $ yes_arg $ logging_term))
14131413+14141414+(* Join command *)
14151415+14161416+let join_cmd =
14171417+ let doc = "Bring an external repository into the monorepo" in
14181418+ let man =
14191419+ [
14201420+ `S Manpage.s_description;
14211421+ `P
14221422+ "Clones an external git repository and adds it as a subtree in the \
14231423+ monorepo. This is the inverse of $(b,monopam fork).";
14241424+ `S "JOIN MODES";
14251425+ `P "The join command handles multiple scenarios:";
14261426+ `I ("URL join", "Clone from a git URL and add as subtree (default).");
14271427+ `I ("Local directory join", "Import from a local filesystem path. If the \
14281428+ path is a git repo, uses it directly. If not, initializes a new repo.");
14291429+ `I ("Verse join", "Join from a verse member's repository using $(b,--from).");
14301430+ `S "WHAT IT DOES";
14311431+ `P "The join command:";
14321432+ `I ("1.", "Analyzes the source (URL or local path)");
14331433+ `I ("2.", "Builds an action plan and shows discovery details");
14341434+ `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
14351435+ `I ("4.", "Clones/copies the repository to src/<name>/");
14361436+ `I ("5.", "Uses $(b,git subtree add) to bring into monorepo");
14371437+ `I ("6.", "Updates sources.toml with $(b,origin = \"join\")");
14381438+ `S "JOINING FROM VERSE";
14391439+ `P "To join a package from a verse member, use $(b,--from):";
14401440+ `Pre "monopam join --from avsm.bsky.social --url git@github.com:me/cohttp.git cohttp";
14411441+ `P "This will:";
14421442+ `I ("-", "Look up the package in their opam-repo");
14431443+ `I ("-", "Find all packages from the same git repository");
14441444+ `I ("-", "Create opam entries pointing to your fork");
14451445+ `I ("-", "Clone and add the subtree");
14461446+ `S "AFTER JOINING";
14471447+ `P "After joining, work with the subtree normally:";
14481448+ `I ("1.", "Make changes in mono/<name>/");
14491449+ `I ("2.", "Commit in mono/");
14501450+ `I ("3.", "Run $(b,monopam sync --remote) to push upstream");
14511451+ `S Manpage.s_examples;
14521452+ `P "Join a repository:";
14531453+ `Pre "monopam join https://github.com/someone/some-lib";
14541454+ `P "Join from a local directory:";
14551455+ `Pre "monopam join /path/to/local/repo --as my-lib";
14561456+ `P "Join with explicit name using --url:";
14571457+ `Pre "monopam join --url https://tangled.org/handle/sortal sortal";
14581458+ `P "Join with a custom name using --as:";
14591459+ `Pre "monopam join https://github.com/someone/some-lib --as my-lib";
14601460+ `P "Join with upstream tracking (for forks):";
14611461+ `Pre "monopam join https://github.com/me/cohttp --upstream https://github.com/mirage/cohttp";
14621462+ `P "Join from a verse member:";
14631463+ `Pre "monopam join cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
14641464+ `P "Preview what would be done:";
14651465+ `Pre "monopam join https://github.com/someone/lib --dry-run";
14661466+ `P "Join without confirmation:";
14671467+ `Pre "monopam join https://github.com/someone/lib --yes";
14681468+ ]
14691469+ in
14701470+ let info = Cmd.info "join" ~doc ~man in
14711471+ let url_or_pkg_arg =
14721472+ let doc = "Git URL, local path, or subtree name (when using --url)" in
14731473+ Arg.(required & pos 0 (some string) None & info [] ~docv:"SOURCE" ~doc)
14741474+ in
14751475+ let as_arg =
14761476+ let doc = "Override subtree directory name" in
14771477+ Arg.(value & opt (some string) None & info [ "as" ] ~docv:"NAME" ~doc)
14781478+ in
14791479+ let upstream_arg =
14801480+ let doc = "Original upstream URL (for tracking forks)" in
14811481+ Arg.(value & opt (some string) None & info [ "upstream" ] ~docv:"URL" ~doc)
14821482+ in
14831483+ let from_arg =
14841484+ let doc = "Verse member handle to join from (requires --url)" in
14851485+ Arg.(value & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc)
14861486+ in
14871487+ let fork_url_arg =
14881488+ let doc = "Git URL to clone from (makes positional arg the subtree name)" in
14891489+ Arg.(value & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc)
14901490+ in
14911491+ let dry_run_arg =
14921492+ let doc = "Show what would be done without making changes" in
14931493+ Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
14941494+ in
14951495+ let yes_arg =
14961496+ let doc = "Assume yes to all prompts (for automation)" in
14971497+ Arg.(value & flag & info [ "yes"; "y" ] ~doc)
14981498+ in
14991499+ let run url_or_pkg as_name upstream from fork_url dry_run yes () =
15001500+ Eio_main.run @@ fun env ->
15011501+ with_verse_config env @@ fun config ->
15021502+ let fs = Eio.Stdenv.fs env in
15031503+ let proc = Eio.Stdenv.process_mgr env in
15041504+ match from with
15051505+ | Some handle ->
15061506+ (* Join from verse member - requires --url for your fork *)
15071507+ (* Uses legacy API as it involves verse-specific operations *)
15081508+ (match fork_url with
15091509+ | None ->
15101510+ Fmt.epr "Error: --url is required when using --from@.";
15111511+ `Error (false, "--url required")
15121512+ | Some fork_url ->
15131513+ match Monopam.Fork_join.join_from_verse ~proc ~fs ~config ~verse_config:config
15141514+ ~package:url_or_pkg ~handle ~fork_url ~dry_run () with
15151515+ | Ok result ->
15161516+ if dry_run then begin
15171517+ Fmt.pr "Would join '%s' from %s:@." result.name (Option.value ~default:"verse" result.from_handle);
15181518+ Fmt.pr " Source: %s@." result.source_url;
15191519+ Option.iter (fun u -> Fmt.pr " Upstream: %s@." u) result.upstream_url;
15201520+ Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_added
15211521+ end else begin
15221522+ Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
15231523+ Fmt.pr "@.Next steps:@.";
15241524+ Fmt.pr " 1. Commit the opam changes: cd opam-repo && git add -A && git commit@.";
15251525+ Fmt.pr " 2. Run $(b,monopam sync) to synchronize@."
15261526+ end;
15271527+ `Ok ()
15281528+ | Error e ->
15291529+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
15301530+ `Error (false, "join failed"))
15311531+ | None ->
15321532+ (* Normal join from URL or local path - use plan-based workflow *)
15331533+ let source = match fork_url with Some u -> u | None -> url_or_pkg in
15341534+ let name = match fork_url with Some _ -> Some url_or_pkg | None -> as_name in
15351535+ (* Build the plan *)
15361536+ match Monopam.Fork_join.plan_join ~proc ~fs ~config ~source ?name ?upstream ~dry_run () with
15371537+ | Error e ->
15381538+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
15391539+ `Error (false, "join failed")
15401540+ | Ok plan ->
15411541+ (* Print discovery and actions *)
15421542+ let is_local = Monopam.Fork_join.is_local_path source in
15431543+ Fmt.pr "Analyzing join request...@.@.";
15441544+ Fmt.pr "Discovery:@.";
15451545+ Fmt.pr " Source: %s (%s)@." source
15461546+ (if is_local then "local directory" else "remote URL");
15471547+ Fmt.pr "%a" Monopam.Fork_join.pp_discovery plan.discovery;
15481548+ Fmt.pr "@.Actions to perform:@.";
15491549+ List.iteri (fun i action ->
15501550+ Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
15511551+ ) plan.actions;
15521552+ Fmt.pr "@.";
15531553+ (* Prompt for confirmation unless --yes or --dry-run *)
15541554+ let proceed =
15551555+ if dry_run then begin
15561556+ Fmt.pr "(dry-run mode - no changes will be made)@.";
15571557+ true
15581558+ end else if yes then
15591559+ true
15601560+ else
15611561+ confirm "Proceed?"
15621562+ in
15631563+ if not proceed then begin
15641564+ Fmt.pr "Cancelled.@.";
15651565+ `Ok ()
15661566+ end else begin
15671567+ (* Execute the plan *)
15681568+ match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with
15691569+ | Ok result ->
15701570+ if not dry_run then begin
15711571+ Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
15721572+ Fmt.pr "@.Next steps:@.";
15731573+ Fmt.pr " 1. Run $(b,monopam sync) to synchronize@."
15741574+ end;
15751575+ `Ok ()
15761576+ | Error e ->
15771577+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
15781578+ `Error (false, "join failed")
15791579+ end
15801580+ in
15811581+ Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ yes_arg $ logging_term))
15821582+15831583+(* Rejoin command *)
15841584+15851585+let rejoin_cmd =
15861586+ let doc = "Add a source checkout back into the monorepo as a subtree" in
15871587+ let man =
15881588+ [
15891589+ `S Manpage.s_description;
15901590+ `P
15911591+ "Adds an existing src/<name>/ repository back into mono/<name>/ as a \
15921592+ subtree. This is useful after forking a package and removing it from \
15931593+ the monorepo with $(b,git rm).";
15941594+ `S "WORKFLOW";
15951595+ `P "Typical workflow for removing and re-adding a package:";
15961596+ `I ("1.", "Fork the package: $(b,monopam fork my-lib)");
15971597+ `I ("2.", "Remove from monorepo: $(b,git rm -r mono/my-lib && git commit)");
15981598+ `I ("3.", "Work on it in src/my-lib/");
15991599+ `I ("4.", "Re-add to monorepo: $(b,monopam rejoin my-lib)");
16001600+ `S "REQUIREMENTS";
16011601+ `P "For rejoin to work:";
16021602+ `I ("-", "src/<name>/ must exist and be a git repository");
16031603+ `I ("-", "mono/<name>/ must NOT exist (was removed)");
16041604+ `S "WHAT IT DOES";
16051605+ `P "The rejoin command:";
16061606+ `I ("1.", "Verifies src/<name>/ exists and is a git repo");
16071607+ `I ("2.", "Verifies mono/<name>/ does not exist");
16081608+ `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
16091609+ `I ("4.", "Uses $(b,git subtree add) to bring src/<name>/ into mono/<name>/");
16101610+ `S Manpage.s_examples;
16111611+ `P "Re-add a package from src/:";
16121612+ `Pre "monopam rejoin my-lib";
16131613+ `P "Preview what would be done:";
16141614+ `Pre "monopam rejoin my-lib --dry-run";
16151615+ `P "Rejoin without confirmation:";
16161616+ `Pre "monopam rejoin my-lib --yes";
16171617+ ]
16181618+ in
16191619+ let info = Cmd.info "rejoin" ~doc ~man in
16201620+ let name_arg =
16211621+ let doc = "Name of the subtree to rejoin (directory name under src/)" in
16221622+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
16231623+ in
16241624+ let dry_run_arg =
16251625+ let doc = "Show what would be done without making changes" in
16261626+ Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
16271627+ in
16281628+ let yes_arg =
16291629+ let doc = "Assume yes to all prompts (for automation)" in
16301630+ Arg.(value & flag & info [ "yes"; "y" ] ~doc)
16311631+ in
16321632+ let run name dry_run yes () =
16331633+ Eio_main.run @@ fun env ->
16341634+ with_verse_config env @@ fun config ->
16351635+ let fs = Eio.Stdenv.fs env in
16361636+ let proc = Eio.Stdenv.process_mgr env in
16371637+ (* Build the plan *)
16381638+ match Monopam.Fork_join.plan_rejoin ~proc ~fs ~config ~name ~dry_run () with
16391639+ | Error e ->
16401640+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
16411641+ `Error (false, "rejoin failed")
16421642+ | Ok plan ->
16431643+ (* Print discovery and actions *)
16441644+ Fmt.pr "Analyzing rejoin request for '%s'...@.@." name;
16451645+ Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery;
16461646+ Fmt.pr "@.Actions to perform:@.";
16471647+ List.iteri (fun i action ->
16481648+ Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
16491649+ ) plan.actions;
16501650+ Fmt.pr "@.";
16511651+ (* Prompt for confirmation unless --yes or --dry-run *)
16521652+ let proceed =
16531653+ if dry_run then begin
16541654+ Fmt.pr "(dry-run mode - no changes will be made)@.";
16551655+ true
16561656+ end else if yes then
16571657+ true
16581658+ else
16591659+ confirm "Proceed?"
16601660+ in
16611661+ if not proceed then begin
16621662+ Fmt.pr "Cancelled.@.";
16631663+ `Ok ()
16641664+ end else begin
16651665+ (* Execute the plan *)
16661666+ match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with
16671667+ | Ok result ->
16681668+ if not dry_run then begin
16691669+ Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
16701670+ Fmt.pr "@.Next steps:@.";
16711671+ Fmt.pr " 1. Commit the changes: git add -A && git commit@.";
16721672+ Fmt.pr " 2. Run $(b,monopam sync) to synchronize@."
16731673+ end;
16741674+ `Ok ()
16751675+ | Error e ->
16761676+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
16771677+ `Error (false, "rejoin failed")
16781678+ end
16791679+ in
16801680+ Cmd.v info Term.(ret (const run $ name_arg $ dry_run_arg $ yes_arg $ logging_term))
16811681+16821682+(* Site command *)
16831683+16841684+let site_cmd =
16851685+ let doc = "Generate a static HTML site representing the monoverse map" in
16861686+ let man =
16871687+ [
16881688+ `S Manpage.s_description;
16891689+ `P
16901690+ "Generates a static index.html file that maps the monoverse, showing all \
16911691+ verse members, their packages, and the relationships between them.";
16921692+ `S "OUTPUT";
16931693+ `P "The generated site includes:";
16941694+ `I ("Members", "All verse members with links to their monorepo and opam repos");
16951695+ `I ("Summary", "Overview of common libraries and member-specific packages");
16961696+ `I ("Repository Details", "Each shared repo with packages and fork status");
16971697+ `S "FORK STATUS";
16981698+ `P "Use $(b,--status) to include fork relationship information:";
16991699+ `I ("+N", "You are N commits ahead of them");
17001700+ `I ("-N", "They are N commits ahead of you");
17011701+ `I ("+N/-M", "Diverged: you have N new, they have M new");
17021702+ `I ("sync", "Same commit");
17031703+ `S "DESIGN";
17041704+ `P "The HTML is designed to be:";
17051705+ `I ("-", "Simple and clean with a 10pt font");
17061706+ `I ("-", "Responsive and compact");
17071707+ `I ("-", "External links marked with icon and teal color");
17081708+ `S Manpage.s_examples;
17091709+ `P "Generate site to default location (mono/index.html):";
17101710+ `Pre "monopam site";
17111711+ `P "Generate site with fork status (slower, fetches remotes):";
17121712+ `Pre "monopam site --status";
17131713+ `P "Generate site to custom location:";
17141714+ `Pre "monopam site -o /var/www/monoverse/index.html";
17151715+ `P "Print HTML to stdout:";
17161716+ `Pre "monopam site --stdout";
17171717+ ]
17181718+ in
17191719+ let info = Cmd.info "site" ~doc ~man in
17201720+ let output_arg =
17211721+ let doc = "Output file path. Defaults to mono/index.html." in
17221722+ Arg.(value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc)
17231723+ in
17241724+ let stdout_arg =
17251725+ let doc = "Print HTML to stdout instead of writing to file." in
17261726+ Arg.(value & flag & info [ "stdout" ] ~doc)
17271727+ in
17281728+ let status_arg =
17291729+ let doc = "Include fork status (ahead/behind) for each repository. \
17301730+ This fetches from remotes and may be slower." in
17311731+ Arg.(value & flag & info [ "status"; "s" ] ~doc)
17321732+ in
17331733+ let run output to_stdout with_status () =
17341734+ Eio_main.run @@ fun env ->
17351735+ with_config env @@ fun monopam_config ->
17361736+ with_verse_config env @@ fun verse_config ->
17371737+ let fs = Eio.Stdenv.fs env in
17381738+ let proc = Eio.Stdenv.process_mgr env in
17391739+ (* Pull/clone registry to get latest metadata *)
17401740+ Fmt.pr "Syncing registry...@.";
17411741+ let registry =
17421742+ match Monopam.Verse_registry.clone_or_pull ~proc ~fs:(fs :> _ Eio.Path.t) ~config:verse_config () with
17431743+ | Ok r -> r
17441744+ | Error msg ->
17451745+ Fmt.epr "Warning: Could not sync registry: %s@." msg;
17461746+ Monopam.Verse_registry.{ name = "opamverse"; description = None; members = [] }
17471747+ in
17481748+ (* Compute forks if --status is requested *)
17491749+ let forks =
17501750+ if with_status then begin
17511751+ Fmt.pr "Computing fork status...@.";
17521752+ Some (Monopam.Forks.compute ~proc ~fs:(fs :> _ Eio.Path.t)
17531753+ ~verse_config ~monopam_config ())
17541754+ end else None
17551755+ in
17561756+ if to_stdout then begin
17571757+ let html = Monopam.Site.generate ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry () in
17581758+ print_string html;
17591759+ `Ok ()
17601760+ end else begin
17611761+ let output_path =
17621762+ match output with
17631763+ | Some p -> (
17641764+ match Fpath.of_string p with
17651765+ | Ok fp -> fp
17661766+ | Error (`Msg _) -> Fpath.v p)
17671767+ | None -> Fpath.(Monopam.Verse_config.mono_path verse_config / "index.html")
17681768+ in
17691769+ match Monopam.Site.write ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry ~output_path () with
17701770+ | Ok () ->
17711771+ Fmt.pr "Site generated: %a@." Fpath.pp output_path;
17721772+ `Ok ()
17731773+ | Error msg ->
17741774+ Fmt.epr "Error: %s@." msg;
17751775+ `Error (false, "site generation failed")
17761776+ end
17771777+ in
17781778+ Cmd.v info Term.(ret (const run $ output_arg $ stdout_arg $ status_arg $ logging_term))
17791779+10411780(* Main command group *)
1042178110431782let main_cmd =
···10481787 `P
10491788 "Monopam synchronizes packages between an opam overlay repository, \
10501789 individual git checkouts, and a monorepo using git subtrees.";
17901790+ `P
17911791+ "Monopam is designed to run inside a devcontainer that provides a \
17921792+ consistent OCaml development environment with all required tools \
17931793+ pre-installed.";
10511794 `S "QUICK START";
10521052- `P "First time setup:";
17951795+ `P "Start by creating a devcontainer workspace:";
17961796+ `Pre
17971797+ "monopam devcontainer ~/tangled";
17981798+ `P "Inside the devcontainer, initialize your workspace:";
10531799 `Pre
10541054- "mkdir ~/tangled && cd ~/tangled\n\
10551055- monopam verse init --handle yourname.bsky.social\n\
18001800+ "cd ~/tangled\n\
18011801+ monopam init --handle yourname.bsky.social\n\
10561802 cd mono";
10571803 `P "Daily workflow:";
10581804 `Pre
···10651811 `P "Monopam manages three directory trees:";
10661812 `I
10671813 ( "mono/",
10681068- "The monorepo combining all packages as git subtrees. This is where you \
10691069- make changes." );
18141814+ "The monorepo combining all packages as git subtrees. This is where \
18151815+ you make changes." );
10701816 `I
10711817 ( "src/",
10721818 "Individual git checkouts of each unique repository. Used for review \
···10851831 `I
10861832 ( "4. monopam sync --remote",
10871833 "Sync again, including pushing to upstream git remotes" );
10881088- `P
10891089- "For finer control, use $(b,push) and $(b,pull) separately:";
18341834+ `P "For finer control over the sync phases:";
10901835 `I
10911091- ( "monopam push",
10921092- "Export monorepo changes to checkouts (for manual review/push)" );
18361836+ ( "monopam sync --skip-pull",
18371837+ "Export monorepo changes to checkouts only (skip fetching remotes)" );
10931838 `I
10941094- ( "monopam pull",
10951095- "Pull remote changes into monorepo (when you know there are no local changes)" );
18391839+ ( "monopam sync --skip-push",
18401840+ "Pull remote changes only (skip exporting local changes)" );
10961841 `S "CHECKING STATUS";
10971842 `P "Run $(b,monopam status) to see the state of all repositories:";
10981843 `I ("local:+N", "Your monorepo is N commits ahead of the checkout");
···11011846 `I ("remote:+N", "Your checkout is N commits ahead of upstream");
11021847 `I ("remote:-N", "Upstream is N commits ahead (run $(b,monopam sync))");
11031848 `S "COMMON TASKS";
11041104- `I ("Start fresh", "monopam verse init --handle you.bsky.social");
18491849+ `I ("Start fresh", "monopam init --handle you.bsky.social");
11051850 `I ("Check status", "monopam status");
11061851 `I ("Sync everything", "monopam sync");
11071852 `I ("Sync and push upstream", "monopam sync --remote");
11081853 `I ("Sync one package", "monopam sync <package-name>");
11091854 `S "CONFIGURATION";
11101855 `P
11111111- "Run $(b,monopam verse init --handle <handle>) to create a workspace. \
18561856+ "Run $(b,monopam init --handle <handle>) to create a workspace. \
11121857 Configuration is stored in ~/.config/monopam/opamverse.toml.";
11131858 `P "Workspace structure:";
11141859 `Pre
···11331878 in
11341879 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in
11351880 Cmd.group info
11361136- [ status_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd ]
18811881+ [ init_cmd; status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; fork_cmd; join_cmd; rejoin_cmd; devcontainer_cmd; site_cmd ]
1137188211381883let () = exit (Cmd.eval main_cmd)
···11(** Changelog generation for monopam.
2233- This module handles generating weekly and daily changelog entries using Claude AI
44- to analyze git commit history and produce user-facing change summaries.
33+ This module handles generating weekly and daily changelog entries using
44+ Claude AI to analyze git commit history and produce user-facing change
55+ summaries.
5667 Changes are stored in a .changes directory at the monorepo root:
78 - .changes/<repo_name>.json - weekly changelog entries
88- - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file per day per repo)
99+ - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file
1010+ per day per repo)
911 - .changes/YYYYMMDD.json - aggregated daily changes for broadcasting
10121113 {1 Submodules}
12141315 - {!Aggregated} - Types and I/O for aggregated daily changes (YYYYMMDD.json)
1414- - {!Daily} - Types and I/O for per-day-per-repo changes (repo-YYYY-MM-DD.json)
1616+ - {!Daily} - Types and I/O for per-day-per-repo changes
1717+ (repo-YYYY-MM-DD.json)
1518 - {!Query} - High-level query interface for changes *)
16192020+module Aggregated = Changes_aggregated
1721(** Re-export submodules for querying changes *)
1818-module Aggregated = Changes_aggregated
2222+1923module Daily = Changes_daily
2024module Query = Changes_query
21252222-type commit_range = {
2323- from_hash : string;
2424- to_hash : string;
2525- count : int;
2626-}
2626+type commit_range = { from_hash : string; to_hash : string; count : int }
27272828type weekly_entry = {
2929- week_start : string; (* ISO date YYYY-MM-DD, Monday *)
3030- week_end : string; (* ISO date YYYY-MM-DD, Sunday *)
3131- summary : string; (* One-line summary *)
2929+ week_start : string; (* ISO date YYYY-MM-DD, Monday *)
3030+ week_end : string; (* ISO date YYYY-MM-DD, Sunday *)
3131+ summary : string; (* One-line summary *)
3232 changes : string list; (* Bullet points *)
3333 commit_range : commit_range;
3434}
35353636type daily_entry = {
3737- date : string; (* ISO date YYYY-MM-DD *)
3838- hour : int; (* Hour of day 0-23 *)
3939- timestamp : Ptime.t; (* RFC3339 timestamp for precise ordering *)
4040- summary : string; (* One-line summary *)
3737+ date : string; (* ISO date YYYY-MM-DD *)
3838+ hour : int; (* Hour of day 0-23 *)
3939+ timestamp : Ptime.t; (* RFC3339 timestamp for precise ordering *)
4040+ summary : string; (* One-line summary *)
4141 changes : string list; (* Bullet points *)
4242 commit_range : commit_range;
4343 contributors : string list; (* List of contributors for this entry *)
4444 repo_url : string option; (* Upstream repository URL *)
4545}
46464747-type changes_file = {
4848- repository : string;
4949- entries : weekly_entry list;
5050-}
5151-5252-type daily_changes_file = {
5353- repository : string;
5454- entries : daily_entry list;
5555-}
4747+type changes_file = { repository : string; entries : weekly_entry list }
4848+type daily_changes_file = { repository : string; entries : daily_entry list }
56495750(** Mode for changelog generation *)
5851type mode = Weekly | Daily
···7265 { week_start; week_end; summary; changes; commit_range }
7366 in
7467 Jsont.Object.map ~kind:"weekly_entry" make
7575- |> Jsont.Object.mem "week_start" Jsont.string ~enc:(fun (e : weekly_entry) -> e.week_start)
7676- |> Jsont.Object.mem "week_end" Jsont.string ~enc:(fun (e : weekly_entry) -> e.week_end)
7777- |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : weekly_entry) -> e.summary)
7878- |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : weekly_entry) -> e.changes)
7979- |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : weekly_entry) -> e.commit_range)
6868+ |> Jsont.Object.mem "week_start" Jsont.string ~enc:(fun (e : weekly_entry) ->
6969+ e.week_start)
7070+ |> Jsont.Object.mem "week_end" Jsont.string ~enc:(fun (e : weekly_entry) ->
7171+ e.week_end)
7272+ |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : weekly_entry) ->
7373+ e.summary)
7474+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string)
7575+ ~enc:(fun (e : weekly_entry) -> e.changes)
7676+ |> Jsont.Object.mem "commit_range" commit_range_jsont
7777+ ~enc:(fun (e : weekly_entry) -> e.commit_range)
8078 |> Jsont.Object.finish
81798280let changes_file_jsont : changes_file Jsont.t =
8381 let make repository entries : changes_file = { repository; entries } in
8482 Jsont.Object.map ~kind:"changes_file" make
8585- |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : changes_file) -> f.repository)
8686- |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont) ~enc:(fun (f : changes_file) -> f.entries)
8383+ |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : changes_file) ->
8484+ f.repository)
8585+ |> Jsont.Object.mem "entries" (Jsont.list weekly_entry_jsont)
8686+ ~enc:(fun (f : changes_file) -> f.entries)
8787 |> Jsont.Object.finish
88888989let ptime_jsont =
9090- let enc t =
9191- Ptime.to_rfc3339 t ~tz_offset_s:0
9292- in
9090+ let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in
9391 let dec s =
9492 match Ptime.of_rfc3339 s with
9593 | Ok (t, _, _) -> t
···9896 Jsont.map ~dec ~enc Jsont.string
999710098let daily_entry_jsont : daily_entry Jsont.t =
101101- let make date hour timestamp summary changes commit_range contributors repo_url : daily_entry =
102102- { date; hour; timestamp; summary; changes; commit_range; contributors; repo_url }
9999+ let make date hour timestamp summary changes commit_range contributors
100100+ repo_url : daily_entry =
101101+ {
102102+ date;
103103+ hour;
104104+ timestamp;
105105+ summary;
106106+ changes;
107107+ commit_range;
108108+ contributors;
109109+ repo_url;
110110+ }
103111 in
104112 (* Default hour and timestamp for backwards compat when reading old files *)
105113 let default_hour = 0 in
106114 let default_timestamp = Ptime.epoch in
107115 Jsont.Object.map ~kind:"daily_entry" make
108116 |> Jsont.Object.mem "date" Jsont.string ~enc:(fun (e : daily_entry) -> e.date)
109109- |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun (e : daily_entry) -> e.hour)
110110- |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun (e : daily_entry) -> e.timestamp)
111111- |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : daily_entry) -> e.summary)
112112- |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun (e : daily_entry) -> e.changes)
113113- |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun (e : daily_entry) -> e.commit_range)
114114- |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun (e : daily_entry) -> e.contributors)
115115- |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun (e : daily_entry) -> e.repo_url)
117117+ |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour
118118+ ~enc:(fun (e : daily_entry) -> e.hour)
119119+ |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp
120120+ ~enc:(fun (e : daily_entry) -> e.timestamp)
121121+ |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun (e : daily_entry) ->
122122+ e.summary)
123123+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string)
124124+ ~enc:(fun (e : daily_entry) -> e.changes)
125125+ |> Jsont.Object.mem "commit_range" commit_range_jsont
126126+ ~enc:(fun (e : daily_entry) -> e.commit_range)
127127+ |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[]
128128+ ~enc:(fun (e : daily_entry) -> e.contributors)
129129+ |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None
130130+ ~enc:(fun (e : daily_entry) -> e.repo_url)
116131 |> Jsont.Object.finish
117132118133let daily_changes_file_jsont : daily_changes_file Jsont.t =
119134 let make repository entries : daily_changes_file = { repository; entries } in
120135 Jsont.Object.map ~kind:"daily_changes_file" make
121121- |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun (f : daily_changes_file) -> f.repository)
122122- |> Jsont.Object.mem "entries" (Jsont.list daily_entry_jsont) ~enc:(fun (f : daily_changes_file) -> f.entries)
136136+ |> Jsont.Object.mem "repository" Jsont.string
137137+ ~enc:(fun (f : daily_changes_file) -> f.repository)
138138+ |> Jsont.Object.mem "entries" (Jsont.list daily_entry_jsont)
139139+ ~enc:(fun (f : daily_changes_file) -> f.entries)
123140 |> Jsont.Object.finish
124141125142(* File I/O *)
···134151135152(* Load weekly changes from .changes/<repo>.json in monorepo *)
136153let load ~fs ~monorepo repo_name =
137137- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ ".json")) in
154154+ let file_path =
155155+ Eio.Path.(
156156+ fs / Fpath.to_string monorepo / ".changes" / (repo_name ^ ".json"))
157157+ in
138158 match Eio.Path.kind ~follow:true file_path with
139159 | `Regular_file -> (
140160 let content = Eio.Path.load file_path in
141161 match Jsont_bytesrw.decode_string changes_file_jsont content with
142162 | Ok cf -> Ok cf
143143- | Error e -> Error (Format.sprintf "Failed to parse %s.json: %s" repo_name e))
163163+ | Error e ->
164164+ Error (Format.sprintf "Failed to parse %s.json: %s" repo_name e))
144165 | _ -> Ok { repository = repo_name; entries = [] }
145166 | exception Eio.Io _ -> Ok { repository = repo_name; entries = [] }
146167147168(* Save weekly changes to .changes/<repo>.json in monorepo *)
148169let save ~fs ~monorepo (cf : changes_file) =
149170 ensure_changes_dir ~fs monorepo;
150150- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ ".json")) in
151151- match Jsont_bytesrw.encode_string ~format:Jsont.Indent changes_file_jsont cf with
171171+ let file_path =
172172+ Eio.Path.(
173173+ fs / Fpath.to_string monorepo / ".changes" / (cf.repository ^ ".json"))
174174+ in
175175+ match
176176+ Jsont_bytesrw.encode_string ~format:Jsont.Indent changes_file_jsont cf
177177+ with
152178 | Ok content ->
153179 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
154180 Ok ()
155155- | Error e -> Error (Format.sprintf "Failed to encode %s.json: %s" cf.repository e)
181181+ | Error e ->
182182+ Error (Format.sprintf "Failed to encode %s.json: %s" cf.repository e)
156183157184(* Filename for daily changes: <repo>-<YYYY-MM-DD>.json *)
158158-let daily_filename repo_name date =
159159- repo_name ^ "-" ^ date ^ ".json"
185185+let daily_filename repo_name date = repo_name ^ "-" ^ date ^ ".json"
160186161187(* Check if daily file exists on disk *)
162188let daily_exists ~fs ~monorepo ~date repo_name =
163189 let filename = daily_filename repo_name date in
164164- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in
190190+ let file_path =
191191+ Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename)
192192+ in
165193 match Eio.Path.kind ~follow:true file_path with
166194 | `Regular_file -> true
167195 | _ -> false
···170198(* Load daily changes from .changes/<repo>-<date>.json in monorepo *)
171199let load_daily ~fs ~monorepo ~date repo_name =
172200 let filename = daily_filename repo_name date in
173173- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in
201201+ let file_path =
202202+ Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename)
203203+ in
174204 match Eio.Path.kind ~follow:true file_path with
175205 | `Regular_file -> (
176206 let content = Eio.Path.load file_path in
···184214let save_daily ~fs ~monorepo ~date (cf : daily_changes_file) =
185215 ensure_changes_dir ~fs monorepo;
186216 let filename = daily_filename cf.repository date in
187187- let file_path = Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename) in
188188- match Jsont_bytesrw.encode_string ~format:Jsont.Indent daily_changes_file_jsont cf with
217217+ let file_path =
218218+ Eio.Path.(fs / Fpath.to_string monorepo / ".changes" / filename)
219219+ in
220220+ match
221221+ Jsont_bytesrw.encode_string ~format:Jsont.Indent daily_changes_file_jsont cf
222222+ with
189223 | Ok content ->
190224 Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
191225 Ok ()
···196230let to_markdown (cf : changes_file) =
197231 let buf = Buffer.create 1024 in
198232 Buffer.add_string buf (Printf.sprintf "# %s Changelog\n\n" cf.repository);
199199- List.iter (fun (entry : weekly_entry) ->
200200- Buffer.add_string buf (Printf.sprintf "## Week of %s to %s\n\n" entry.week_start entry.week_end);
201201- Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
202202- List.iter (fun change ->
203203- Buffer.add_string buf (Printf.sprintf "- %s\n" change))
204204- entry.changes;
205205- Buffer.add_string buf "\n")
233233+ List.iter
234234+ (fun (entry : weekly_entry) ->
235235+ Buffer.add_string buf
236236+ (Printf.sprintf "## Week of %s to %s\n\n" entry.week_start
237237+ entry.week_end);
238238+ Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
239239+ List.iter
240240+ (fun change -> Buffer.add_string buf (Printf.sprintf "- %s\n" change))
241241+ entry.changes;
242242+ Buffer.add_string buf "\n")
206243 cf.entries;
207244 Buffer.contents buf
208245209246let aggregate ~history (cfs : changes_file list) =
210247 (* Collect all entries from all files, tagged with repository *)
211248 let all_entries =
212212- List.concat_map (fun (cf : changes_file) ->
213213- List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries)
249249+ List.concat_map
250250+ (fun (cf : changes_file) ->
251251+ List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries)
214252 cfs
215253 in
216254 (* Sort by week_start descending *)
217217- let sorted = List.sort (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) ->
218218- String.compare e2.week_start e1.week_start) all_entries
255255+ let sorted =
256256+ List.sort
257257+ (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) ->
258258+ String.compare e2.week_start e1.week_start)
259259+ all_entries
219260 in
220261 (* Group by week *)
221262 let rec group_by_week acc current_week current_group = function
222263 | [] ->
223223- if current_group <> [] then (current_week, List.rev current_group) :: acc
264264+ if current_group <> [] then
265265+ (current_week, List.rev current_group) :: acc
224266 else acc
225267 | (repo, (entry : weekly_entry)) :: rest ->
226268 let week_key = entry.week_start ^ " to " ^ entry.week_end in
···230272 group_by_week
231273 ((current_week, List.rev current_group) :: acc)
232274 week_key
233233- [(repo, entry)]
275275+ [ (repo, entry) ]
234276 rest
235277 in
236278 let grouped = List.rev (group_by_week [] "" [] sorted) in
237279 (* Take only the requested number of weeks *)
238280 let limited =
239239- if history > 0 then
240240- List.filteri (fun i _ -> i < history) grouped
281281+ if history > 0 then List.filteri (fun i _ -> i < history) grouped
241282 else grouped
242283 in
243284 (* Generate markdown *)
244285 let buf = Buffer.create 4096 in
245286 Buffer.add_string buf "# Changelog\n\n";
246246- List.iter (fun (week_key, entries) ->
247247- Buffer.add_string buf (Printf.sprintf "## Week of %s\n\n" week_key);
248248- List.iter (fun (repo, (entry : weekly_entry)) ->
249249- Buffer.add_string buf (Printf.sprintf "### %s\n" repo);
250250- Buffer.add_string buf (Printf.sprintf "%s\n" entry.summary);
251251- List.iter (fun change ->
252252- Buffer.add_string buf (Printf.sprintf "- %s\n" change))
253253- entry.changes;
254254- Buffer.add_string buf "\n")
255255- entries)
287287+ List.iter
288288+ (fun (week_key, entries) ->
289289+ Buffer.add_string buf (Printf.sprintf "## Week of %s\n\n" week_key);
290290+ List.iter
291291+ (fun (repo, (entry : weekly_entry)) ->
292292+ Buffer.add_string buf (Printf.sprintf "### %s\n" repo);
293293+ Buffer.add_string buf (Printf.sprintf "%s\n" entry.summary);
294294+ List.iter
295295+ (fun change ->
296296+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
297297+ entry.changes;
298298+ Buffer.add_string buf "\n")
299299+ entries)
256300 limited;
257301 Buffer.contents buf
258302···266310 let q = day in
267311 let k = y mod 100 in
268312 let j = y / 100 in
269269- let h = (q + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) mod 7 in
313313+ let h = (q + (13 * (m + 1) / 5) + k + (k / 4) + (j / 4) - (2 * j)) mod 7 in
270314 (* Convert from Zeller's (0=Sat) to standard (0=Sun) *)
271271- ((h + 6) mod 7)
315315+ (h + 6) mod 7
272316273317let add_days (y, m, d) n =
274318 (* Simple day addition - handles month/year boundaries *)
···276320 match month with
277321 | 1 | 3 | 5 | 7 | 8 | 10 | 12 -> 31
278322 | 4 | 6 | 9 | 11 -> 30
279279- | 2 -> if (year mod 4 = 0 && year mod 100 <> 0) || year mod 400 = 0 then 29 else 28
323323+ | 2 ->
324324+ if (year mod 4 = 0 && year mod 100 <> 0) || year mod 400 = 0 then 29
325325+ else 28
280326 | _ -> 30
281327 in
282328 let rec loop y m d n =
···289335 let new_m = if m = 12 then 1 else m + 1 in
290336 let new_y = if m = 12 then y + 1 else y in
291337 loop new_y new_m 1 (n - remaining - 1)
292292- else (* n < 0 *)
293293- if d + n >= 1 then (y, m, d + n)
294294- else
295295- let new_m = if m = 1 then 12 else m - 1 in
296296- let new_y = if m = 1 then y - 1 else y in
297297- let dim = days_in_month new_y new_m in
298298- loop new_y new_m dim (n + d)
338338+ else if
339339+ (* n < 0 *)
340340+ d + n >= 1
341341+ then (y, m, d + n)
342342+ else
343343+ let new_m = if m = 1 then 12 else m - 1 in
344344+ let new_y = if m = 1 then y - 1 else y in
345345+ let dim = days_in_month new_y new_m in
346346+ loop new_y new_m dim (n + d)
299347 in
300348 loop y m d n
301349302302-let format_date (y, m, d) =
303303- Printf.sprintf "%04d-%02d-%02d" y m d
350350+let format_date (y, m, d) = Printf.sprintf "%04d-%02d-%02d" y m d
304351305352let week_of_date (y, m, d) =
306353 let dow = day_of_week y m d in
···330377let aggregate_daily ~history (cfs : daily_changes_file list) =
331378 (* Collect all entries from all files, tagged with repository *)
332379 let all_entries =
333333- List.concat_map (fun (cf : daily_changes_file) ->
334334- List.map (fun (e : daily_entry) -> (cf.repository, e)) cf.entries)
380380+ List.concat_map
381381+ (fun (cf : daily_changes_file) ->
382382+ List.map (fun (e : daily_entry) -> (cf.repository, e)) cf.entries)
335383 cfs
336384 in
337385 (* Sort by date descending *)
338338- let sorted = List.sort (fun (_, (e1 : daily_entry)) (_, (e2 : daily_entry)) ->
339339- String.compare e2.date e1.date) all_entries
386386+ let sorted =
387387+ List.sort
388388+ (fun (_, (e1 : daily_entry)) (_, (e2 : daily_entry)) ->
389389+ String.compare e2.date e1.date)
390390+ all_entries
340391 in
341392 (* Group by date *)
342393 let rec group_by_date acc current_date current_group = function
343394 | [] ->
344344- if current_group <> [] then (current_date, List.rev current_group) :: acc
395395+ if current_group <> [] then
396396+ (current_date, List.rev current_group) :: acc
345397 else acc
346398 | (repo, (entry : daily_entry)) :: rest ->
347399 if current_date = "" || current_date = entry.date then
···350402 group_by_date
351403 ((current_date, List.rev current_group) :: acc)
352404 entry.date
353353- [(repo, entry)]
405405+ [ (repo, entry) ]
354406 rest
355407 in
356408 let grouped = List.rev (group_by_date [] "" [] sorted) in
357409 (* Take only the requested number of days *)
358410 let limited =
359359- if history > 0 then
360360- List.filteri (fun i _ -> i < history) grouped
411411+ if history > 0 then List.filteri (fun i _ -> i < history) grouped
361412 else grouped
362413 in
363414 (* Generate markdown - only include repos with actual changes *)
364415 let buf = Buffer.create 4096 in
365416 Buffer.add_string buf "# Daily Changelog\n\n";
366366- List.iter (fun (date, entries) ->
367367- (* Filter out entries with empty changes - these are repos with no changes *)
368368- let entries_with_changes = List.filter (fun (_, (entry : daily_entry)) ->
369369- entry.changes <> []) entries
370370- in
371371- if entries_with_changes <> [] then begin
372372- Buffer.add_string buf (Printf.sprintf "## %s\n\n" date);
373373- List.iter (fun (repo, (entry : daily_entry)) ->
374374- (* Format repo name with link if URL available *)
375375- let repo_header = match entry.repo_url with
376376- | Some url -> Printf.sprintf "[%s](%s)" repo url
377377- | None -> repo
378378- in
379379- Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_header);
380380- Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
381381- List.iter (fun change ->
382382- Buffer.add_string buf (Printf.sprintf "- %s\n" change))
383383- entry.changes;
384384- (* Add contributors if any *)
385385- if entry.contributors <> [] then begin
386386- let contributors_str = String.concat ", " entry.contributors in
387387- Buffer.add_string buf (Printf.sprintf "\n*Contributors: %s*\n" contributors_str)
388388- end;
389389- Buffer.add_string buf "\n")
390390- entries_with_changes
391391- end)
417417+ List.iter
418418+ (fun (date, entries) ->
419419+ (* Filter out entries with empty changes - these are repos with no changes *)
420420+ let entries_with_changes =
421421+ List.filter
422422+ (fun (_, (entry : daily_entry)) -> entry.changes <> [])
423423+ entries
424424+ in
425425+ if entries_with_changes <> [] then begin
426426+ Buffer.add_string buf (Printf.sprintf "## %s\n\n" date);
427427+ List.iter
428428+ (fun (repo, (entry : daily_entry)) ->
429429+ (* Format repo name with link if URL available *)
430430+ let repo_header =
431431+ match entry.repo_url with
432432+ | Some url -> Printf.sprintf "[%s](%s)" repo url
433433+ | None -> repo
434434+ in
435435+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_header);
436436+ Buffer.add_string buf (Printf.sprintf "%s\n\n" entry.summary);
437437+ List.iter
438438+ (fun change ->
439439+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
440440+ entry.changes;
441441+ (* Add contributors if any *)
442442+ if entry.contributors <> [] then begin
443443+ let contributors_str = String.concat ", " entry.contributors in
444444+ Buffer.add_string buf
445445+ (Printf.sprintf "\n*Contributors: %s*\n" contributors_str)
446446+ end;
447447+ Buffer.add_string buf "\n")
448448+ entries_with_changes
449449+ end)
392450 limited;
393451 Buffer.contents buf
394452···396454397455let generate_weekly_prompt ~repository ~week_start ~week_end commits =
398456 let buf = Buffer.create 4096 in
399399- Buffer.add_string buf (Printf.sprintf
400400- "You are analyzing git commits for the OCaml library \"%s\".\n" repository);
401401- Buffer.add_string buf (Printf.sprintf
402402- "Generate a user-facing changelog entry for the week of %s to %s.\n\n"
403403- week_start week_end);
457457+ Buffer.add_string buf
458458+ (Printf.sprintf
459459+ "You are analyzing git commits for the OCaml library \"%s\".\n"
460460+ repository);
461461+ Buffer.add_string buf
462462+ (Printf.sprintf
463463+ "Generate a user-facing changelog entry for the week of %s to %s.\n\n"
464464+ week_start week_end);
404465 Buffer.add_string buf "## Commits this week:\n\n";
405405- List.iter (fun (commit : Git.log_entry) ->
406406- Buffer.add_string buf (Printf.sprintf "### %s by %s (%s)\n"
407407- (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
408408- commit.author commit.date);
409409- Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
410410- if commit.body <> "" then begin
411411- Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
412412- end;
413413- Buffer.add_string buf "---\n\n")
466466+ List.iter
467467+ (fun (commit : Git.log_entry) ->
468468+ Buffer.add_string buf
469469+ (Printf.sprintf "### %s by %s (%s)\n"
470470+ (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
471471+ commit.author commit.date);
472472+ Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
473473+ if commit.body <> "" then begin
474474+ Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
475475+ end;
476476+ Buffer.add_string buf "---\n\n")
414477 commits;
415415- Buffer.add_string buf {|## Instructions:
478478+ Buffer.add_string buf
479479+ {|## Instructions:
4164804174811. Focus on USER-FACING changes only. Skip:
418482 - Internal refactoring with no API impact
···445509446510let generate_daily_prompt ~repository ~date commits =
447511 let buf = Buffer.create 4096 in
448448- Buffer.add_string buf (Printf.sprintf
449449- "You are analyzing git commits for the OCaml library \"%s\".\n" repository);
450450- Buffer.add_string buf (Printf.sprintf
451451- "Generate a user-facing changelog entry for %s.\n\n" date);
512512+ Buffer.add_string buf
513513+ (Printf.sprintf
514514+ "You are analyzing git commits for the OCaml library \"%s\".\n"
515515+ repository);
516516+ Buffer.add_string buf
517517+ (Printf.sprintf "Generate a user-facing changelog entry for %s.\n\n" date);
452518 Buffer.add_string buf "## Commits today:\n\n";
453453- List.iter (fun (commit : Git.log_entry) ->
454454- Buffer.add_string buf (Printf.sprintf "### %s by %s (%s)\n"
455455- (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
456456- commit.author commit.date);
457457- Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
458458- if commit.body <> "" then begin
459459- Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
460460- end;
461461- Buffer.add_string buf "---\n\n")
519519+ List.iter
520520+ (fun (commit : Git.log_entry) ->
521521+ Buffer.add_string buf
522522+ (Printf.sprintf "### %s by %s (%s)\n"
523523+ (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
524524+ commit.author commit.date);
525525+ Buffer.add_string buf (Printf.sprintf "%s\n\n" commit.subject);
526526+ if commit.body <> "" then begin
527527+ Buffer.add_string buf (Printf.sprintf "%s\n" commit.body)
528528+ end;
529529+ Buffer.add_string buf "---\n\n")
462530 commits;
463463- Buffer.add_string buf {|## Instructions:
531531+ Buffer.add_string buf
532532+ {|## Instructions:
4645334655341. Focus on USER-FACING changes only. Skip:
466535 - Internal refactoring with no API impact
···496565497566(* Response parsing *)
498567499499-type claude_response = {
500500- summary : string;
501501- changes : string list;
502502-}
568568+type claude_response = { summary : string; changes : string list }
503569504570let claude_response_jsont =
505571 let make summary changes = { summary; changes } in
506572 Jsont.Object.map ~kind:"claude_response" make
507573 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun r -> r.summary)
508508- |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun r -> r.changes)
574574+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun r ->
575575+ r.changes)
509576 |> Jsont.Object.finish
510577511578let parse_claude_response text =
···516583 match Jsont_bytesrw.decode_string claude_response_jsont text with
517584 | Ok r ->
518585 (* Treat empty summary and changes as no changes *)
519519- if r.summary = "" && r.changes = [] then Ok None
520520- else Ok (Some r)
586586+ if r.summary = "" && r.changes = [] then Ok None else Ok (Some r)
521587 | Error e -> Error (Format.sprintf "Failed to parse Claude response: %s" e)
522588523589(* Main analysis function *)
524590525525-let analyze_commits
526526- ~sw
527527- ~process_mgr
528528- ~clock
529529- ~repository
530530- ~week_start
531531- ~week_end
591591+let analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end
532592 commits =
533593 if commits = [] then Ok None
534594 else begin
···537597 (* Create Claude options with structured output *)
538598 let output_schema =
539599 let open Jsont in
540540- Object ([
541541- (("type", Meta.none), String ("object", Meta.none));
542542- (("properties", Meta.none), Object ([
543543- (("summary", Meta.none), Object ([
544544- (("type", Meta.none), String ("string", Meta.none));
545545- ], Meta.none));
546546- (("changes", Meta.none), Object ([
547547- (("type", Meta.none), String ("array", Meta.none));
548548- (("items", Meta.none), Object ([
549549- (("type", Meta.none), String ("string", Meta.none));
550550- ], Meta.none));
551551- ], Meta.none));
552552- ], Meta.none));
553553- (("required", Meta.none), Array ([
554554- String ("summary", Meta.none);
555555- String ("changes", Meta.none);
556556- ], Meta.none));
557557- ], Meta.none)
600600+ Object
601601+ ( [
602602+ (("type", Meta.none), String ("object", Meta.none));
603603+ ( ("properties", Meta.none),
604604+ Object
605605+ ( [
606606+ ( ("summary", Meta.none),
607607+ Object
608608+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
609609+ Meta.none ) );
610610+ ( ("changes", Meta.none),
611611+ Object
612612+ ( [
613613+ (("type", Meta.none), String ("array", Meta.none));
614614+ ( ("items", Meta.none),
615615+ Object
616616+ ( [
617617+ ( ("type", Meta.none),
618618+ String ("string", Meta.none) );
619619+ ],
620620+ Meta.none ) );
621621+ ],
622622+ Meta.none ) );
623623+ ],
624624+ Meta.none ) );
625625+ ( ("required", Meta.none),
626626+ Array
627627+ ( [
628628+ String ("summary", Meta.none); String ("changes", Meta.none);
629629+ ],
630630+ Meta.none ) );
631631+ ],
632632+ Meta.none )
558633 in
559559- let output_format = Claude.Proto.Structured_output.of_json_schema output_schema in
634634+ let output_format =
635635+ Claude.Proto.Structured_output.of_json_schema output_schema
636636+ in
560637 let options =
561638 Claude.Options.default
562639 |> Claude.Options.with_output_format output_format
···568645569646 let responses = Claude.Client.receive_all client in
570647 let result = ref None in
571571- List.iter (function
572572- | Claude.Response.Complete c -> (
573573- match Claude.Response.Complete.structured_output c with
574574- | Some json -> (
575575- match Jsont.Json.decode claude_response_jsont json with
576576- | Ok r -> result := Some (Ok (Some r))
577577- | Error e ->
578578- result := Some (Error (Format.sprintf "Failed to decode response: %s" e)))
579579- | None ->
580580- (* Try to get text and parse it as fallback *)
581581- match Claude.Response.Complete.result_text c with
582582- | Some text -> result := Some (parse_claude_response text)
583583- | None -> result := Some (Ok None))
584584- | Claude.Response.Text t ->
585585- let text = Claude.Response.Text.content t in
586586- if String.trim text = "NO_CHANGES" then
587587- result := Some (Ok None)
588588- | Claude.Response.Error e ->
589589- result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e)))
590590- | _ -> ())
648648+ List.iter
649649+ (function
650650+ | Claude.Response.Complete c -> (
651651+ match Claude.Response.Complete.structured_output c with
652652+ | Some json -> (
653653+ match Jsont.Json.decode claude_response_jsont json with
654654+ | Ok r -> result := Some (Ok (Some r))
655655+ | Error e ->
656656+ result :=
657657+ Some
658658+ (Error
659659+ (Format.sprintf "Failed to decode response: %s" e)))
660660+ | None -> (
661661+ (* Try to get text and parse it as fallback *)
662662+ match Claude.Response.Complete.result_text c with
663663+ | Some text -> result := Some (parse_claude_response text)
664664+ | None -> result := Some (Ok None)))
665665+ | Claude.Response.Text t ->
666666+ let text = Claude.Response.Text.content t in
667667+ if String.trim text = "NO_CHANGES" then result := Some (Ok None)
668668+ | Claude.Response.Error e ->
669669+ result :=
670670+ Some
671671+ (Error
672672+ (Printf.sprintf "Claude error: %s"
673673+ (Claude.Response.Error.message e)))
674674+ | _ -> ())
591675 responses;
592676593593- match !result with
594594- | Some r -> r
595595- | None -> Ok None
677677+ match !result with Some r -> r | None -> Ok None
596678 end
597679598680(* Daily analysis function *)
599599-let analyze_commits_daily
600600- ~sw
601601- ~process_mgr
602602- ~clock
603603- ~repository
604604- ~date
605605- commits =
681681+let analyze_commits_daily ~sw ~process_mgr ~clock ~repository ~date commits =
606682 if commits = [] then Ok None
607683 else begin
608684 let prompt = generate_daily_prompt ~repository ~date commits in
···610686 (* Create Claude options with structured output *)
611687 let output_schema =
612688 let open Jsont in
613613- Object ([
614614- (("type", Meta.none), String ("object", Meta.none));
615615- (("properties", Meta.none), Object ([
616616- (("summary", Meta.none), Object ([
617617- (("type", Meta.none), String ("string", Meta.none));
618618- ], Meta.none));
619619- (("changes", Meta.none), Object ([
620620- (("type", Meta.none), String ("array", Meta.none));
621621- (("items", Meta.none), Object ([
622622- (("type", Meta.none), String ("string", Meta.none));
623623- ], Meta.none));
624624- ], Meta.none));
625625- ], Meta.none));
626626- (("required", Meta.none), Array ([
627627- String ("summary", Meta.none);
628628- String ("changes", Meta.none);
629629- ], Meta.none));
630630- ], Meta.none)
689689+ Object
690690+ ( [
691691+ (("type", Meta.none), String ("object", Meta.none));
692692+ ( ("properties", Meta.none),
693693+ Object
694694+ ( [
695695+ ( ("summary", Meta.none),
696696+ Object
697697+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
698698+ Meta.none ) );
699699+ ( ("changes", Meta.none),
700700+ Object
701701+ ( [
702702+ (("type", Meta.none), String ("array", Meta.none));
703703+ ( ("items", Meta.none),
704704+ Object
705705+ ( [
706706+ ( ("type", Meta.none),
707707+ String ("string", Meta.none) );
708708+ ],
709709+ Meta.none ) );
710710+ ],
711711+ Meta.none ) );
712712+ ],
713713+ Meta.none ) );
714714+ ( ("required", Meta.none),
715715+ Array
716716+ ( [
717717+ String ("summary", Meta.none); String ("changes", Meta.none);
718718+ ],
719719+ Meta.none ) );
720720+ ],
721721+ Meta.none )
722722+ in
723723+ let output_format =
724724+ Claude.Proto.Structured_output.of_json_schema output_schema
631725 in
632632- let output_format = Claude.Proto.Structured_output.of_json_schema output_schema in
633726 let options =
634727 Claude.Options.default
635728 |> Claude.Options.with_output_format output_format
···641734642735 let responses = Claude.Client.receive_all client in
643736 let result = ref None in
644644- List.iter (function
645645- | Claude.Response.Complete c -> (
646646- match Claude.Response.Complete.structured_output c with
647647- | Some json -> (
648648- match Jsont.Json.decode claude_response_jsont json with
649649- | Ok r ->
650650- (* Treat empty response as no changes *)
651651- if r.summary = "" && r.changes = [] then
652652- result := Some (Ok None)
653653- else
654654- result := Some (Ok (Some r))
655655- | Error e ->
656656- result := Some (Error (Format.sprintf "Failed to decode response: %s" e)))
657657- | None ->
658658- (* Try to get text and parse it as fallback *)
659659- match Claude.Response.Complete.result_text c with
660660- | Some text -> result := Some (parse_claude_response text)
661661- | None -> result := Some (Ok None))
662662- | Claude.Response.Text t ->
663663- let text = Claude.Response.Text.content t in
664664- if String.trim text = "NO_CHANGES" then
665665- result := Some (Ok None)
666666- | Claude.Response.Error e ->
667667- result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e)))
668668- | _ -> ())
737737+ List.iter
738738+ (function
739739+ | Claude.Response.Complete c -> (
740740+ match Claude.Response.Complete.structured_output c with
741741+ | Some json -> (
742742+ match Jsont.Json.decode claude_response_jsont json with
743743+ | Ok r ->
744744+ (* Treat empty response as no changes *)
745745+ if r.summary = "" && r.changes = [] then
746746+ result := Some (Ok None)
747747+ else result := Some (Ok (Some r))
748748+ | Error e ->
749749+ result :=
750750+ Some
751751+ (Error
752752+ (Format.sprintf "Failed to decode response: %s" e)))
753753+ | None -> (
754754+ (* Try to get text and parse it as fallback *)
755755+ match Claude.Response.Complete.result_text c with
756756+ | Some text -> result := Some (parse_claude_response text)
757757+ | None -> result := Some (Ok None)))
758758+ | Claude.Response.Text t ->
759759+ let text = Claude.Response.Text.content t in
760760+ if String.trim text = "NO_CHANGES" then result := Some (Ok None)
761761+ | Claude.Response.Error e ->
762762+ result :=
763763+ Some
764764+ (Error
765765+ (Printf.sprintf "Claude error: %s"
766766+ (Claude.Response.Error.message e)))
767767+ | _ -> ())
669768 responses;
670769671671- match !result with
672672- | Some r -> r
673673- | None -> Ok None
770770+ match !result with Some r -> r | None -> Ok None
674771 end
675772676773(* Refine daily changelog markdown to be more narrative *)
677677-let refine_daily_changelog
678678- ~sw
679679- ~process_mgr
680680- ~clock
681681- markdown =
682682- let prompt = Printf.sprintf {|You are editing a daily changelog for an OCaml monorepo.
774774+let refine_daily_changelog ~sw ~process_mgr ~clock markdown =
775775+ let prompt =
776776+ Printf.sprintf
777777+ {|You are editing a daily changelog for an OCaml monorepo.
683778684779Your task is to refine the following changelog to be:
6857801. More narrative and human-readable - write it as a daily update that developers will want to read
···705800706801%s
707802708708-Output ONLY the refined markdown, no explanation or preamble.|} markdown
803803+Output ONLY the refined markdown, no explanation or preamble.|}
804804+ markdown
709805 in
710806711711- let options =
712712- Claude.Options.default
713713- |> Claude.Options.with_max_turns 1
714714- in
807807+ let options = Claude.Options.default |> Claude.Options.with_max_turns 1 in
715808716809 let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
717810 Claude.Client.query client prompt;
718811719812 let responses = Claude.Client.receive_all client in
720813 let result = ref None in
721721- List.iter (function
722722- | Claude.Response.Complete c -> (
723723- match Claude.Response.Complete.result_text c with
724724- | Some text -> result := Some (Ok text)
725725- | None -> result := Some (Ok markdown)) (* fallback to original *)
726726- | Claude.Response.Error e ->
727727- result := Some (Error (Printf.sprintf "Claude error: %s" (Claude.Response.Error.message e)))
728728- | _ -> ())
814814+ List.iter
815815+ (function
816816+ | Claude.Response.Complete c -> (
817817+ match Claude.Response.Complete.result_text c with
818818+ | Some text -> result := Some (Ok text)
819819+ | None -> result := Some (Ok markdown) (* fallback to original *))
820820+ | Claude.Response.Error e ->
821821+ result :=
822822+ Some
823823+ (Error
824824+ (Printf.sprintf "Claude error: %s"
825825+ (Claude.Response.Error.message e)))
826826+ | _ -> ())
729827 responses;
730828731829 match !result with
···749847(* Infer change type from summary text *)
750848let infer_change_type summary =
751849 let summary_lower = String.lowercase_ascii summary in
752752- if String.starts_with ~prefix:"initial import" summary_lower ||
753753- String.starts_with ~prefix:"added as subtree" summary_lower ||
754754- String.starts_with ~prefix:"added" summary_lower && String.ends_with ~suffix:"library" summary_lower then
755755- Changes_aggregated.New_library
756756- else if List.exists (fun kw -> string_contains_s summary_lower kw)
757757- ["fix"; "bugfix"; "bug fix"; "repair"; "patch"; "resolve"; "correct"] then
758758- Changes_aggregated.Bugfix
759759- else if List.exists (fun kw -> string_contains_s summary_lower kw)
760760- ["refactor"; "cleanup"; "clean up"; "reorganize"; "restructure"; "simplify"] then
761761- Changes_aggregated.Refactor
762762- else if List.exists (fun kw -> string_contains_s summary_lower kw)
763763- ["doc"; "documentation"; "readme"; "comment"; "tutorial"; "guide"] then
764764- Changes_aggregated.Documentation
765765- else if List.exists (fun kw -> string_contains_s summary_lower kw)
766766- ["add"; "new"; "feature"; "implement"; "support"; "introduce"; "enable"] then
767767- Changes_aggregated.Feature
768768- else
769769- Changes_aggregated.Unknown
850850+ if
851851+ String.starts_with ~prefix:"initial import" summary_lower
852852+ || String.starts_with ~prefix:"added as subtree" summary_lower
853853+ || String.starts_with ~prefix:"added" summary_lower
854854+ && String.ends_with ~suffix:"library" summary_lower
855855+ then Changes_aggregated.New_library
856856+ else if
857857+ List.exists
858858+ (fun kw -> string_contains_s summary_lower kw)
859859+ [ "fix"; "bugfix"; "bug fix"; "repair"; "patch"; "resolve"; "correct" ]
860860+ then Changes_aggregated.Bugfix
861861+ else if
862862+ List.exists
863863+ (fun kw -> string_contains_s summary_lower kw)
864864+ [
865865+ "refactor";
866866+ "cleanup";
867867+ "clean up";
868868+ "reorganize";
869869+ "restructure";
870870+ "simplify";
871871+ ]
872872+ then Changes_aggregated.Refactor
873873+ else if
874874+ List.exists
875875+ (fun kw -> string_contains_s summary_lower kw)
876876+ [ "doc"; "documentation"; "readme"; "comment"; "tutorial"; "guide" ]
877877+ then Changes_aggregated.Documentation
878878+ else if
879879+ List.exists
880880+ (fun kw -> string_contains_s summary_lower kw)
881881+ [ "add"; "new"; "feature"; "implement"; "support"; "introduce"; "enable" ]
882882+ then Changes_aggregated.Feature
883883+ else Changes_aggregated.Unknown
770884771771-(** Generate an aggregated daily file from individual daily json files.
772772- This creates a YYYYMMDD.json file in the .changes directory. *)
885885+(** Generate an aggregated daily file from individual daily json files. This
886886+ creates a YYYYMMDD.json file in the .changes directory. *)
773887let generate_aggregated ~fs ~monorepo ~date ~git_head ~now =
774888 let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in
775889776890 (* List all *-<date>.json files (new per-day format) *)
777777- let files =
778778- try Eio.Path.read_dir changes_dir
779779- with Eio.Io _ -> []
780780- in
891891+ let files = try Eio.Path.read_dir changes_dir with Eio.Io _ -> [] in
781892 (* Match files like "<repo>-2026-01-19.json" for the given date *)
782893 let date_suffix = "-" ^ date ^ ".json" in
783894 let date_suffix_len = String.length date_suffix in
784784- let daily_files = List.filter (fun f ->
785785- String.ends_with ~suffix:date_suffix f && String.length f > date_suffix_len) files
895895+ let daily_files =
896896+ List.filter
897897+ (fun f ->
898898+ String.ends_with ~suffix:date_suffix f
899899+ && String.length f > date_suffix_len)
900900+ files
786901 in
787902788903 (* Load all daily files for this date and collect entries *)
789789- let entries = List.concat_map (fun filename ->
790790- (* Extract repo name: filename is "<repo>-<date>.json" *)
791791- let repo_name = String.sub filename 0 (String.length filename - date_suffix_len) in
792792- let path = Eio.Path.(changes_dir / filename) in
793793- try
794794- let content = Eio.Path.load path in
795795- match Jsont_bytesrw.decode_string daily_changes_file_jsont content with
796796- | Ok dcf ->
797797- List.filter_map (fun (e : daily_entry) ->
798798- if e.changes <> [] then
799799- Some (repo_name, e)
800800- else
801801- None) dcf.entries
802802- | Error _ -> []
803803- with Eio.Io _ -> []
804804- ) daily_files in
904904+ let entries =
905905+ List.concat_map
906906+ (fun filename ->
907907+ (* Extract repo name: filename is "<repo>-<date>.json" *)
908908+ let repo_name =
909909+ String.sub filename 0 (String.length filename - date_suffix_len)
910910+ in
911911+ let path = Eio.Path.(changes_dir / filename) in
912912+ try
913913+ let content = Eio.Path.load path in
914914+ match
915915+ Jsont_bytesrw.decode_string daily_changes_file_jsont content
916916+ with
917917+ | Ok dcf ->
918918+ List.filter_map
919919+ (fun (e : daily_entry) ->
920920+ if e.changes <> [] then Some (repo_name, e) else None)
921921+ dcf.entries
922922+ | Error _ -> []
923923+ with Eio.Io _ -> [])
924924+ daily_files
925925+ in
805926806927 (* Convert to aggregated format *)
807807- let agg_entries = List.map (fun (repo_name, (e : daily_entry)) ->
808808- let change_type = infer_change_type e.summary in
809809- Changes_aggregated.{
810810- repository = repo_name;
811811- hour = e.hour;
812812- timestamp = e.timestamp;
813813- summary = e.summary;
814814- changes = e.changes;
815815- commit_range = {
816816- from_hash = e.commit_range.from_hash;
817817- to_hash = e.commit_range.to_hash;
818818- count = e.commit_range.count;
819819- };
820820- contributors = e.contributors;
821821- repo_url = e.repo_url;
822822- change_type;
823823- }) entries
928928+ let agg_entries =
929929+ List.map
930930+ (fun (repo_name, (e : daily_entry)) ->
931931+ let change_type = infer_change_type e.summary in
932932+ Changes_aggregated.
933933+ {
934934+ repository = repo_name;
935935+ hour = e.hour;
936936+ timestamp = e.timestamp;
937937+ summary = e.summary;
938938+ changes = e.changes;
939939+ commit_range =
940940+ {
941941+ from_hash = e.commit_range.from_hash;
942942+ to_hash = e.commit_range.to_hash;
943943+ count = e.commit_range.count;
944944+ };
945945+ contributors = e.contributors;
946946+ repo_url = e.repo_url;
947947+ change_type;
948948+ })
949949+ entries
824950 in
825951826952 (* Collect all unique authors *)
···831957 in
832958833959 (* Create the aggregated structure *)
834834- let aggregated : Changes_aggregated.t = {
835835- date;
836836- generated_at = now;
837837- git_head;
838838- entries = agg_entries;
839839- authors;
840840- } in
960960+ let aggregated : Changes_aggregated.t =
961961+ { date; generated_at = now; git_head; entries = agg_entries; authors }
962962+ in
841963842964 (* Save to YYYYMMDD.json *)
843965 let changes_dir_fpath = Fpath.(v (Fpath.to_string monorepo) / ".changes") in
+90-78
lib/changes.mli
···11(** Changelog generation for monopam.
2233- This module handles generating weekly and daily changelog entries using Claude AI
44- to analyze git commit history and produce user-facing change summaries.
33+ This module handles generating weekly and daily changelog entries using
44+ Claude AI to analyze git commit history and produce user-facing change
55+ summaries.
5667 Changes are stored in a .changes directory at the monorepo root:
78 - .changes/<repo_name>.json - weekly changelog entries
88- - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file per day per repo)
99+ - .changes/<repo_name>-<YYYY-MM-DD>.json - daily changelog entries (one file
1010+ per day per repo)
911 - .changes/YYYYMMDD.json - aggregated daily changes for broadcasting
10121113 {1 Submodules}
12141313- These modules provide types and I/O for querying the generated changes files. *)
1515+ These modules provide types and I/O for querying the generated changes
1616+ files. *)
14171515-(** Aggregated daily changes format (YYYYMMDD.json files). *)
1618module Aggregated = Changes_aggregated
1919+(** Aggregated daily changes format (YYYYMMDD.json files). *)
17201818-(** Daily changes with per-day-per-repo structure (repo-YYYY-MM-DD.json files). *)
1921module Daily = Changes_daily
2222+(** Daily changes with per-day-per-repo structure (repo-YYYY-MM-DD.json files).
2323+*)
20242121-(** High-level query interface for changes. *)
2225module Query = Changes_query
2626+(** High-level query interface for changes. *)
23272428(** {1 Types} *)
25292626-type commit_range = {
2727- from_hash : string;
2828- to_hash : string;
2929- count : int;
3030-}
3030+type commit_range = { from_hash : string; to_hash : string; count : int }
3131(** Range of commits included in a changelog entry. *)
32323333type weekly_entry = {
3434 week_start : string; (** ISO date YYYY-MM-DD, Monday *)
3535- week_end : string; (** ISO date YYYY-MM-DD, Sunday *)
3636- summary : string; (** One-line summary *)
3737- changes : string list; (** Bullet points *)
3535+ week_end : string; (** ISO date YYYY-MM-DD, Sunday *)
3636+ summary : string; (** One-line summary *)
3737+ changes : string list; (** Bullet points *)
3838 commit_range : commit_range;
3939}
4040(** A single week's changelog entry. *)
41414242type daily_entry = {
4343- date : string; (** ISO date YYYY-MM-DD *)
4444- hour : int; (** Hour of day 0-23 for filtering *)
4343+ date : string; (** ISO date YYYY-MM-DD *)
4444+ hour : int; (** Hour of day 0-23 for filtering *)
4545 timestamp : Ptime.t; (** RFC3339 timestamp for precise ordering *)
4646- summary : string; (** One-line summary *)
4747- changes : string list; (** Bullet points *)
4646+ summary : string; (** One-line summary *)
4747+ changes : string list; (** Bullet points *)
4848 commit_range : commit_range;
4949- contributors : string list; (** List of contributors for this entry *)
5050- repo_url : string option; (** Upstream repository URL *)
4949+ contributors : string list; (** List of contributors for this entry *)
5050+ repo_url : string option; (** Upstream repository URL *)
5151}
5252(** A single day's changelog entry with hour tracking for real-time updates. *)
53535454-type changes_file = {
5555- repository : string;
5656- entries : weekly_entry list;
5757-}
5454+type changes_file = { repository : string; entries : weekly_entry list }
5855(** Contents of a weekly changes JSON file for a repository. *)
59566060-type daily_changes_file = {
6161- repository : string;
6262- entries : daily_entry list;
6363-}
5757+type daily_changes_file = { repository : string; entries : daily_entry list }
6458(** Contents of a daily changes JSON file for a repository. *)
65596660(** Mode for changelog generation. *)
···85798680(** {1 File I/O} *)
87818888-val load : fs:_ Eio.Path.t -> monorepo:Fpath.t -> string -> (changes_file, string) result
8989-(** [load ~fs ~monorepo repo_name] loads weekly changes from .changes/<repo_name>.json.
9090- Returns an empty changes file if the file does not exist. *)
8282+val load :
8383+ fs:_ Eio.Path.t -> monorepo:Fpath.t -> string -> (changes_file, string) result
8484+(** [load ~fs ~monorepo repo_name] loads weekly changes from
8585+ .changes/<repo_name>.json. Returns an empty changes file if the file does
8686+ not exist. *)
91879292-val save : fs:_ Eio.Path.t -> monorepo:Fpath.t -> changes_file -> (unit, string) result
9393-(** [save ~fs ~monorepo cf] saves the changes file to .changes/<repo_name>.json. *)
8888+val save :
8989+ fs:_ Eio.Path.t -> monorepo:Fpath.t -> changes_file -> (unit, string) result
9090+(** [save ~fs ~monorepo cf] saves the changes file to .changes/<repo_name>.json.
9191+*)
94929595-val daily_exists : fs:_ Eio.Path.t -> monorepo:Fpath.t -> date:string -> string -> bool
9696-(** [daily_exists ~fs ~monorepo ~date repo_name] checks if a daily changes file exists.
9393+val daily_exists :
9494+ fs:_ Eio.Path.t -> monorepo:Fpath.t -> date:string -> string -> bool
9595+(** [daily_exists ~fs ~monorepo ~date repo_name] checks if a daily changes file
9696+ exists.
9797 @param date Date in YYYY-MM-DD format *)
98989999-val load_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> date:string -> string -> (daily_changes_file, string) result
100100-(** [load_daily ~fs ~monorepo ~date repo_name] loads daily changes from .changes/<repo_name>-<date>.json.
101101- Returns an empty changes file if the file does not exist.
9999+val load_daily :
100100+ fs:_ Eio.Path.t ->
101101+ monorepo:Fpath.t ->
102102+ date:string ->
103103+ string ->
104104+ (daily_changes_file, string) result
105105+(** [load_daily ~fs ~monorepo ~date repo_name] loads daily changes from
106106+ .changes/<repo_name>-<date>.json. Returns an empty changes file if the file
107107+ does not exist.
102108 @param date Date in YYYY-MM-DD format *)
103109104104-val save_daily : fs:_ Eio.Path.t -> monorepo:Fpath.t -> date:string -> daily_changes_file -> (unit, string) result
105105-(** [save_daily ~fs ~monorepo ~date cf] saves the changes file to .changes/<repo_name>-<date>.json.
110110+val save_daily :
111111+ fs:_ Eio.Path.t ->
112112+ monorepo:Fpath.t ->
113113+ date:string ->
114114+ daily_changes_file ->
115115+ (unit, string) result
116116+(** [save_daily ~fs ~monorepo ~date cf] saves the changes file to
117117+ .changes/<repo_name>-<date>.json.
106118 @param date Date in YYYY-MM-DD format *)
107119108120(** {1 Markdown Generation} *)
···111123(** [to_markdown cf] generates markdown from a single weekly changes file. *)
112124113125val aggregate : history:int -> changes_file list -> string
114114-(** [aggregate ~history cfs] generates combined markdown from multiple weekly changes files.
126126+(** [aggregate ~history cfs] generates combined markdown from multiple weekly
127127+ changes files.
115128 @param history Number of weeks to include (0 for all) *)
116129117130val aggregate_daily : history:int -> daily_changes_file list -> string
118118-(** [aggregate_daily ~history cfs] generates combined markdown from multiple daily changes files.
119119- Only includes repos with actual changes (filters out empty entries).
131131+(** [aggregate_daily ~history cfs] generates combined markdown from multiple
132132+ daily changes files. Only includes repos with actual changes (filters out
133133+ empty entries).
120134 @param history Number of days to include (0 for all) *)
121135122136(** {1 Date Calculation} *)
···125139(** [format_date (year, month, day)] formats a date as YYYY-MM-DD. *)
126140127141val week_of_date : int * int * int -> string * string
128128-(** [week_of_date (year, month, day)] returns (week_start, week_end) as ISO date strings.
129129- week_start is Monday, week_end is Sunday. *)
142142+(** [week_of_date (year, month, day)] returns (week_start, week_end) as ISO date
143143+ strings. week_start is Monday, week_end is Sunday. *)
130144131145val week_of_ptime : Ptime.t -> string * string
132146(** [week_of_ptime t] returns (week_start, week_end) for the given timestamp. *)
···135149(** [date_of_ptime t] returns the date as YYYY-MM-DD for the given timestamp. *)
136150137151val has_week : changes_file -> week_start:string -> bool
138138-(** [has_week cf ~week_start] returns true if the changes file already has an entry
139139- for the week starting on the given date. *)
152152+(** [has_week cf ~week_start] returns true if the changes file already has an
153153+ entry for the week starting on the given date. *)
140154141155val has_day : daily_changes_file -> date:string -> bool
142142-(** [has_day cf ~date] returns true if the daily changes file already has an entry
143143- for the given date. *)
156156+(** [has_day cf ~date] returns true if the daily changes file already has an
157157+ entry for the given date. *)
144158145159(** {1 Claude Integration} *)
146160147147-type claude_response = {
148148- summary : string;
149149- changes : string list;
150150-}
161161+type claude_response = { summary : string; changes : string list }
151162(** Response from Claude analysis. *)
152163153164val generate_prompt :
···156167 week_end:string ->
157168 Git.log_entry list ->
158169 string
159159-(** [generate_prompt ~repository ~week_start ~week_end commits] creates the prompt
160160- to send to Claude for weekly changelog generation. *)
170170+(** [generate_prompt ~repository ~week_start ~week_end commits] creates the
171171+ prompt to send to Claude for weekly changelog generation. *)
161172162173val generate_weekly_prompt :
163174 repository:string ->
···165176 week_end:string ->
166177 Git.log_entry list ->
167178 string
168168-(** [generate_weekly_prompt ~repository ~week_start ~week_end commits] creates the prompt
169169- to send to Claude for weekly changelog generation. *)
179179+(** [generate_weekly_prompt ~repository ~week_start ~week_end commits] creates
180180+ the prompt to send to Claude for weekly changelog generation. *)
170181171182val generate_daily_prompt :
172172- repository:string ->
173173- date:string ->
174174- Git.log_entry list ->
175175- string
176176-(** [generate_daily_prompt ~repository ~date commits] creates the prompt
177177- to send to Claude for daily changelog generation. *)
183183+ repository:string -> date:string -> Git.log_entry list -> string
184184+(** [generate_daily_prompt ~repository ~date commits] creates the prompt to send
185185+ to Claude for daily changelog generation. *)
178186179187val parse_claude_response : string -> (claude_response option, string) result
180180-(** [parse_claude_response text] parses Claude's response.
181181- Returns [Ok None] if the response is empty (blank summary and changes) or "NO_CHANGES".
182182- Returns [Ok (Some r)] if valid JSON was parsed with actual changes.
183183- Returns [Error msg] if parsing failed. *)
188188+(** [parse_claude_response text] parses Claude's response. Returns [Ok None] if
189189+ the response is empty (blank summary and changes) or "NO_CHANGES". Returns
190190+ [Ok (Some r)] if valid JSON was parsed with actual changes. Returns
191191+ [Error msg] if parsing failed. *)
184192185193val analyze_commits :
186194 sw:Eio.Switch.t ->
···191199 week_end:string ->
192200 Git.log_entry list ->
193201 (claude_response option, string) result
194194-(** [analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end commits]
195195- sends commits to Claude for weekly analysis and returns the parsed response. *)
202202+(** [analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end
203203+ commits] sends commits to Claude for weekly analysis and returns the parsed
204204+ response. *)
196205197206val analyze_commits_daily :
198207 sw:Eio.Switch.t ->
···203212 Git.log_entry list ->
204213 (claude_response option, string) result
205214(** [analyze_commits_daily ~sw ~process_mgr ~clock ~repository ~date commits]
206206- sends commits to Claude for daily analysis and returns the parsed response. *)
215215+ sends commits to Claude for daily analysis and returns the parsed response.
216216+*)
207217208218val refine_daily_changelog :
209219 sw:Eio.Switch.t ->
···213223 (string, string) result
214224(** [refine_daily_changelog ~sw ~process_mgr ~clock markdown] sends the raw
215225 daily changelog markdown through Claude to produce a more narrative,
216216- well-organized version. Groups related changes together and orders them
217217- by significance. Ensures all repository names are formatted as markdown
218218- links using the pattern [\[repo-name\](https://tangled.org/@anil.recoil.org/repo-name.git)].
219219- Returns the refined markdown or the original on error. *)
226226+ well-organized version. Groups related changes together and orders them by
227227+ significance. Ensures all repository names are formatted as markdown links
228228+ using the pattern
229229+ [[repo-name](https://tangled.org/@anil.recoil.org/repo-name.git)]. Returns
230230+ the refined markdown or the original on error. *)
220231221232(** {1 Aggregated Files} *)
222233···227238 git_head:string ->
228239 now:Ptime.t ->
229240 (unit, string) result
230230-(** [generate_aggregated ~fs ~monorepo ~date ~git_head ~now] generates an aggregated
231231- JSON file from all daily JSON files.
241241+(** [generate_aggregated ~fs ~monorepo ~date ~git_head ~now] generates an
242242+ aggregated JSON file from all daily JSON files.
232243233244 This creates a .changes/YYYYMMDD.json file containing all repository entries
234234- for the specified date, with change type classification and author aggregation.
245245+ for the specified date, with change type classification and author
246246+ aggregation.
235247236248 @param fs Filesystem path
237249 @param monorepo Path to the monorepo root
+71-46
lib/changes_aggregated.ml
···3434 | New_library -> "new_library"
3535 | Unknown -> "unknown"
36363737-type commit_range = {
3838- from_hash : string;
3939- to_hash : string;
4040- count : int;
4141-}
3737+type commit_range = { from_hash : string; to_hash : string; count : int }
42384339type entry = {
4440 repository : string;
···6359(* JSON codecs *)
64606561let change_type_jsont =
6666- Jsont.enum ~kind:"change_type" [
6767- ("feature", Feature);
6868- ("bugfix", Bugfix);
6969- ("documentation", Documentation);
7070- ("refactor", Refactor);
7171- ("new_library", New_library);
7272- ("unknown", Unknown);
7373- ]
6262+ Jsont.enum ~kind:"change_type"
6363+ [
6464+ ("feature", Feature);
6565+ ("bugfix", Bugfix);
6666+ ("documentation", Documentation);
6767+ ("refactor", Refactor);
6868+ ("new_library", New_library);
6969+ ("unknown", Unknown);
7070+ ]
74717572let commit_range_jsont =
7673 let make from_hash to_hash count = { from_hash; to_hash; count } in
···8178 |> Jsont.Object.finish
82798380let ptime_jsont =
8484- let enc t =
8585- Ptime.to_rfc3339 t ~tz_offset_s:0
8686- in
8181+ let enc t = Ptime.to_rfc3339 t ~tz_offset_s:0 in
8782 let dec s =
8883 match Ptime.of_rfc3339 s with
8984 | Ok (t, _, _) -> t
···9287 Jsont.map ~dec ~enc Jsont.string
93889489let entry_jsont =
9595- let make repository hour timestamp summary changes commit_range contributors repo_url change_type =
9696- { repository; hour; timestamp; summary; changes; commit_range; contributors; repo_url; change_type }
9090+ let make repository hour timestamp summary changes commit_range contributors
9191+ repo_url change_type =
9292+ {
9393+ repository;
9494+ hour;
9595+ timestamp;
9696+ summary;
9797+ changes;
9898+ commit_range;
9999+ contributors;
100100+ repo_url;
101101+ change_type;
102102+ }
97103 in
98104 (* Default hour and timestamp for backwards compat when reading old files *)
99105 let default_hour = 0 in
100106 let default_timestamp = Ptime.epoch in
101107 Jsont.Object.map ~kind:"aggregated_entry" make
102108 |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun e -> e.repository)
103103- |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> e.hour)
104104- |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun e -> e.timestamp)
109109+ |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e ->
110110+ e.hour)
111111+ |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp
112112+ ~enc:(fun e -> e.timestamp)
105113 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary)
106106- |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes)
107107- |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range)
108108- |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun e -> e.contributors)
109109- |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url)
110110- |> Jsont.Object.mem "change_type" change_type_jsont ~dec_absent:Unknown ~enc:(fun e -> e.change_type)
114114+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e ->
115115+ e.changes)
116116+ |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e ->
117117+ e.commit_range)
118118+ |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[]
119119+ ~enc:(fun e -> e.contributors)
120120+ |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None
121121+ ~enc:(fun e -> e.repo_url)
122122+ |> Jsont.Object.mem "change_type" change_type_jsont ~dec_absent:Unknown
123123+ ~enc:(fun e -> e.change_type)
111124 |> Jsont.Object.finish
112125113126let jsont =
···118131 |> Jsont.Object.mem "date" Jsont.string ~enc:(fun t -> t.date)
119132 |> Jsont.Object.mem "generated_at" ptime_jsont ~enc:(fun t -> t.generated_at)
120133 |> Jsont.Object.mem "git_head" Jsont.string ~enc:(fun t -> t.git_head)
121121- |> Jsont.Object.mem "entries" (Jsont.list entry_jsont) ~enc:(fun t -> t.entries)
122122- |> Jsont.Object.mem "authors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun t -> t.authors)
134134+ |> Jsont.Object.mem "entries" (Jsont.list entry_jsont) ~enc:(fun t ->
135135+ t.entries)
136136+ |> Jsont.Object.mem "authors" (Jsont.list Jsont.string) ~dec_absent:[]
137137+ ~enc:(fun t -> t.authors)
123138 |> Jsont.Object.finish
124139125140(* File I/O *)
···137152 let mm = String.sub yyyymmdd 4 2 in
138153 let dd = String.sub yyyymmdd 6 2 in
139154 Some (yyyy ^ "-" ^ mm ^ "-" ^ dd)
140140- else
141141- None
155155+ else None
142156143157let load ~fs ~changes_dir ~date =
144158 let filename = filename_of_date date in
···156170 (* List all YYYYMMDD.json files and filter by range *)
157171 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in
158172 match Eio.Path.kind ~follow:true dir_path with
159159- | `Directory -> (
173173+ | `Directory ->
160174 let entries = Eio.Path.read_dir dir_path in
161161- let json_files = List.filter (fun f ->
162162- String.length f = 13 && String.ends_with ~suffix:".json" f &&
163163- not (String.contains f '-')) entries
175175+ let json_files =
176176+ List.filter
177177+ (fun f ->
178178+ String.length f = 13
179179+ && String.ends_with ~suffix:".json" f
180180+ && not (String.contains f '-'))
181181+ entries
164182 in
165183 let sorted = List.sort String.compare json_files in
166184 let from_file = filename_of_date from_date in
167185 let to_file = filename_of_date to_date in
168168- let in_range = List.filter (fun f ->
169169- f >= from_file && f <= to_file) sorted
186186+ let in_range =
187187+ List.filter (fun f -> f >= from_file && f <= to_file) sorted
170188 in
171171- let results = List.filter_map (fun filename ->
172172- match date_of_filename filename with
173173- | Some date -> (
174174- match load ~fs ~changes_dir ~date with
175175- | Ok t -> Some t
176176- | Error _ -> None)
177177- | None -> None) in_range
189189+ let results =
190190+ List.filter_map
191191+ (fun filename ->
192192+ match date_of_filename filename with
193193+ | Some date -> (
194194+ match load ~fs ~changes_dir ~date with
195195+ | Ok t -> Some t
196196+ | Error _ -> None)
197197+ | None -> None)
198198+ in_range
178199 in
179179- Ok results)
200200+ Ok results
180201 | _ -> Error "Changes directory not found"
181202 | exception Eio.Io _ -> Error "Could not read changes directory"
182203···185206 match Eio.Path.kind ~follow:true dir_path with
186207 | `Directory -> (
187208 let entries = Eio.Path.read_dir dir_path in
188188- let json_files = List.filter (fun f ->
189189- String.length f = 13 && String.ends_with ~suffix:".json" f &&
190190- not (String.contains f '-')) entries
209209+ let json_files =
210210+ List.filter
211211+ (fun f ->
212212+ String.length f = 13
213213+ && String.ends_with ~suffix:".json" f
214214+ && not (String.contains f '-'))
215215+ entries
191216 in
192217 match List.sort (fun a b -> String.compare b a) json_files with
193218 | [] -> Ok None
+30-29
lib/changes_aggregated.mli
···14141515(** Classification of changes for grouping in broadcasts. *)
1616type change_type =
1717- | Feature (** New features or capabilities *)
1818- | Bugfix (** Bug fixes *)
1919- | Documentation (** Documentation updates *)
2020- | Refactor (** Code refactoring *)
2121- | New_library (** Initial import of a new library *)
2222- | Unknown (** Unclassified changes *)
1717+ | Feature (** New features or capabilities *)
1818+ | Bugfix (** Bug fixes *)
1919+ | Documentation (** Documentation updates *)
2020+ | Refactor (** Code refactoring *)
2121+ | New_library (** Initial import of a new library *)
2222+ | Unknown (** Unclassified changes *)
23232424val change_type_of_string : string -> change_type
2525val string_of_change_type : change_type -> string
26262727(** {1 Entry Types} *)
28282929-(** Commit range information. *)
3029type commit_range = {
3130 from_hash : string; (** Starting commit hash *)
3232- to_hash : string; (** Ending commit hash *)
3333- count : int; (** Number of commits in range *)
3131+ to_hash : string; (** Ending commit hash *)
3232+ count : int; (** Number of commits in range *)
3433}
3434+(** Commit range information. *)
35353636-(** A single repository's changes for the day. *)
3736type entry = {
3838- repository : string; (** Repository name *)
3939- hour : int; (** Hour of day 0-23 for filtering *)
4040- timestamp : Ptime.t; (** RFC3339 timestamp for precise ordering *)
4141- summary : string; (** One-line summary of changes *)
4242- changes : string list; (** List of change bullet points *)
3737+ repository : string; (** Repository name *)
3838+ hour : int; (** Hour of day 0-23 for filtering *)
3939+ timestamp : Ptime.t; (** RFC3339 timestamp for precise ordering *)
4040+ summary : string; (** One-line summary of changes *)
4141+ changes : string list; (** List of change bullet points *)
4342 commit_range : commit_range; (** Commits included *)
4444- contributors : string list; (** Contributors to these changes *)
4545- repo_url : string option; (** Optional repository URL *)
4646- change_type : change_type; (** Classification of the change *)
4343+ contributors : string list; (** Contributors to these changes *)
4444+ repo_url : string option; (** Optional repository URL *)
4545+ change_type : change_type; (** Classification of the change *)
4746}
4747+(** A single repository's changes for the day. *)
48484949(** {1 Aggregated File Type} *)
50505151-(** The complete aggregated daily changes file. *)
5251type t = {
5353- date : string; (** ISO date YYYY-MM-DD *)
5454- generated_at : Ptime.t; (** When this file was generated *)
5555- git_head : string; (** Monorepo HEAD at generation time *)
5656- entries : entry list; (** All repository entries for this day *)
5757- authors : string list; (** All unique authors for this day *)
5252+ date : string; (** ISO date YYYY-MM-DD *)
5353+ generated_at : Ptime.t; (** When this file was generated *)
5454+ git_head : string; (** Monorepo HEAD at generation time *)
5555+ entries : entry list; (** All repository entries for this day *)
5656+ authors : string list; (** All unique authors for this day *)
5857}
5858+(** The complete aggregated daily changes file. *)
59596060(** {1 JSON Codecs} *)
6161···64646565(** {1 File I/O} *)
66666767-val load : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> date:string -> (t, string) result
6868-(** Load aggregated changes for a specific date.
6969- [date] should be in YYYY-MM-DD format. *)
6767+val load :
6868+ fs:_ Eio.Path.t -> changes_dir:Fpath.t -> date:string -> (t, string) result
6969+(** Load aggregated changes for a specific date. [date] should be in YYYY-MM-DD
7070+ format. *)
70717172val load_range :
7273 fs:_ Eio.Path.t ->
···7475 from_date:string ->
7576 to_date:string ->
7677 (t list, string) result
7777-(** Load all aggregated changes files in date range.
7878- Dates should be in YYYY-MM-DD format. *)
7878+(** Load all aggregated changes files in date range. Dates should be in
7979+ YYYY-MM-DD format. *)
79808081val latest : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> (t option, string) result
8182(** Load the most recent aggregated changes file. *)
+104-74
lib/changes_daily.ml
···1010 [<repo>-<YYYY-MM-DD>.json] and contain timestamped entries for real-time
1111 tracking. *)
12121313-type commit_range = {
1414- from_hash : string;
1515- to_hash : string;
1616- count : int;
1717-}
1313+type commit_range = { from_hash : string; to_hash : string; count : int }
18141915type entry = {
2016 repository : string;
···2723 repo_url : string option;
2824}
29253030-type day = {
3131- repository : string;
3232- date : string;
3333- entries : entry list;
3434-}
2626+type day = { repository : string; date : string; entries : entry list }
35273636-module String_map = Map.Make(String)
2828+module String_map = Map.Make (String)
37293830type t = {
3931 by_repo : day list String_map.t;
···7870 let default_hour = 0 in
7971 let default_timestamp = Ptime.epoch in
8072 Jsont.Object.map ~kind:"daily_entry" make
8181- |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e -> e.hour)
8282- |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp ~enc:(fun e -> e.timestamp)
7373+ |> Jsont.Object.mem "hour" Jsont.int ~dec_absent:default_hour ~enc:(fun e ->
7474+ e.hour)
7575+ |> Jsont.Object.mem "timestamp" ptime_jsont ~dec_absent:default_timestamp
7676+ ~enc:(fun e -> e.timestamp)
8377 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary)
8484- |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes)
8585- |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range)
8686- |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun e -> e.contributors)
8787- |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url)
7878+ |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e ->
7979+ e.changes)
8080+ |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e ->
8181+ e.commit_range)
8282+ |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[]
8383+ ~enc:(fun e -> e.contributors)
8484+ |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None
8585+ ~enc:(fun e -> e.repo_url)
8886 |> Jsont.Object.finish
89879090-type json_file = {
9191- json_repository : string;
9292- json_entries : file_entry list;
9393-}
8888+type json_file = { json_repository : string; json_entries : file_entry list }
94899590let json_file_jsont =
9691 let make json_repository json_entries = { json_repository; json_entries } in
9792 Jsont.Object.map ~kind:"daily_changes_file" make
9898- |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun f -> f.json_repository)
9999- |> Jsont.Object.mem "entries" (Jsont.list file_entry_jsont) ~enc:(fun f -> f.json_entries)
9393+ |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun f ->
9494+ f.json_repository)
9595+ |> Jsont.Object.mem "entries" (Jsont.list file_entry_jsont) ~enc:(fun f ->
9696+ f.json_entries)
10097 |> Jsont.Object.finish
1019810299(* Parse date from filename: <repo>-<YYYY-MM-DD>.json *)
103100let parse_daily_filename filename =
104101 (* Check for pattern: ends with -YYYY-MM-DD.json *)
105102 let len = String.length filename in
106106- if len < 16 || not (String.ends_with ~suffix:".json" filename) then
107107- None
103103+ if len < 16 || not (String.ends_with ~suffix:".json" filename) then None
108104 else
109105 (* Try to extract date: last 15 chars are -YYYY-MM-DD.json *)
110106 let date_start = len - 15 in
111107 let potential_date = String.sub filename (date_start + 1) 10 in
112108 (* Validate date format YYYY-MM-DD *)
113113- if String.length potential_date = 10 &&
114114- potential_date.[4] = '-' && potential_date.[7] = '-' then
109109+ if
110110+ String.length potential_date = 10
111111+ && potential_date.[4] = '-'
112112+ && potential_date.[7] = '-'
113113+ then
115114 let repo = String.sub filename 0 date_start in
116115 Some (repo, potential_date)
117117- else
118118- None
116116+ else None
119117120118(* Load a single daily file *)
121119let load_file ~fs ~changes_dir ~repo ~date : entry list =
···126124 let content = Eio.Path.load file_path in
127125 match Jsont_bytesrw.decode_string json_file_jsont content with
128126 | Ok jf ->
129129- List.map (fun (fe : file_entry) : entry ->
130130- { repository = repo;
131131- hour = fe.hour;
132132- timestamp = fe.timestamp;
133133- summary = fe.summary;
134134- changes = fe.changes;
135135- commit_range = fe.commit_range;
136136- contributors = fe.contributors;
137137- repo_url = fe.repo_url;
138138- }) jf.json_entries
127127+ List.map
128128+ (fun (fe : file_entry) : entry ->
129129+ {
130130+ repository = repo;
131131+ hour = fe.hour;
132132+ timestamp = fe.timestamp;
133133+ summary = fe.summary;
134134+ changes = fe.changes;
135135+ commit_range = fe.commit_range;
136136+ contributors = fe.contributors;
137137+ repo_url = fe.repo_url;
138138+ })
139139+ jf.json_entries
139140 | Error _ -> [])
140141 | _ -> []
141142 | exception Eio.Io _ -> []
142143143143-let empty = {
144144- by_repo = String_map.empty;
145145- by_date = String_map.empty;
146146- all_entries = [];
147147-}
144144+let empty =
145145+ { by_repo = String_map.empty; by_date = String_map.empty; all_entries = [] }
148146149147let list_repos ~fs ~changes_dir =
150148 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in
···168166 match parse_daily_filename filename with
169167 | Some (r, date) when r = repo -> Some date
170168 | _ -> None)
171171- |> List.sort (fun a b -> String.compare b a) (* descending *)
169169+ |> List.sort (fun a b -> String.compare b a)
170170+ (* descending *)
172171 | _ -> []
173172 | exception Eio.Io _ -> []
174173···187186 let parsed_files = List.filter_map parse_daily_filename files in
188187189188 (* Load all files and build days *)
190190- let days : day list = List.filter_map (fun (repo, date) ->
191191- let loaded_entries : entry list = load_file ~fs ~changes_dir ~repo ~date in
192192- if loaded_entries = [] then None
193193- else
194194- let sorted_entries : entry list = List.sort (fun (e1 : entry) (e2 : entry) ->
195195- Ptime.compare e1.timestamp e2.timestamp) loaded_entries
196196- in
197197- Some ({ repository = repo; date; entries = sorted_entries } : day)
198198- ) parsed_files in
189189+ let days : day list =
190190+ List.filter_map
191191+ (fun (repo, date) ->
192192+ let loaded_entries : entry list =
193193+ load_file ~fs ~changes_dir ~repo ~date
194194+ in
195195+ if loaded_entries = [] then None
196196+ else
197197+ let sorted_entries : entry list =
198198+ List.sort
199199+ (fun (e1 : entry) (e2 : entry) ->
200200+ Ptime.compare e1.timestamp e2.timestamp)
201201+ loaded_entries
202202+ in
203203+ Some ({ repository = repo; date; entries = sorted_entries } : day))
204204+ parsed_files
205205+ in
199206200207 (* Build by_repo map *)
201201- let by_repo : day list String_map.t = List.fold_left (fun acc (d : day) ->
202202- let existing = String_map.find_opt d.repository acc |> Option.value ~default:[] in
203203- String_map.add d.repository (d :: existing) acc
204204- ) String_map.empty days in
208208+ let by_repo : day list String_map.t =
209209+ List.fold_left
210210+ (fun acc (d : day) ->
211211+ let existing =
212212+ String_map.find_opt d.repository acc |> Option.value ~default:[]
213213+ in
214214+ String_map.add d.repository (d :: existing) acc)
215215+ String_map.empty days
216216+ in
205217206218 (* Sort each repo's days by date descending *)
207207- let by_repo : day list String_map.t = String_map.map (fun (ds : day list) ->
208208- List.sort (fun (d1 : day) (d2 : day) -> String.compare d2.date d1.date) ds
209209- ) by_repo in
219219+ let by_repo : day list String_map.t =
220220+ String_map.map
221221+ (fun (ds : day list) ->
222222+ List.sort
223223+ (fun (d1 : day) (d2 : day) -> String.compare d2.date d1.date)
224224+ ds)
225225+ by_repo
226226+ in
210227211228 (* Build by_date map *)
212212- let by_date : day list String_map.t = List.fold_left (fun acc (d : day) ->
213213- let existing = String_map.find_opt d.date acc |> Option.value ~default:[] in
214214- String_map.add d.date (d :: existing) acc
215215- ) String_map.empty days in
229229+ let by_date : day list String_map.t =
230230+ List.fold_left
231231+ (fun acc (d : day) ->
232232+ let existing =
233233+ String_map.find_opt d.date acc |> Option.value ~default:[]
234234+ in
235235+ String_map.add d.date (d :: existing) acc)
236236+ String_map.empty days
237237+ in
216238217239 (* Sort each date's days by repo name *)
218218- let by_date : day list String_map.t = String_map.map (fun (ds : day list) ->
219219- List.sort (fun (d1 : day) (d2 : day) -> String.compare d1.repository d2.repository) ds
220220- ) by_date in
240240+ let by_date : day list String_map.t =
241241+ String_map.map
242242+ (fun (ds : day list) ->
243243+ List.sort
244244+ (fun (d1 : day) (d2 : day) ->
245245+ String.compare d1.repository d2.repository)
246246+ ds)
247247+ by_date
248248+ in
221249222250 (* Collect all entries sorted by timestamp *)
223251 let all_entries : entry list =
224252 days
225253 |> List.concat_map (fun (d : day) -> d.entries)
226226- |> List.sort (fun (e1 : entry) (e2 : entry) -> Ptime.compare e1.timestamp e2.timestamp)
254254+ |> List.sort (fun (e1 : entry) (e2 : entry) ->
255255+ Ptime.compare e1.timestamp e2.timestamp)
227256 in
228257229258 { by_repo; by_date; all_entries }
230230-231259 | _ -> empty
232260 | exception Eio.Io _ -> empty
233261234262let since (t : t) (timestamp : Ptime.t) : entry list =
235235- List.filter (fun (e : entry) -> Ptime.compare e.timestamp timestamp > 0) t.all_entries
263263+ List.filter
264264+ (fun (e : entry) -> Ptime.compare e.timestamp timestamp > 0)
265265+ t.all_entries
236266237267let for_repo t repo =
238268 String_map.find_opt repo t.by_repo |> Option.value ~default:[]
···240270let for_date t date =
241271 String_map.find_opt date t.by_date |> Option.value ~default:[]
242272243243-let repos t =
244244- String_map.bindings t.by_repo |> List.map fst
273273+let repos t = String_map.bindings t.by_repo |> List.map fst
245274246275let dates t =
247276 String_map.bindings t.by_date
248277 |> List.map fst
249249- |> List.sort (fun a b -> String.compare b a) (* descending *)
278278+ |> List.sort (fun a b -> String.compare b a)
279279+(* descending *)
250280251281let entries_since ~fs ~changes_dir ~since:timestamp =
252282 let t = load_all ~fs ~changes_dir in
+14-24
lib/changes_daily.mli
···12121313(** {1 Types} *)
14141515-type commit_range = {
1616- from_hash : string;
1717- to_hash : string;
1818- count : int;
1919-}
1515+type commit_range = { from_hash : string; to_hash : string; count : int }
2016(** Commit range information. *)
21172218type entry = {
···43394440type t = {
4541 by_repo : day list String_map.t;
4646- (** Map from repository name to list of days. *)
4242+ (** Map from repository name to list of days. *)
4743 by_date : day list String_map.t;
4848- (** Map from date (YYYY-MM-DD) to list of days across repos. *)
4949- all_entries : entry list;
5050- (** All entries sorted by timestamp ascending. *)
4444+ (** Map from date (YYYY-MM-DD) to list of days across repos. *)
4545+ all_entries : entry list; (** All entries sorted by timestamp ascending. *)
5146}
5247(** Immutable collection of all loaded daily changes. *)
5348···5752(** Empty daily changes structure. *)
58535954val load_all : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> t
6060-(** [load_all ~fs ~changes_dir] loads all [<repo>-<YYYY-MM-DD>.json] files
6161- from the changes directory and returns an immutable structure for querying. *)
5555+(** [load_all ~fs ~changes_dir] loads all [<repo>-<YYYY-MM-DD>.json] files from
5656+ the changes directory and returns an immutable structure for querying. *)
62576358(** {1 Querying} *)
6459···6762 sorted by timestamp ascending. *)
68636964val for_repo : t -> string -> day list
7070-(** [for_repo t repo] returns all days for the given repository,
7171- sorted by date descending. *)
6565+(** [for_repo t repo] returns all days for the given repository, sorted by date
6666+ descending. *)
72677368val for_date : t -> string -> day list
7469(** [for_date t date] returns all days (across repos) for the given date. *)
···8277(** {1 File Discovery} *)
83788479val list_repos : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> string list
8585-(** [list_repos ~fs ~changes_dir] returns all repository names that have
8686- daily change files. *)
8080+(** [list_repos ~fs ~changes_dir] returns all repository names that have daily
8181+ change files. *)
87828888-val list_dates : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> repo:string -> string list
8383+val list_dates :
8484+ fs:_ Eio.Path.t -> changes_dir:Fpath.t -> repo:string -> string list
8985(** [list_dates ~fs ~changes_dir ~repo] returns all dates for which the given
9086 repository has change files. *)
9187···10197 repo and date. Returns empty list if file doesn't exist. *)
1029810399val load_repo_all :
104104- fs:_ Eio.Path.t ->
105105- changes_dir:Fpath.t ->
106106- repo:string ->
107107- entry list
100100+ fs:_ Eio.Path.t -> changes_dir:Fpath.t -> repo:string -> entry list
108101(** [load_repo_all ~fs ~changes_dir ~repo] loads all entries for a repository
109102 across all dates. *)
110103111104val entries_since :
112112- fs:_ Eio.Path.t ->
113113- changes_dir:Fpath.t ->
114114- since:Ptime.t ->
115115- entry list
105105+ fs:_ Eio.Path.t -> changes_dir:Fpath.t -> since:Ptime.t -> entry list
116106(** [entries_since ~fs ~changes_dir ~since] returns all entries created after
117107 the given timestamp, useful for real-time updates. *)
+105-67
lib/changes_query.ml
···1919 let (y, m, d), _ = Ptime.to_date_time now in
2020 Printf.sprintf "%04d-%02d-%02d" y m d
2121 in
2222- match Changes_aggregated.load_range ~fs ~changes_dir ~from_date:since_date ~to_date:now_date with
2222+ match
2323+ Changes_aggregated.load_range ~fs ~changes_dir ~from_date:since_date
2424+ ~to_date:now_date
2525+ with
2326 | Error e -> Error e
2427 | Ok aggregated_files ->
2528 (* Filter to files generated after 'since' and collect entries *)
2626- let entries = List.concat_map (fun (agg : Changes_aggregated.t) ->
2727- if Ptime.compare agg.generated_at since > 0 then
2828- agg.entries
2929- else
3030- []) aggregated_files
2929+ let entries =
3030+ List.concat_map
3131+ (fun (agg : Changes_aggregated.t) ->
3232+ if Ptime.compare agg.generated_at since > 0 then agg.entries else [])
3333+ aggregated_files
3134 in
3235 Ok entries
3336···3942let format_repo_link repo url_opt =
4043 match url_opt with
4144 | Some url -> Printf.sprintf "[%s](%s)" repo url
4242- | None -> Printf.sprintf "[%s](https://tangled.org/@anil.recoil.org/%s.git)" repo repo
4545+ | None -> repo (* No URL available, just use repo name *)
43464447let format_for_zulip ~entries ~include_date ~date =
4545- if entries = [] then
4646- "No changes to report."
4848+ if entries = [] then "No changes to report."
4749 else begin
4850 let buf = Buffer.create 1024 in
4951 if include_date then begin
···5254 | None -> Buffer.add_string buf "Recent updates:\n\n"
5355 end;
5456 (* Group by change type *)
5555- let by_type = [
5656- (Changes_aggregated.New_library, "New Libraries", []);
5757- (Changes_aggregated.Feature, "Features", []);
5858- (Changes_aggregated.Bugfix, "Bug Fixes", []);
5959- (Changes_aggregated.Documentation, "Documentation", []);
6060- (Changes_aggregated.Refactor, "Improvements", []);
6161- (Changes_aggregated.Unknown, "Other Changes", []);
6262- ] in
6363- let grouped = List.map (fun (ct, title, _) ->
6464- let matching = List.filter (fun (e : Changes_aggregated.entry) -> e.change_type = ct) entries in
6565- (ct, title, matching)) by_type
5757+ let by_type =
5858+ [
5959+ (Changes_aggregated.New_library, "New Libraries", []);
6060+ (Changes_aggregated.Feature, "Features", []);
6161+ (Changes_aggregated.Bugfix, "Bug Fixes", []);
6262+ (Changes_aggregated.Documentation, "Documentation", []);
6363+ (Changes_aggregated.Refactor, "Improvements", []);
6464+ (Changes_aggregated.Unknown, "Other Changes", []);
6565+ ]
6666 in
6767- List.iter (fun (_ct, title, entries) ->
6868- if entries <> [] then begin
6969- Buffer.add_string buf (Printf.sprintf "### %s\n\n" title);
7070- List.iter (fun (entry : Changes_aggregated.entry) ->
7171- let repo_link = format_repo_link entry.repository entry.repo_url in
7272- Buffer.add_string buf (Printf.sprintf "**%s**: %s\n" repo_link entry.summary);
7373- List.iter (fun change ->
7474- Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes;
7575- if entry.contributors <> [] then
7676- Buffer.add_string buf (Printf.sprintf "*Contributors: %s*\n"
7777- (String.concat ", " entry.contributors));
7878- Buffer.add_string buf "\n") entries
7979- end) grouped;
6767+ let grouped =
6868+ List.map
6969+ (fun (ct, title, _) ->
7070+ let matching =
7171+ List.filter
7272+ (fun (e : Changes_aggregated.entry) -> e.change_type = ct)
7373+ entries
7474+ in
7575+ (ct, title, matching))
7676+ by_type
7777+ in
7878+ List.iter
7979+ (fun (_ct, title, entries) ->
8080+ if entries <> [] then begin
8181+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" title);
8282+ List.iter
8383+ (fun (entry : Changes_aggregated.entry) ->
8484+ let repo_link =
8585+ format_repo_link entry.repository entry.repo_url
8686+ in
8787+ Buffer.add_string buf
8888+ (Printf.sprintf "**%s**: %s\n" repo_link entry.summary);
8989+ List.iter
9090+ (fun change ->
9191+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
9292+ entry.changes;
9393+ if entry.contributors <> [] then
9494+ Buffer.add_string buf
9595+ (Printf.sprintf "*Contributors: %s*\n"
9696+ (String.concat ", " entry.contributors));
9797+ Buffer.add_string buf "\n")
9898+ entries
9999+ end)
100100+ grouped;
80101 Buffer.contents buf
81102 end
8210383104let format_summary ~entries =
8484- if entries = [] then
8585- "No new changes."
105105+ if entries = [] then "No new changes."
86106 else
87107 let count = List.length entries in
8888- let repos = List.sort_uniq String.compare
8989- (List.map (fun (e : Changes_aggregated.entry) -> e.repository) entries) in
9090- Printf.sprintf "%d change%s across %d repositor%s: %s"
9191- count (if count = 1 then "" else "s")
9292- (List.length repos) (if List.length repos = 1 then "y" else "ies")
108108+ let repos =
109109+ List.sort_uniq String.compare
110110+ (List.map (fun (e : Changes_aggregated.entry) -> e.repository) entries)
111111+ in
112112+ Printf.sprintf "%d change%s across %d repositor%s: %s" count
113113+ (if count = 1 then "" else "s")
114114+ (List.length repos)
115115+ (if List.length repos = 1 then "y" else "ies")
93116 (String.concat ", " repos)
9411795118(** {1 Daily Changes (Real-time)} *)
···101124 daily_changes_since ~fs ~changes_dir ~since <> []
102125103126let format_daily_for_zulip ~entries ~include_date ~date =
104104- if entries = [] then
105105- "No changes to report."
127127+ if entries = [] then "No changes to report."
106128 else begin
107129 let buf = Buffer.create 1024 in
108130 if include_date then begin
109131 match date with
110110- | Some d -> Buffer.add_string buf (Printf.sprintf "## Changes for %s\n\n" d)
132132+ | Some d ->
133133+ Buffer.add_string buf (Printf.sprintf "## Changes for %s\n\n" d)
111134 | None -> Buffer.add_string buf "## Recent Changes\n\n"
112135 end;
113136 (* Group by repository *)
114114- let repos = List.sort_uniq String.compare
115115- (List.map (fun (e : Changes_daily.entry) -> e.repository) entries) in
116116- List.iter (fun repo ->
117117- let repo_entries = List.filter (fun (e : Changes_daily.entry) -> e.repository = repo) entries in
118118- if repo_entries <> [] then begin
119119- let first_entry = List.hd repo_entries in
120120- let repo_link = format_repo_link repo first_entry.repo_url in
121121- Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_link);
122122- List.iter (fun (entry : Changes_daily.entry) ->
123123- Buffer.add_string buf (Printf.sprintf "**%s**\n" entry.summary);
124124- List.iter (fun change ->
125125- Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes;
126126- if entry.contributors <> [] then
127127- Buffer.add_string buf (Printf.sprintf "*Contributors: %s*\n"
128128- (String.concat ", " entry.contributors));
129129- Buffer.add_string buf "\n") repo_entries
130130- end) repos;
137137+ let repos =
138138+ List.sort_uniq String.compare
139139+ (List.map (fun (e : Changes_daily.entry) -> e.repository) entries)
140140+ in
141141+ List.iter
142142+ (fun repo ->
143143+ let repo_entries =
144144+ List.filter
145145+ (fun (e : Changes_daily.entry) -> e.repository = repo)
146146+ entries
147147+ in
148148+ if repo_entries <> [] then begin
149149+ let first_entry = List.hd repo_entries in
150150+ let repo_link = format_repo_link repo first_entry.repo_url in
151151+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_link);
152152+ List.iter
153153+ (fun (entry : Changes_daily.entry) ->
154154+ Buffer.add_string buf (Printf.sprintf "**%s**\n" entry.summary);
155155+ List.iter
156156+ (fun change ->
157157+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
158158+ entry.changes;
159159+ if entry.contributors <> [] then
160160+ Buffer.add_string buf
161161+ (Printf.sprintf "*Contributors: %s*\n"
162162+ (String.concat ", " entry.contributors));
163163+ Buffer.add_string buf "\n")
164164+ repo_entries
165165+ end)
166166+ repos;
131167 Buffer.contents buf
132168 end
133169134170let format_daily_summary ~entries =
135135- if entries = [] then
136136- "No new changes."
171171+ if entries = [] then "No new changes."
137172 else
138173 let count = List.length entries in
139139- let repos = List.sort_uniq String.compare
140140- (List.map (fun (e : Changes_daily.entry) -> e.repository) entries) in
141141- Printf.sprintf "%d change%s across %d repositor%s: %s"
142142- count (if count = 1 then "" else "s")
143143- (List.length repos) (if List.length repos = 1 then "y" else "ies")
174174+ let repos =
175175+ List.sort_uniq String.compare
176176+ (List.map (fun (e : Changes_daily.entry) -> e.repository) entries)
177177+ in
178178+ Printf.sprintf "%d change%s across %d repositor%s: %s" count
179179+ (if count = 1 then "" else "s")
180180+ (List.length repos)
181181+ (if List.length repos = 1 then "y" else "ies")
144182 (String.concat ", " repos)
+12-24
lib/changes_query.mli
···1616 since:Ptime.t ->
1717 now:Ptime.t ->
1818 (Changes_aggregated.entry list, string) result
1919-(** Get all change entries from aggregated files created after [since].
2020- Returns entries from all days after the timestamp.
1919+(** Get all change entries from aggregated files created after [since]. Returns
2020+ entries from all days after the timestamp.
2121 @param now Current time for determining the date range end. *)
22222323val has_new_changes :
2424- fs:_ Eio.Path.t ->
2525- changes_dir:Fpath.t ->
2626- since:Ptime.t ->
2727- now:Ptime.t ->
2828- bool
2424+ fs:_ Eio.Path.t -> changes_dir:Fpath.t -> since:Ptime.t -> now:Ptime.t -> bool
2925(** Check if there are any new changes since the given timestamp.
3026 @param now Current time for determining the date range end. *)
3127···3632 include_date:bool ->
3733 date:string option ->
3834 string
3939-(** Format entries as markdown suitable for Zulip.
4040- If [include_date] is true, includes a date header.
4141- [date] is used for the header if provided. *)
3535+(** Format entries as markdown suitable for Zulip. If [include_date] is true,
3636+ includes a date header. [date] is used for the header if provided. *)
42374343-val format_summary :
4444- entries:Changes_aggregated.entry list ->
4545- string
3838+val format_summary : entries:Changes_aggregated.entry list -> string
4639(** Format a brief summary of the changes. *)
47404841(** {1 Daily Changes (Real-time)} *)
···5245 changes_dir:Fpath.t ->
5346 since:Ptime.t ->
5447 Changes_daily.entry list
5555-(** Get all daily change entries created after [since] timestamp.
5656- Uses the per-day-per-repo files for real-time access. *)
4848+(** Get all daily change entries created after [since] timestamp. Uses the
4949+ per-day-per-repo files for real-time access. *)
57505851val has_new_daily_changes :
5959- fs:_ Eio.Path.t ->
6060- changes_dir:Fpath.t ->
6161- since:Ptime.t ->
6262- bool
5252+ fs:_ Eio.Path.t -> changes_dir:Fpath.t -> since:Ptime.t -> bool
6353(** Check if there are any new daily changes since the given timestamp. *)
64546555val format_daily_for_zulip :
···6757 include_date:bool ->
6858 date:string option ->
6959 string
7070-(** Format daily entries as markdown suitable for Zulip.
7171- Groups entries by repository. *)
6060+(** Format daily entries as markdown suitable for Zulip. Groups entries by
6161+ repository. *)
72627373-val format_daily_summary :
7474- entries:Changes_daily.entry list ->
7575- string
6363+val format_daily_summary : entries:Changes_daily.entry list -> string
7664(** Format a brief summary of daily changes. *)
+197-93
lib/config.ml
···11+(** Unified configuration for monopam.
22+33+ Configuration is stored in TOML format at ~/.config/monopam/opamverse.toml *)
44+55+let app_name = "monopam"
66+77+(** {1 Package Overrides} *)
88+19module Package_config = struct
210 type t = { branch : string option }
311···1119 |> finish))
1220end
13212222+(** {1 Paths Configuration} *)
2323+2424+type paths = {
2525+ mono : string; (** Monorepo directory (default: "mono") *)
2626+ src : string; (** Source checkouts directory (default: "src") *)
2727+ verse : string; (** Verse directory (default: "verse") *)
2828+}
2929+3030+let default_paths = { mono = "mono"; src = "src"; verse = "verse" }
3131+3232+(** {1 Main Configuration Type} *)
3333+1434type t = {
1515- opam_repo : Fpath.t;
1616- checkouts : Fpath.t;
1717- monorepo : Fpath.t;
1818- default_branch : string;
3535+ (* Workspace structure *)
3636+ root : Fpath.t;
3737+ paths : paths;
3838+ (* Identity *)
3939+ handle : string;
4040+ knot : string; (** Git push server hostname (e.g., "git.recoil.org") *)
4141+ (* Package overrides *)
1942 packages : (string * Package_config.t) list;
2043}
21444545+(** {1 Accessors} *)
4646+4747+let root t = t.root
4848+let handle t = t.handle
4949+let knot t = t.knot
5050+let paths t = t.paths
5151+let packages t = t.packages
5252+let package_config t name = List.assoc_opt name t.packages
5353+5454+(* Derived paths *)
5555+let default_branch = "main"
5656+let mono_path t = Fpath.(t.root / t.paths.mono)
5757+let src_path t = Fpath.(t.root / t.paths.src)
5858+let opam_repo_path t = Fpath.(t.root / "opam-repo")
5959+let verse_path t = Fpath.(t.root / t.paths.verse)
6060+6161+(* Aliases for backwards compatibility with old Config.Paths module *)
2262module Paths = struct
2323- let opam_repo t = t.opam_repo
2424- let checkouts t = t.checkouts
2525- let monorepo t = t.monorepo
6363+ let opam_repo = opam_repo_path
6464+ let checkouts = src_path
6565+ let monorepo = mono_path
2666end
27672828-let default_branch t = t.default_branch
2929-let package_config t name = List.assoc_opt name t.packages
6868+(** {1 XDG Paths} *)
6969+7070+let xdg_config_home () =
7171+ match Sys.getenv_opt "XDG_CONFIG_HOME" with
7272+ | Some dir when dir <> "" -> Fpath.v dir
7373+ | _ -> (
7474+ match Sys.getenv_opt "HOME" with
7575+ | Some home -> Fpath.(v home / ".config")
7676+ | None -> Fpath.v "/tmp")
7777+7878+let xdg_data_home () =
7979+ match Sys.getenv_opt "XDG_DATA_HOME" with
8080+ | Some dir when dir <> "" -> Fpath.v dir
8181+ | _ -> (
8282+ match Sys.getenv_opt "HOME" with
8383+ | Some home -> Fpath.(v home / ".local" / "share")
8484+ | None -> Fpath.v "/tmp")
8585+8686+let xdg_cache_home () =
8787+ match Sys.getenv_opt "XDG_CACHE_HOME" with
8888+ | Some dir when dir <> "" -> Fpath.v dir
8989+ | _ ->
9090+ match Sys.getenv_opt "HOME" with
9191+ | Some home -> Fpath.(v home / ".cache")
9292+ | None -> Fpath.v "/tmp"
30933131-let create ~opam_repo ~checkouts ~monorepo ?(default_branch = "main") () =
3232- { opam_repo; checkouts; monorepo; default_branch; packages = [] }
9494+let config_dir () = Fpath.(xdg_config_home () / app_name)
9595+let data_dir () = Fpath.(xdg_data_home () / app_name)
9696+let cache_dir () = Fpath.(xdg_cache_home () / app_name)
9797+let config_file () = Fpath.(config_dir () / "opamverse.toml")
9898+let registry_path () = Fpath.(data_dir () / "opamverse-registry")
33993434-let with_package_override t ~name ~branch:b =
3535- let pkg_config = Package_config.{ branch = Some b } in
100100+(** {1 Construction} *)
101101+102102+(** Derive knot (git push server) from handle.
103103+ E.g., "anil.recoil.org" -> "git.recoil.org" *)
104104+let default_knot_from_handle handle =
105105+ match String.index_opt handle '.' with
106106+ | None -> "git." ^ handle (* fallback *)
107107+ | Some i ->
108108+ let domain = String.sub handle (i + 1) (String.length handle - i - 1) in
109109+ "git." ^ domain
110110+111111+let create ~root ~handle ?knot ?(packages = []) ?(paths = default_paths) () =
112112+ let knot = match knot with Some k -> k | None -> default_knot_from_handle handle in
113113+ { root; handle; knot; packages; paths }
114114+115115+let with_package_override t ~name ?branch:branch_opt () =
116116+ let existing = List.assoc_opt name t.packages in
117117+ let existing_branch = Option.bind existing Package_config.branch in
118118+ let new_branch =
119119+ match branch_opt with Some _ -> branch_opt | None -> existing_branch
120120+ in
121121+ let pkg_config = Package_config.{ branch = new_branch } in
36122 let packages = (name, pkg_config) :: List.remove_assoc name t.packages in
37123 { t with packages }
124124+125125+(** {1 TOML Codecs} *)
3812639127let expand_tilde s =
40128 if String.length s > 0 && s.[0] = '~' then
···53141 match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m)
54142 ~enc:Fpath.to_string Tomlt.string
551435656-let codec : t Tomlt.t =
144144+let paths_codec : paths Tomlt.t =
57145 Tomlt.(
58146 Table.(
5959- obj (fun opam_repo checkouts monorepo default_branch packages ->
6060- {
6161- opam_repo;
6262- checkouts;
6363- monorepo;
6464- default_branch = Option.value ~default:"main" default_branch;
6565- packages;
6666- })
6767- |> mem "opam_repo" fpath_codec ~enc:(fun c -> c.opam_repo)
6868- |> mem "checkouts" fpath_codec ~enc:(fun c -> c.checkouts)
6969- |> mem "monorepo" fpath_codec ~enc:(fun c -> c.monorepo)
7070- |> opt_mem "default_branch" string ~enc:(fun c ->
7171- if c.default_branch = "main" then None else Some c.default_branch)
7272- |> keep_unknown
7373- ~enc:(fun c -> c.packages)
147147+ obj (fun mono src verse ->
148148+ { mono = Option.value ~default:default_paths.mono mono;
149149+ src = Option.value ~default:default_paths.src src;
150150+ verse = Option.value ~default:default_paths.verse verse })
151151+ |> opt_mem "mono" string ~enc:(fun p -> Some p.mono)
152152+ |> opt_mem "src" string ~enc:(fun p -> Some p.src)
153153+ |> opt_mem "verse" string ~enc:(fun p -> Some p.verse)
154154+ |> finish))
155155+156156+(* TOML structure:
157157+ [workspace]
158158+ root = "~/tangled"
159159+160160+ [identity]
161161+ handle = "anil.recoil.org"
162162+ knot = "git.recoil.org"
163163+164164+ [paths]
165165+ mono = "mono"
166166+ src = "src"
167167+168168+ [packages.braid]
169169+ branch = "backport-fix"
170170+*)
171171+172172+type workspace_section = { w_root : Fpath.t }
173173+type identity_section = { i_handle : string; i_knot : string option }
174174+175175+let default_knot = "git.recoil.org"
176176+177177+let workspace_codec : workspace_section Tomlt.t =
178178+ Tomlt.(
179179+ Table.(
180180+ obj (fun w_root -> { w_root })
181181+ |> mem "root" fpath_codec ~enc:(fun w -> w.w_root)
182182+ |> finish))
183183+184184+let identity_codec : identity_section Tomlt.t =
185185+ Tomlt.(
186186+ Table.(
187187+ obj (fun i_handle i_knot -> { i_handle; i_knot })
188188+ |> mem "handle" string ~enc:(fun i -> i.i_handle)
189189+ |> opt_mem "knot" string ~enc:(fun i -> i.i_knot)
190190+ |> finish))
191191+192192+(* Codec for the [packages] table which contains subtree->override mappings *)
193193+let packages_table_codec : (string * Package_config.t) list Tomlt.t =
194194+ Tomlt.(
195195+ Table.(
196196+ obj (fun pkgs -> pkgs)
197197+ |> keep_unknown ~enc:(fun pkgs -> pkgs)
74198 (Mems.assoc Package_config.codec)
75199 |> finish))
76200201201+let codec : t Tomlt.t =
202202+ Tomlt.(
203203+ Table.(
204204+ obj (fun workspace identity packages paths ->
205205+ let packages = Option.value ~default:[] packages in
206206+ let paths = Option.value ~default:default_paths paths in
207207+ let knot = Option.value ~default:default_knot identity.i_knot in
208208+ { root = workspace.w_root; handle = identity.i_handle; knot; packages; paths })
209209+ |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root })
210210+ |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle; i_knot = Some t.knot })
211211+ |> opt_mem "packages" packages_table_codec
212212+ ~enc:(fun t -> if t.packages = [] then None else Some t.packages)
213213+ |> opt_mem "paths" paths_codec
214214+ ~enc:(fun t -> if t.paths = default_paths then None else Some t.paths)
215215+ |> finish))
216216+217217+(** {1 Validation} *)
218218+77219type validation_error =
78220 | Path_not_found of string * Fpath.t
79221 | Not_a_directory of string * Fpath.t
···98240 Hint: Use an absolute path starting with / or ~/"
99241 field Fpath.pp path
100242101101-let validate ~fs t =
102102- (* Get the root filesystem for checking absolute paths *)
103103- let root_fs =
104104- let dir, _ = (fs : _ Eio.Path.t) in
105105- (dir, "")
106106- in
107107- let check_absolute field path =
108108- if Fpath.is_abs path then Ok () else Error (Relative_path (field, path))
109109- in
110110- let check_dir field path =
111111- let eio_path = Eio.Path.(root_fs / Fpath.to_string path) in
112112- match Eio.Path.kind ~follow:true eio_path with
113113- | `Directory -> Ok ()
114114- | `Regular_file | `Symbolic_link | `Block_device | `Character_special
115115- | `Fifo | `Socket | `Unknown | `Not_found ->
116116- Error (Not_a_directory (field, path))
117117- | exception Eio.Io (Eio.Fs.E (Not_found _), _) ->
118118- Error (Path_not_found (field, path))
119119- | exception _ -> Error (Path_not_found (field, path))
120120- in
121121- let check_opam_repo path =
122122- let packages_dir = Fpath.(path / "packages") in
123123- let eio_path = Eio.Path.(root_fs / Fpath.to_string packages_dir) in
124124- match Eio.Path.kind ~follow:true eio_path with
125125- | `Directory -> Ok ()
126126- | _ -> Error (Not_an_opam_repo path)
127127- | exception _ -> Error (Not_an_opam_repo path)
128128- in
129129- let ( let* ) = Result.bind in
130130- (* Check all paths are absolute first *)
131131- let* () = check_absolute "opam_repo" t.opam_repo in
132132- let* () = check_absolute "checkouts" t.checkouts in
133133- let* () = check_absolute "monorepo" t.monorepo in
134134- (* Then check opam_repo exists and is valid *)
135135- let* () = check_dir "opam_repo" t.opam_repo in
136136- let* () = check_opam_repo t.opam_repo in
137137- Ok t
243243+(** {1 Loading and Saving} *)
138244139139-let load ~fs ~root_fs path =
140140- try
141141- let config = Tomlt_eio.decode_path_exn codec ~fs (Fpath.to_string path) in
142142- validate ~fs:root_fs config
143143- |> Result.map_error (fun e -> Fmt.str "%a" pp_validation_error e)
144144- with
145145- | Eio.Io _ as e -> Error (Printexc.to_string e)
146146- | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
147147-148148-let load_xdg ~xdg () =
149149- let config_dir = Xdge.config_dir xdg in
150150- let config_path = Eio.Path.(config_dir / "config.toml") in
151151- try
152152- let config =
153153- Tomlt_eio.decode_path_exn codec ~fs:config_dir (snd config_path)
154154- in
155155- let dir, _ = config_dir in
156156- validate ~fs:(dir, "") config
157157- |> Result.map_error (fun e -> Fmt.str "%a" pp_validation_error e)
158158- with
159159- | Eio.Io _ as e -> Error (Printexc.to_string e)
160160- | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
245245+let load ~fs () =
246246+ let path = config_file () in
247247+ let path_str = Fpath.to_string path in
248248+ let eio_path = Eio.Path.(fs / path_str) in
249249+ match Eio.Path.kind ~follow:true eio_path with
250250+ | `Regular_file -> (
251251+ try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str) with
252252+ | Failure msg -> Error (Printf.sprintf "Invalid config: %s" msg)
253253+ | exn -> Error (Printf.sprintf "Error loading config: %s" (Printexc.to_string exn)))
254254+ | _ -> Error (Printf.sprintf "Config file not found: %s" path_str)
255255+ | exception _ -> Error (Printf.sprintf "Config file not found: %s" path_str)
161256162162-let save ~fs t path =
257257+let save ~fs t =
258258+ let dir = config_dir () in
259259+ let path = config_file () in
163260 try
261261+ (* Ensure XDG config directory exists *)
262262+ let dir_path = Eio.Path.(fs / Fpath.to_string dir) in
263263+ (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ());
164264 Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path);
165265 Ok ()
166266 with Eio.Io _ as e -> Error (Printexc.to_string e)
167267268268+(** {1 Pretty Printing} *)
269269+168270let pp ppf t =
169271 Fmt.pf ppf
170170- "@[<v>@[<hov 2>paths:@ opam_repo=%a@ checkouts=%a@ monorepo=%a@]@,\
171171- default_branch=%s@,\
272272+ "@[<v>@[<hov 2>workspace:@ root=%a@]@,\
273273+ @[<hov 2>identity:@ handle=%s@ knot=%s@]@,\
274274+ @[<hov 2>paths:@ mono=%s@ src=%s@ verse=%s@]@,\
172275 packages=%d@]"
173173- Fpath.pp t.opam_repo Fpath.pp t.checkouts Fpath.pp t.monorepo
174174- t.default_branch (List.length t.packages)
276276+ Fpath.pp t.root t.handle t.knot
277277+ t.paths.mono t.paths.src t.paths.verse
278278+ (List.length t.packages)
+121-67
lib/config.mli
···11-(** Configuration management for monopam.
11+(** Unified configuration for monopam.
2233- Configuration is stored in TOML format and loaded from XDG standard
44- locations or a user-specified path. The config file specifies paths to the
55- opam overlay, individual checkouts, and the monorepo, along with optional
66- per-package overrides. *)
33+ Configuration is stored in TOML format at [~/.config/monopam/opamverse.toml].
44+55+ The config stores:
66+ - Workspace root and custom paths
77+ - User identity (handle, knot)
88+ - Per-package overrides
99+1010+ Standard paths derived from root:
1111+ - [mono/] - user's monorepo
1212+ - [src/] - git checkouts for subtrees
1313+ - [opam-repo/] - opam overlay repository
1414+ - [verse/] - other members' monorepos *)
715816(** {1 Types} *)
917···1624 (** [branch t] returns the branch override for this package, if set. *)
1725end
18262727+(** Configurable paths within the workspace.
2828+2929+ By default, paths are:
3030+ - [mono = "mono"] - monorepo directory
3131+ - [src = "src"] - source checkouts directory
3232+ - [verse = "verse"] - verse directory
3333+3434+ Set [mono = "."] to have packages at the root level. *)
3535+type paths = {
3636+ mono : string; (** Monorepo directory (default: "mono") *)
3737+ src : string; (** Source checkouts directory (default: "src") *)
3838+ verse : string; (** Verse directory (default: "verse") *)
3939+}
4040+4141+val default_paths : paths
4242+(** Default paths configuration. *)
4343+1944type t
2045(** The main configuration. *)
21462222-(** {1 Paths Configuration} *)
4747+(** {1 Accessors} *)
23482424-(** Path-related accessors. *)
2525-module Paths : sig
2626- val opam_repo : t -> Fpath.t
2727- (** [opam_repo t] returns the path to the opam overlay repository. *)
2828-2929- val checkouts : t -> Fpath.t
3030- (** [checkouts t] returns the parent directory where individual package
3131- checkouts are stored. *)
4949+val root : t -> Fpath.t
5050+(** [root t] returns the workspace root directory. *)
32513333- val monorepo : t -> Fpath.t
3434- (** [monorepo t] returns the path to the monorepo directory. *)
3535-end
5252+val handle : t -> string
5353+(** [handle t] returns the user's handle. *)
36543737-(** {1 Options} *)
5555+val knot : t -> string
5656+(** [knot t] returns the git push server hostname (e.g., "git.recoil.org").
5757+ Used for converting tangled URLs to SSH push URLs. *)
38583939-val default_branch : t -> string
4040-(** [default_branch t] returns the default git branch to track.
5959+val paths : t -> paths
6060+(** [paths t] returns the paths configuration. *)
41614242- Defaults to "main" if not specified. *)
6262+val packages : t -> (string * Package_config.t) list
6363+(** [packages t] returns the list of package overrides. *)
43644465val package_config : t -> string -> Package_config.t option
4566(** [package_config t name] returns package-specific configuration overrides for
4667 the named package, if any exist. *)
47684848-(** {1 Validation} *)
6969+(** {1 Derived Paths} *)
49705050-(** Errors that can occur when validating configuration paths. *)
5151-type validation_error =
5252- | Path_not_found of string * Fpath.t (** A configured path does not exist *)
5353- | Not_a_directory of string * Fpath.t
5454- (** A configured path is not a directory *)
5555- | Not_an_opam_repo of Fpath.t
5656- (** The opam_repo path is not a valid opam repository (missing packages/
5757- directory) *)
5858- | Invalid_path of string * string (** A path string could not be parsed *)
5959- | Relative_path of string * Fpath.t
6060- (** A configured path is relative but must be absolute *)
7171+val default_branch : string
7272+(** Default git branch, always ["main"]. *)
61736262-val pp_validation_error : validation_error Fmt.t
6363-(** [pp_validation_error] formats validation errors. *)
7474+val mono_path : t -> Fpath.t
7575+(** [mono_path t] returns the path to the user's monorepo. *)
64766565-(** {1 Loading and Saving} *)
7777+val src_path : t -> Fpath.t
7878+(** [src_path t] returns the path to git checkouts. *)
66796767-val load :
6868- fs:_ Eio.Path.t -> root_fs:_ Eio.Path.t -> Fpath.t -> (t, string) result
6969-(** [load ~fs ~root_fs path] loads configuration from the specified TOML file.
8080+val opam_repo_path : t -> Fpath.t
8181+(** [opam_repo_path t] returns the path to the opam overlay. *)
70827171- Validates that paths exist and are valid. Supports tilde expansion for paths
7272- (e.g., [~/src/...]).
8383+val verse_path : t -> Fpath.t
8484+(** [verse_path t] returns the path to tracked members' monorepos. *)
73857474- @param fs The filesystem path for locating the config file
7575- @param root_fs The root filesystem for validating absolute paths in config
8686+(** {1 Backwards Compatibility} *)
76877777- Returns [Error msg] if the file cannot be read, parsed, or if validation
7878- fails. *)
8888+(** Path accessors using old naming convention. *)
8989+module Paths : sig
9090+ val opam_repo : t -> Fpath.t
9191+ (** Alias for [opam_repo_path]. *)
79928080-val load_xdg : xdg:Xdge.t -> unit -> (t, string) result
8181-(** [load_xdg ~xdg ()] loads configuration from XDG standard locations.
9393+ val checkouts : t -> Fpath.t
9494+ (** Alias for [src_path]. *)
82958383- Searches for "config.toml" in the monopam XDG config directory. Validates
8484- that paths exist and are valid. Supports tilde expansion.
9696+ val monorepo : t -> Fpath.t
9797+ (** Alias for [mono_path]. *)
9898+end
9999+100100+(** {1 XDG Paths} *)
101101+102102+val config_dir : unit -> Fpath.t
103103+(** [config_dir ()] returns the XDG config directory for monopam
104104+ (~/.config/monopam). *)
105105+106106+val data_dir : unit -> Fpath.t
107107+(** [data_dir ()] returns the XDG data directory for monopam
108108+ (~/.local/share/monopam). *)
851098686- Returns [Error msg] if no config file is found, parsing fails, or if
8787- validation fails.
110110+val cache_dir : unit -> Fpath.t
111111+(** [cache_dir ()] returns the XDG cache directory for monopam
112112+ (~/.cache/monopam). *)
881138989- @param xdg The Xdge context for "monopam" application *)
114114+val config_file : unit -> Fpath.t
115115+(** [config_file ()] returns the path to the config file
116116+ (~/.config/monopam/opamverse.toml). *)
901179191-val save : fs:_ Eio.Path.t -> t -> Fpath.t -> (unit, string) result
9292-(** [save ~fs t path] writes the configuration to the specified path. *)
118118+val registry_path : unit -> Fpath.t
119119+(** [registry_path ()] returns the path to the cloned registry git repo
120120+ (~/.local/share/monopam/opamverse-registry). *)
9312194122(** {1 Construction} *)
9512396124val create :
9797- opam_repo:Fpath.t ->
9898- checkouts:Fpath.t ->
9999- monorepo:Fpath.t ->
100100- ?default_branch:string ->
125125+ root:Fpath.t ->
126126+ handle:string ->
127127+ ?knot:string ->
128128+ ?packages:(string * Package_config.t) list ->
129129+ ?paths:paths ->
101130 unit ->
102131 t
103103-(** [create ~opam_repo ~checkouts ~monorepo ?default_branch ()] creates a new
104104- configuration with the specified paths.
132132+(** [create ~root ~handle ?knot ?packages ?paths ()] creates a new configuration.
133133+134134+ @param root Workspace root directory (absolute path)
135135+ @param handle User's handle
136136+ @param knot Git push server hostname. If not provided, derived from handle
137137+ @param packages Optional list of package overrides
138138+ @param paths Optional custom paths configuration *)
139139+140140+val with_package_override : t -> name:string -> ?branch:string -> unit -> t
141141+(** [with_package_override t ~name ?branch ()] returns a new config
142142+ with overrides for the named package. *)
143143+144144+(** {1 Validation} *)
145145+146146+type validation_error =
147147+ | Path_not_found of string * Fpath.t
148148+ | Not_a_directory of string * Fpath.t
149149+ | Not_an_opam_repo of Fpath.t
150150+ | Invalid_path of string * string
151151+ | Relative_path of string * Fpath.t
152152+153153+val pp_validation_error : validation_error Fmt.t
154154+(** [pp_validation_error] formats validation errors. *)
155155+156156+(** {1 Loading and Saving} *)
157157+158158+val load : fs:_ Eio.Path.t -> unit -> (t, string) result
159159+(** [load ~fs ()] loads the configuration from the XDG config file.
160160+161161+ @param fs Eio filesystem *)
105162106106- @param opam_repo Path to the opam overlay repository
107107- @param checkouts Parent directory for individual git checkouts
108108- @param monorepo Path to the monorepo
109109- @param default_branch Default branch to track (default: "main") *)
163163+val save : fs:_ Eio.Path.t -> t -> (unit, string) result
164164+(** [save ~fs config] saves the configuration to the XDG config file.
110165111111-val with_package_override : t -> name:string -> branch:string -> t
112112-(** [with_package_override t ~name ~branch] returns a new config with a branch
113113- override for the named package. *)
166166+ @param fs Eio filesystem
167167+ @param config Configuration to save *)
114168115169(** {1 Pretty Printing} *)
116170
+63-41
lib/cross_status.ml
···11(** Cross-user repository comparison for monopam.
2233- Compares subtrees across multiple verse users' monorepos to identify
44- common repositories and their relative commit states. *)
33+ Compares subtrees across multiple verse users' monorepos to identify common
44+ repositories and their relative commit states. *)
5566(** Relationship between two subtree commits. *)
77type relationship =
···1212 (** Commits have diverged from a common ancestor *)
1313 | Unknown (** Cannot determine relationship (missing commits, etc.) *)
14141515-(** Information about a subtree in a monorepo. *)
1615type subtree_info = {
1716 monorepo_path : Fpath.t; (** Path to the monorepo *)
1817 prefix : string; (** Subtree directory name *)
1918 upstream_commit : string option; (** Last synced upstream commit SHA *)
2019}
2020+(** Information about a subtree in a monorepo. *)
21212222-(** Comparison of a repo across multiple users. *)
2322type repo_comparison = {
2423 repo_name : string; (** Repository/subtree name *)
2525- my_info : subtree_info option; (** My subtree info (None if not in my mono) *)
2424+ my_info : subtree_info option;
2525+ (** My subtree info (None if not in my mono) *)
2626 others : (string * subtree_info * relationship) list;
2727 (** List of (handle, info, relationship to me) *)
2828}
2929+(** Comparison of a repo across multiple users. *)
29303030-(** Summary of all cross-user comparisons. *)
3131type t = {
3232 my_repos : repo_comparison list; (** Repos I have, compared against others *)
3333 other_repos : (string * string list) list;
3434 (** Repos I don't have: (repo_name, list of handles who have it) *)
3535}
3636+(** Summary of all cross-user comparisons. *)
36373738let pp_relationship ppf = function
3839 | Same -> Fmt.string ppf "same"
3939- | I_am_ahead n -> Fmt.pf ppf "%d behind" n (* They are behind me *)
4040- | I_am_behind n -> Fmt.pf ppf "%d ahead" n (* They are ahead of me *)
4040+ | I_am_ahead n -> Fmt.pf ppf "%d behind" n (* They are behind me *)
4141+ | I_am_behind n -> Fmt.pf ppf "%d ahead" n (* They are ahead of me *)
4142 | Diverged { my_ahead; their_ahead } ->
4243 Fmt.pf ppf "diverged: them +%d, me +%d" their_ahead my_ahead
4344 | Unknown -> Fmt.string ppf "unknown"
44454546let pp_subtree_info ppf info =
4647 match info.upstream_commit with
4747- | Some commit -> Fmt.pf ppf "%s" (String.sub commit 0 (min 7 (String.length commit)))
4848+ | Some commit ->
4949+ Fmt.pf ppf "%s" (String.sub commit 0 (min 7 (String.length commit)))
4850 | None -> Fmt.string ppf "(no commit)"
49515052let pp_repo_comparison ppf comp =
···5456 | None -> ());
5557 List.iter
5658 (fun (handle, info, rel) ->
5757- Fmt.pf ppf "%-19s %a (%a)@," handle pp_subtree_info info pp_relationship rel)
5959+ Fmt.pf ppf "%-19s %a (%a)@," handle pp_subtree_info info pp_relationship
6060+ rel)
5861 comp.others;
5962 Fmt.pf ppf "@]"
6063···6265let pp ppf t =
6366 if t.my_repos <> [] then begin
6467 Fmt.pf ppf "@[<v>Cross-user comparison:@,";
6565- List.iter (fun comp -> Fmt.pf ppf " %a@," pp_repo_comparison comp) t.my_repos;
6868+ List.iter
6969+ (fun comp -> Fmt.pf ppf " %a@," pp_repo_comparison comp)
7070+ t.my_repos;
6671 Fmt.pf ppf "@]"
6772 end;
6873 if t.other_repos <> [] then begin
···97102 let with_actions = ref [] in
98103 let in_sync = ref [] in
99104100100- List.iter (fun comp ->
101101- let actionable =
102102- List.filter (fun (_, _, rel) -> is_actionable rel) comp.others
103103- in
104104- if actionable <> [] then
105105- with_actions := (comp, actionable) :: !with_actions
106106- else
107107- in_sync := comp :: !in_sync)
105105+ List.iter
106106+ (fun comp ->
107107+ let actionable =
108108+ List.filter (fun (_, _, rel) -> is_actionable rel) comp.others
109109+ in
110110+ if actionable <> [] then
111111+ with_actions := (comp, actionable) :: !with_actions
112112+ else in_sync := comp :: !in_sync)
108113 t.my_repos;
109114110115 (* Print repos with actions needed first *)
111116 if !with_actions <> [] then begin
112117 Fmt.pf ppf "@[<v>@,Subtrees with upstream changes:@,";
113113- List.iter (fun (comp, actionable) ->
114114- let changes = List.map (fun (h, _, rel) ->
115115- Fmt.str "%s:%a" h pp_rel_short rel) actionable
116116- in
117117- Fmt.pf ppf " %-24s %s@," comp.repo_name (String.concat " " changes))
118118+ List.iter
119119+ (fun (comp, actionable) ->
120120+ let changes =
121121+ List.map
122122+ (fun (h, _, rel) -> Fmt.str "%s:%a" h pp_rel_short rel)
123123+ actionable
124124+ in
125125+ Fmt.pf ppf " %-24s %s@," comp.repo_name (String.concat " " changes))
118126 (List.rev !with_actions);
119127 Fmt.pf ppf "@]"
120128 end;
···137145 in
138146 { monorepo_path; prefix; upstream_commit }
139147140140-(** Compare two subtree commits using a reference checkout.
141141- If checkout is available, use it as the authoritative source.
142142- Otherwise, just check if commits match. *)
148148+(** Compare two subtree commits using a reference checkout. If checkout is
149149+ available, use it as the authoritative source. Otherwise, just check if
150150+ commits match. *)
143151let compare_commits ~proc ~fs ~checkout_path ~my_commit ~their_commit () =
144152 match (my_commit, their_commit) with
145153 | None, _ | _, None -> Unknown
···150158 else begin
151159 (* Check if either is ancestor of the other *)
152160 let my_is_ancestor =
153153- Git.is_ancestor ~proc ~fs ~repo:checkout_path ~commit1:my ~commit2:their ()
161161+ Git.is_ancestor ~proc ~fs ~repo:checkout_path ~commit1:my
162162+ ~commit2:their ()
154163 in
155164 let their_is_ancestor =
156156- Git.is_ancestor ~proc ~fs ~repo:checkout_path ~commit1:their ~commit2:my ()
165165+ Git.is_ancestor ~proc ~fs ~repo:checkout_path ~commit1:their
166166+ ~commit2:my ()
157167 in
158168 match (my_is_ancestor, their_is_ancestor) with
159169 | true, false ->
160170 (* My commit is ancestor of theirs -> I'm behind *)
161171 let behind =
162162- Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base:my ~head:their ()
172172+ Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base:my
173173+ ~head:their ()
163174 in
164175 I_am_behind behind
165176 | false, true ->
166177 (* Their commit is ancestor of mine -> I'm ahead *)
167178 let ahead =
168168- Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base:their ~head:my ()
179179+ Git.count_commits_between ~proc ~fs ~repo:checkout_path
180180+ ~base:their ~head:my ()
169181 in
170182 I_am_ahead ahead
171183 | true, true ->
172184 (* Both are ancestors of each other -> same commit *)
173185 Same
174174- | false, false ->
186186+ | false, false -> (
175187 (* Neither is ancestor -> diverged *)
176176- (match Git.merge_base ~proc ~fs ~repo:checkout_path ~commit1:my ~commit2:their () with
188188+ match
189189+ Git.merge_base ~proc ~fs ~repo:checkout_path ~commit1:my
190190+ ~commit2:their ()
191191+ with
177192 | Error _ -> Unknown
178193 | Ok base ->
179194 let my_ahead =
180180- Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base ~head:my ()
195195+ Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base
196196+ ~head:my ()
181197 in
182198 let their_ahead =
183183- Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base ~head:their ()
199199+ Git.count_commits_between ~proc ~fs ~repo:checkout_path ~base
200200+ ~head:their ()
184201 in
185202 Diverged { my_ahead; their_ahead })
186203 end
187204188188-(** Compute cross-user status comparing my monorepo against all verse members. *)
205205+(** Compute cross-user status comparing my monorepo against all verse members.
206206+*)
189207let compute ~proc ~fs ~verse_config ~monopam_config () =
190208 let my_mono = Verse_config.mono_path verse_config in
191209 let checkouts = Config.Paths.checkouts monopam_config in
···194212 let my_subtrees = Verse.scan_subtrees ~proc ~fs my_mono in
195213196214 (* Get verse subtrees (map: repo_name -> [(handle, monorepo_path)]) *)
197197- let verse_subtrees = Verse.get_verse_subtrees ~proc ~fs ~config:verse_config () in
215215+ let verse_subtrees =
216216+ Verse.get_verse_subtrees ~proc ~fs ~config:verse_config ()
217217+ in
198218199219 (* Build comparisons for repos I have *)
200220 let my_repos =
201221 List.filter_map
202222 (fun repo_name ->
203203- let my_info = get_subtree_info ~proc ~fs ~monorepo_path:my_mono ~prefix:repo_name () in
223223+ let my_info =
224224+ get_subtree_info ~proc ~fs ~monorepo_path:my_mono ~prefix:repo_name ()
225225+ in
204226 let checkout_path = Fpath.(checkouts / repo_name) in
205227206228 (* Find others who have this repo *)
···208230 try Hashtbl.find verse_subtrees repo_name with Not_found -> []
209231 in
210232211211- if others_with_repo = [] then
212212- None (* No one else has this repo, skip *)
233233+ if others_with_repo = [] then None (* No one else has this repo, skip *)
213234 else begin
214235 let others =
215236 List.map
216237 (fun (handle, their_mono) ->
217238 let their_info =
218218- get_subtree_info ~proc ~fs ~monorepo_path:their_mono ~prefix:repo_name ()
239239+ get_subtree_info ~proc ~fs ~monorepo_path:their_mono
240240+ ~prefix:repo_name ()
219241 in
220242 let rel =
221243 compare_commits ~proc ~fs ~checkout_path
+11-10
lib/cross_status.mli
···11(** Cross-user repository comparison for monopam.
2233- Compares subtrees across multiple verse users' monorepos to identify
44- common repositories and their relative commit states. *)
33+ Compares subtrees across multiple verse users' monorepos to identify common
44+ repositories and their relative commit states. *)
5566(** {1 Types} *)
77···1414 (** Commits have diverged from a common ancestor *)
1515 | Unknown (** Cannot determine relationship (missing commits, etc.) *)
16161717-(** Information about a subtree in a monorepo. *)
1817type subtree_info = {
1918 monorepo_path : Fpath.t; (** Path to the monorepo *)
2019 prefix : string; (** Subtree directory name *)
2120 upstream_commit : string option; (** Last synced upstream commit SHA *)
2221}
2222+(** Information about a subtree in a monorepo. *)
23232424-(** Comparison of a repo across multiple users. *)
2524type repo_comparison = {
2625 repo_name : string; (** Repository/subtree name *)
2727- my_info : subtree_info option; (** My subtree info (None if not in my mono) *)
2626+ my_info : subtree_info option;
2727+ (** My subtree info (None if not in my mono) *)
2828 others : (string * subtree_info * relationship) list;
2929 (** List of (handle, info, relationship to me) *)
3030}
3131+(** Comparison of a repo across multiple users. *)
31323232-(** Summary of all cross-user comparisons. *)
3333type t = {
3434 my_repos : repo_comparison list; (** Repos I have, compared against others *)
3535 other_repos : (string * string list) list;
3636 (** Repos I don't have: (repo_name, list of handles who have it) *)
3737}
3838+(** Summary of all cross-user comparisons. *)
38393940(** {1 Pretty Printing} *)
4041···5152(** [pp] formats the full cross-user status with commit SHAs. *)
52535354val pp_summary : t Fmt.t
5454-(** [pp_summary] formats a succinct summary with emphasis on repos where
5555- others have commits not in mine. *)
5555+(** [pp_summary] formats a succinct summary with emphasis on repos where others
5656+ have commits not in mine. *)
56575758val is_actionable : relationship -> bool
5858-(** [is_actionable rel] returns [true] if the relationship indicates
5959- that others have commits I should consider pulling (I_am_behind or Diverged). *)
5959+(** [is_actionable rel] returns [true] if the relationship indicates that others
6060+ have commits I should consider pulling (I_am_behind or Diverged). *)
60616162(** {1 Computation} *)
6263
+563-306
lib/doctor.ml
···11(** Doctor command - Claude-powered workspace health analysis.
2233- Analyzes workspace state, verse member commits, and provides
44- actionable recommendations for maintaining your monorepo. *)
33+ Analyzes workspace state, verse member commits, and provides actionable
44+ recommendations for maintaining your monorepo. *)
5566let src = Logs.Src.create "monopam.doctor" ~doc:"Doctor analysis"
77+78module Log = (val Logs.src_log src : Logs.LOG)
89910(** {1 Types} *)
···1920 | Other
20212122(** Priority level for a change *)
2222-type priority =
2323- | Critical
2424- | High
2525- | Medium
2626- | Low
2323+type priority = Critical | High | Medium | Low
27242825(** Recommended action for a commit *)
2929-type recommendation =
3030- | Merge_now
3131- | Review_first
3232- | Skip
3333- | Needs_discussion
2626+type recommendation = Merge_now | Review_first | Skip | Needs_discussion
34273528(** Risk of conflicts when merging *)
3636-type conflict_risk =
3737- | None_risk
3838- | Low_risk
3939- | Medium_risk
4040- | High_risk
2929+type conflict_risk = None_risk | Low_risk | Medium_risk | High_risk
41304242-(** Analysis of a single commit from a verse member *)
4331type commit_analysis = {
4432 hash : string;
4533 subject : string;
···5139 conflict_risk : conflict_risk;
5240 commit_summary : string;
5341}
4242+(** Analysis of a single commit from a verse member *)
54435555-(** Analysis of commits from a specific verse member for a repo *)
5644type verse_analysis = {
5745 handle : string;
5846 commits : commit_analysis list;
5947 suggested_action : string option;
6048}
4949+(** Analysis of commits from a specific verse member for a repo *)
61506262-(** Sync status for a single repository *)
6351type repo_sync = {
6452 name : string;
6553 local_sync : [ `In_sync | `Ahead of int | `Behind of int | `Needs_sync ];
···6755 remote_behind : int;
6856 verse_analyses : verse_analysis list;
6957}
5858+(** Sync status for a single repository *)
70597171-(** Summary statistics *)
7260type report_summary = {
7361 repos_total : int;
7462 repos_need_sync : int;
7563 repos_behind_upstream : int;
7664 verse_divergences : int;
7765}
6666+(** Summary statistics *)
78677979-(** Actionable recommendation *)
8068type action = {
8169 action_priority : priority;
8270 description : string;
8371 command : string option;
8472}
7373+(** Actionable recommendation *)
85748686-(** Full doctor report *)
8775type report = {
8876 timestamp : string;
8977 workspace : string;
···9280 recommendations : action list;
9381 warnings : string list;
9482}
8383+(** Full doctor report *)
95849685(** {1 JSON Encoding} *)
9786···151140 | _ -> Low_risk
152141153142let commit_analysis_jsont =
154154- let make hash subject author date category priority recommendation conflict_risk commit_summary =
155155- { hash; subject; author; date;
143143+ let make hash subject author date category priority recommendation
144144+ conflict_risk commit_summary =
145145+ {
146146+ hash;
147147+ subject;
148148+ author;
149149+ date;
156150 category = change_category_of_string category;
157151 priority = priority_of_string priority;
158152 recommendation = recommendation_of_string recommendation;
159153 conflict_risk = conflict_risk_of_string conflict_risk;
160160- commit_summary }
154154+ commit_summary;
155155+ }
161156 in
162157 Jsont.Object.map ~kind:"commit_analysis" make
163158 |> Jsont.Object.mem "hash" Jsont.string ~enc:(fun c -> c.hash)
164159 |> Jsont.Object.mem "subject" Jsont.string ~enc:(fun c -> c.subject)
165160 |> Jsont.Object.mem "author" Jsont.string ~enc:(fun c -> c.author)
166161 |> Jsont.Object.mem "date" Jsont.string ~enc:(fun c -> c.date)
167167- |> Jsont.Object.mem "category" Jsont.string ~enc:(fun c -> change_category_to_string c.category)
168168- |> Jsont.Object.mem "priority" Jsont.string ~enc:(fun c -> priority_to_string c.priority)
169169- |> Jsont.Object.mem "recommendation" Jsont.string ~enc:(fun c -> recommendation_to_string c.recommendation)
170170- |> Jsont.Object.mem "conflict_risk" Jsont.string ~enc:(fun c -> conflict_risk_to_string c.conflict_risk)
162162+ |> Jsont.Object.mem "category" Jsont.string ~enc:(fun c ->
163163+ change_category_to_string c.category)
164164+ |> Jsont.Object.mem "priority" Jsont.string ~enc:(fun c ->
165165+ priority_to_string c.priority)
166166+ |> Jsont.Object.mem "recommendation" Jsont.string ~enc:(fun c ->
167167+ recommendation_to_string c.recommendation)
168168+ |> Jsont.Object.mem "conflict_risk" Jsont.string ~enc:(fun c ->
169169+ conflict_risk_to_string c.conflict_risk)
171170 |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun c -> c.commit_summary)
172171 |> Jsont.Object.finish
173172174173let verse_analysis_jsont =
175175- let make handle commits suggested_action = { handle; commits; suggested_action } in
174174+ let make handle commits suggested_action =
175175+ { handle; commits; suggested_action }
176176+ in
176177 Jsont.Object.map ~kind:"verse_analysis" make
177178 |> Jsont.Object.mem "handle" Jsont.string ~enc:(fun v -> v.handle)
178178- |> Jsont.Object.mem "commits" (Jsont.list commit_analysis_jsont) ~enc:(fun v -> v.commits)
179179- |> Jsont.Object.mem "suggested_action" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun v -> v.suggested_action)
179179+ |> Jsont.Object.mem "commits" (Jsont.list commit_analysis_jsont)
180180+ ~enc:(fun v -> v.commits)
181181+ |> Jsont.Object.mem "suggested_action" (Jsont.option Jsont.string)
182182+ ~dec_absent:None ~enc:(fun v -> v.suggested_action)
180183 |> Jsont.Object.finish
181184182185let local_sync_to_string = function
···196199197200let repo_sync_jsont =
198201 let make name local_sync remote_ahead remote_behind verse_analyses =
199199- { name; local_sync = local_sync_of_string local_sync; remote_ahead; remote_behind; verse_analyses }
202202+ {
203203+ name;
204204+ local_sync = local_sync_of_string local_sync;
205205+ remote_ahead;
206206+ remote_behind;
207207+ verse_analyses;
208208+ }
200209 in
201210 Jsont.Object.map ~kind:"repo_sync" make
202211 |> Jsont.Object.mem "name" Jsont.string ~enc:(fun r -> r.name)
203203- |> Jsont.Object.mem "local_sync" Jsont.string ~enc:(fun r -> local_sync_to_string r.local_sync)
212212+ |> Jsont.Object.mem "local_sync" Jsont.string ~enc:(fun r ->
213213+ local_sync_to_string r.local_sync)
204214 |> Jsont.Object.mem "remote_ahead" Jsont.int ~enc:(fun r -> r.remote_ahead)
205215 |> Jsont.Object.mem "remote_behind" Jsont.int ~enc:(fun r -> r.remote_behind)
206206- |> Jsont.Object.mem "verse_analyses" (Jsont.list verse_analysis_jsont) ~enc:(fun r -> r.verse_analyses)
216216+ |> Jsont.Object.mem "verse_analyses" (Jsont.list verse_analysis_jsont)
217217+ ~enc:(fun r -> r.verse_analyses)
207218 |> Jsont.Object.finish
208219209220let report_summary_jsont =
210210- let make repos_total repos_need_sync repos_behind_upstream verse_divergences : report_summary =
221221+ let make repos_total repos_need_sync repos_behind_upstream verse_divergences :
222222+ report_summary =
211223 { repos_total; repos_need_sync; repos_behind_upstream; verse_divergences }
212224 in
213225 Jsont.Object.map ~kind:"report_summary" make
214226 |> Jsont.Object.mem "repos_total" Jsont.int ~enc:(fun s -> s.repos_total)
215215- |> Jsont.Object.mem "repos_need_sync" Jsont.int ~enc:(fun s -> s.repos_need_sync)
216216- |> Jsont.Object.mem "repos_behind_upstream" Jsont.int ~enc:(fun s -> s.repos_behind_upstream)
217217- |> Jsont.Object.mem "verse_divergences" Jsont.int ~enc:(fun s -> s.verse_divergences)
227227+ |> Jsont.Object.mem "repos_need_sync" Jsont.int ~enc:(fun s ->
228228+ s.repos_need_sync)
229229+ |> Jsont.Object.mem "repos_behind_upstream" Jsont.int ~enc:(fun s ->
230230+ s.repos_behind_upstream)
231231+ |> Jsont.Object.mem "verse_divergences" Jsont.int ~enc:(fun s ->
232232+ s.verse_divergences)
218233 |> Jsont.Object.finish
219234220235let action_jsont =
···222237 { action_priority = priority_of_string priority; description; command }
223238 in
224239 Jsont.Object.map ~kind:"action" make
225225- |> Jsont.Object.mem "priority" Jsont.string ~enc:(fun a -> priority_to_string a.action_priority)
240240+ |> Jsont.Object.mem "priority" Jsont.string ~enc:(fun a ->
241241+ priority_to_string a.action_priority)
226242 |> Jsont.Object.mem "action" Jsont.string ~enc:(fun a -> a.description)
227227- |> Jsont.Object.mem "command" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun a -> a.command)
243243+ |> Jsont.Object.mem "command" (Jsont.option Jsont.string) ~dec_absent:None
244244+ ~enc:(fun a -> a.command)
228245 |> Jsont.Object.finish
229246230247let report_jsont =
···234251 Jsont.Object.map ~kind:"report" make
235252 |> Jsont.Object.mem "timestamp" Jsont.string ~enc:(fun r -> r.timestamp)
236253 |> Jsont.Object.mem "workspace" Jsont.string ~enc:(fun r -> r.workspace)
237237- |> Jsont.Object.mem "summary" report_summary_jsont ~enc:(fun r -> r.report_summary)
238238- |> Jsont.Object.mem "repos" (Jsont.list repo_sync_jsont) ~enc:(fun r -> r.repos)
239239- |> Jsont.Object.mem "recommendations" (Jsont.list action_jsont) ~enc:(fun r -> r.recommendations)
240240- |> Jsont.Object.mem "warnings" (Jsont.list Jsont.string) ~enc:(fun r -> r.warnings)
254254+ |> Jsont.Object.mem "summary" report_summary_jsont ~enc:(fun r ->
255255+ r.report_summary)
256256+ |> Jsont.Object.mem "repos" (Jsont.list repo_sync_jsont) ~enc:(fun r ->
257257+ r.repos)
258258+ |> Jsont.Object.mem "recommendations" (Jsont.list action_jsont) ~enc:(fun r ->
259259+ r.recommendations)
260260+ |> Jsont.Object.mem "warnings" (Jsont.list Jsont.string) ~enc:(fun r ->
261261+ r.warnings)
241262 |> Jsont.Object.finish
242263243264(** {1 Text Rendering} *)
···271292272293let pp_commit_analysis ppf c =
273294 Fmt.pf ppf " [%a] %s %s@." pp_priority c.priority c.hash c.subject;
274274- Fmt.pf ppf " Category: %a | Risk: %a | Action: %a@."
275275- pp_category c.category
276276- pp_conflict_risk c.conflict_risk
277277- pp_recommendation c.recommendation;
278278- if c.commit_summary <> "" then
279279- Fmt.pf ppf " -> %s@." c.commit_summary
295295+ Fmt.pf ppf " Category: %a | Risk: %a | Action: %a@." pp_category
296296+ c.category pp_conflict_risk c.conflict_risk pp_recommendation
297297+ c.recommendation;
298298+ if c.commit_summary <> "" then Fmt.pf ppf " -> %s@." c.commit_summary
280299281300let pp_verse_analysis ppf v =
282282- Fmt.pf ppf "@. Their commits from %s (%d):@.@." v.handle (List.length v.commits);
301301+ Fmt.pf ppf "@. Their commits from %s (%d):@.@." v.handle
302302+ (List.length v.commits);
283303 List.iter (pp_commit_analysis ppf) v.commits;
284304 match v.suggested_action with
285305 | Some cmd -> Fmt.pf ppf "@. Suggested: %s@." cmd
286306 | None -> ()
287307288308let pp_repo_sync ppf r =
289289- let local_str = match r.local_sync with
309309+ let local_str =
310310+ match r.local_sync with
290311 | `In_sync -> "="
291312 | `Ahead n -> Printf.sprintf "+%d" n
292313 | `Behind n -> Printf.sprintf "-%d" n
293314 | `Needs_sync -> "sync"
294315 in
295316 Fmt.pf ppf "@.%a (local:%s, remote:+%d/-%d)@."
296296- Fmt.(styled `Bold string) r.name local_str r.remote_ahead r.remote_behind;
317317+ Fmt.(styled `Bold string)
318318+ r.name local_str r.remote_ahead r.remote_behind;
297319 if r.verse_analyses <> [] then
298320 List.iter (pp_verse_analysis ppf) r.verse_analyses
299321300322let pp_action ppf a =
301323 Fmt.pf ppf " [%a] %s@." pp_priority a.action_priority a.description;
302302- match a.command with
303303- | Some cmd -> Fmt.pf ppf " $ %s@." cmd
304304- | None -> ()
324324+ match a.command with Some cmd -> Fmt.pf ppf " $ %s@." cmd | None -> ()
305325306326let pp_report ppf r =
307327 Fmt.pf ppf "@.=== Monopam Doctor Report ===@.";
···313333 Fmt.pf ppf " %d verse divergences@." r.report_summary.verse_divergences;
314334315335 (* Only show repos with issues *)
316316- let repos_with_issues = List.filter (fun r ->
317317- r.local_sync <> `In_sync ||
318318- r.remote_behind > 0 ||
319319- r.verse_analyses <> [])
320320- r.repos
336336+ let repos_with_issues =
337337+ List.filter
338338+ (fun r ->
339339+ r.local_sync <> `In_sync || r.remote_behind > 0
340340+ || r.verse_analyses <> [])
341341+ r.repos
321342 in
322343 if repos_with_issues <> [] then begin
323344 Fmt.pf ppf "@.---@.";
···337358338359(** {1 Claude Analysis} *)
339360340340-(** Information about a single remote's status *)
341361type remote_status = {
342362 remote_name : string;
343363 url : string;
344364 ahead : int; [@warning "-69"] (** Commits we have that remote doesn't *)
345365 behind : int; (** Commits remote has that we don't *)
346346- incoming_commits : Git.log_entry list; (** Commits from remote we don't have *)
366366+ incoming_commits : Git.log_entry list;
367367+ (** Commits from remote we don't have *)
347368}
369369+(** Information about a single remote's status *)
348370349371(** Analyze a single remote for a checkout *)
350372let analyze_remote ~proc ~fs ~checkout_dir ~remote_name =
351351- let url = match Git.get_remote_url ~proc ~fs ~remote:remote_name checkout_dir with
373373+ let url =
374374+ match Git.get_remote_url ~proc ~fs ~remote:remote_name checkout_dir with
352375 | Some u -> u
353376 | None -> "(unknown)"
354377 in
355378 (* Try to get ahead/behind for this remote *)
356356- let (ahead, behind) = match Git.ahead_behind ~proc ~fs ~remote:remote_name checkout_dir with
379379+ let ahead, behind =
380380+ match Git.ahead_behind ~proc ~fs ~remote:remote_name checkout_dir with
357381 | Ok ab -> (ab.ahead, ab.behind)
358382 | Error _ -> (0, 0)
359383 in
···361385 let incoming_commits =
362386 if behind > 0 then
363387 let tip = Printf.sprintf "%s/main" remote_name in
364364- match Git.log_range ~proc ~fs ~base:"HEAD" ~tip ~max_count:20 checkout_dir with
388388+ match
389389+ Git.log_range ~proc ~fs ~base:"HEAD" ~tip ~max_count:20 checkout_dir
390390+ with
365391 | Ok commits -> commits
366366- | Error _ ->
392392+ | Error _ -> (
367393 (* Try with master branch *)
368368- (match Git.log_range ~proc ~fs ~base:"HEAD" ~tip:(Printf.sprintf "%s/master" remote_name)
369369- ~max_count:20 checkout_dir with
394394+ match
395395+ Git.log_range ~proc ~fs ~base:"HEAD"
396396+ ~tip:(Printf.sprintf "%s/master" remote_name)
397397+ ~max_count:20 checkout_dir
398398+ with
370399 | Ok commits -> commits
371400 | Error _ -> [])
372401 else []
···376405(** Analyze all remotes for a checkout *)
377406let analyze_checkout_remotes ~proc ~fs ~checkout_dir =
378407 let remotes = Git.list_remotes ~proc ~fs checkout_dir in
379379- List.map (fun remote_name ->
380380- analyze_remote ~proc ~fs ~checkout_dir ~remote_name)
408408+ List.map
409409+ (fun remote_name -> analyze_remote ~proc ~fs ~checkout_dir ~remote_name)
381410 remotes
382411383412(** Strip ANSI escape codes from a string *)
···400429 in
401430 loop 0
402431403403-(** Build status summary for prompt - includes formatted monopam status output *)
432432+(** Build status summary for prompt - includes formatted monopam status output
433433+*)
404434let build_status_summary statuses =
405435 let buf = Buffer.create 4096 in
406436 Buffer.add_string buf "## Current Monorepo Status\n\n";
407437 Buffer.add_string buf "Output of `monopam status`:\n```\n";
408438 (* Capture formatted pp_summary output (strip ANSI codes for prompt) *)
409409- let fmt_output = Fmt.str "%a" Status.pp_summary statuses in
439439+ let fmt_output = Fmt.str "%a" (Status.pp_summary ?sources:None) statuses in
410440 Buffer.add_string buf (strip_ansi fmt_output);
411441 Buffer.add_string buf "```\n\n";
412442 Buffer.add_string buf "Detailed status per repository:\n";
413413- List.iter (fun (status : Status.t) ->
443443+ List.iter
444444+ (fun (status : Status.t) ->
414445 let name = Package.repo_name status.package in
415415- let local_str = match status.subtree_sync with
446446+ let local_str =
447447+ match status.subtree_sync with
416448 | Status.In_sync -> "local:="
417449 | Status.Subtree_behind n -> Printf.sprintf "local:-%d" n
418450 | Status.Subtree_ahead n -> Printf.sprintf "local:+%d" n
419451 | Status.Trees_differ -> "local:sync"
420452 | Status.Unknown -> "local:?"
421453 in
422422- let remote_str = match status.checkout with
454454+ let remote_str =
455455+ match status.checkout with
423456 | Status.Clean ab ->
424457 if ab.ahead > 0 && ab.behind > 0 then
425458 Printf.sprintf "remote:+%d/-%d" ab.ahead ab.behind
426426- else if ab.ahead > 0 then
427427- Printf.sprintf "remote:+%d" ab.ahead
428428- else if ab.behind > 0 then
429429- Printf.sprintf "remote:-%d" ab.behind
459459+ else if ab.ahead > 0 then Printf.sprintf "remote:+%d" ab.ahead
460460+ else if ab.behind > 0 then Printf.sprintf "remote:-%d" ab.behind
430461 else "remote:="
431462 | Status.Dirty -> "remote:dirty"
432463 | Status.Missing -> "remote:missing"
433464 | Status.Not_a_repo -> "remote:not-repo"
434465 in
435435- Buffer.add_string buf (Printf.sprintf "- %s: %s %s\n" name local_str remote_str))
466466+ Buffer.add_string buf
467467+ (Printf.sprintf "- %s: %s %s\n" name local_str remote_str))
436468 statuses;
437469 Buffer.contents buf
438470···440472let build_incoming_summary remotes_by_repo =
441473 let buf = Buffer.create 8192 in
442474 Buffer.add_string buf "\n## Incoming Commits from Remotes\n\n";
443443- List.iter (fun (repo_name, remotes) ->
475475+ List.iter
476476+ (fun (repo_name, remotes) ->
444477 let has_incoming = List.exists (fun r -> r.behind > 0) remotes in
445478 if has_incoming then begin
446479 Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_name);
447447- List.iter (fun r ->
480480+ List.iter
481481+ (fun r ->
448482 if r.behind > 0 then begin
449449- Buffer.add_string buf (Printf.sprintf "**%s** (%s) - %d commits behind:\n"
450450- r.remote_name r.url r.behind);
451451- List.iter (fun (c : Git.log_entry) ->
452452- let short_hash = String.sub c.hash 0 (min 7 (String.length c.hash)) in
453453- Buffer.add_string buf (Printf.sprintf " - %s %s (%s)\n"
454454- short_hash c.subject c.author))
483483+ Buffer.add_string buf
484484+ (Printf.sprintf "**%s** (%s) - %d commits behind:\n"
485485+ r.remote_name r.url r.behind);
486486+ List.iter
487487+ (fun (c : Git.log_entry) ->
488488+ let short_hash =
489489+ String.sub c.hash 0 (min 7 (String.length c.hash))
490490+ in
491491+ Buffer.add_string buf
492492+ (Printf.sprintf " - %s %s (%s)\n" short_hash c.subject
493493+ c.author))
455494 r.incoming_commits;
456495 Buffer.add_string buf "\n"
457496 end)
···461500 Buffer.contents buf
462501463502(** Analyze all incoming commits using Claude *)
464464-let analyze_with_claude ~sw ~process_mgr ~clock ~status_summary ~incoming_summary =
503503+let analyze_with_claude ~sw ~process_mgr ~clock ~status_summary
504504+ ~incoming_summary =
465505 let prompt = Buffer.create 16384 in
466466- Buffer.add_string prompt {|You are analyzing a monorepo workspace to provide actionable recommendations.
506506+ Buffer.add_string prompt
507507+ {|You are analyzing a monorepo workspace to provide actionable recommendations.
467508468509IMPORTANT: The workspace has already been synced and the status output is provided below.
469510You do NOT need to run `monopam status` or `monopam sync` - this has already been done.
···472513|};
473514 Buffer.add_string prompt status_summary;
474515 Buffer.add_string prompt incoming_summary;
475475- Buffer.add_string prompt {|
516516+ Buffer.add_string prompt
517517+ {|
476518477519## Instructions
478520···506548507549 let output_schema =
508550 let open Jsont in
509509- let commit_schema = Object ([
510510- (("type", Meta.none), String ("object", Meta.none));
511511- (("properties", Meta.none), Object ([
512512- (("hash", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
513513- (("subject", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
514514- (("author", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
515515- (("date", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
516516- (("category", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
517517- (("priority", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
518518- (("recommendation", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
519519- (("conflict_risk", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
520520- (("summary", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
521521- ], Meta.none));
522522- ], Meta.none)
551551+ let commit_schema =
552552+ Object
553553+ ( [
554554+ (("type", Meta.none), String ("object", Meta.none));
555555+ ( ("properties", Meta.none),
556556+ Object
557557+ ( [
558558+ ( ("hash", Meta.none),
559559+ Object
560560+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
561561+ Meta.none ) );
562562+ ( ("subject", Meta.none),
563563+ Object
564564+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
565565+ Meta.none ) );
566566+ ( ("author", Meta.none),
567567+ Object
568568+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
569569+ Meta.none ) );
570570+ ( ("date", Meta.none),
571571+ Object
572572+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
573573+ Meta.none ) );
574574+ ( ("category", Meta.none),
575575+ Object
576576+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
577577+ Meta.none ) );
578578+ ( ("priority", Meta.none),
579579+ Object
580580+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
581581+ Meta.none ) );
582582+ ( ("recommendation", Meta.none),
583583+ Object
584584+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
585585+ Meta.none ) );
586586+ ( ("conflict_risk", Meta.none),
587587+ Object
588588+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
589589+ Meta.none ) );
590590+ ( ("summary", Meta.none),
591591+ Object
592592+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
593593+ Meta.none ) );
594594+ ],
595595+ Meta.none ) );
596596+ ],
597597+ Meta.none )
523598 in
524524- let verse_schema = Object ([
525525- (("type", Meta.none), String ("object", Meta.none));
526526- (("properties", Meta.none), Object ([
527527- (("handle", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
528528- (("commits", Meta.none), Object ([
529529- (("type", Meta.none), String ("array", Meta.none));
530530- (("items", Meta.none), commit_schema);
531531- ], Meta.none));
532532- (("suggested_action", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
533533- ], Meta.none));
534534- ], Meta.none)
599599+ let verse_schema =
600600+ Object
601601+ ( [
602602+ (("type", Meta.none), String ("object", Meta.none));
603603+ ( ("properties", Meta.none),
604604+ Object
605605+ ( [
606606+ ( ("handle", Meta.none),
607607+ Object
608608+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
609609+ Meta.none ) );
610610+ ( ("commits", Meta.none),
611611+ Object
612612+ ( [
613613+ (("type", Meta.none), String ("array", Meta.none));
614614+ (("items", Meta.none), commit_schema);
615615+ ],
616616+ Meta.none ) );
617617+ ( ("suggested_action", Meta.none),
618618+ Object
619619+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
620620+ Meta.none ) );
621621+ ],
622622+ Meta.none ) );
623623+ ],
624624+ Meta.none )
535625 in
536536- let repo_schema = Object ([
537537- (("type", Meta.none), String ("object", Meta.none));
538538- (("properties", Meta.none), Object ([
539539- (("name", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
540540- (("verse_analyses", Meta.none), Object ([
541541- (("type", Meta.none), String ("array", Meta.none));
542542- (("items", Meta.none), verse_schema);
543543- ], Meta.none));
544544- ], Meta.none));
545545- ], Meta.none)
626626+ let repo_schema =
627627+ Object
628628+ ( [
629629+ (("type", Meta.none), String ("object", Meta.none));
630630+ ( ("properties", Meta.none),
631631+ Object
632632+ ( [
633633+ ( ("name", Meta.none),
634634+ Object
635635+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
636636+ Meta.none ) );
637637+ ( ("verse_analyses", Meta.none),
638638+ Object
639639+ ( [
640640+ (("type", Meta.none), String ("array", Meta.none));
641641+ (("items", Meta.none), verse_schema);
642642+ ],
643643+ Meta.none ) );
644644+ ],
645645+ Meta.none ) );
646646+ ],
647647+ Meta.none )
546648 in
547547- let action_schema = Object ([
548548- (("type", Meta.none), String ("object", Meta.none));
549549- (("properties", Meta.none), Object ([
550550- (("priority", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
551551- (("action", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
552552- (("command", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
553553- ], Meta.none));
554554- ], Meta.none)
649649+ let action_schema =
650650+ Object
651651+ ( [
652652+ (("type", Meta.none), String ("object", Meta.none));
653653+ ( ("properties", Meta.none),
654654+ Object
655655+ ( [
656656+ ( ("priority", Meta.none),
657657+ Object
658658+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
659659+ Meta.none ) );
660660+ ( ("action", Meta.none),
661661+ Object
662662+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
663663+ Meta.none ) );
664664+ ( ("command", Meta.none),
665665+ Object
666666+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
667667+ Meta.none ) );
668668+ ],
669669+ Meta.none ) );
670670+ ],
671671+ Meta.none )
555672 in
556556- Object ([
557557- (("type", Meta.none), String ("object", Meta.none));
558558- (("properties", Meta.none), Object ([
559559- (("repos", Meta.none), Object ([
560560- (("type", Meta.none), String ("array", Meta.none));
561561- (("items", Meta.none), repo_schema);
562562- ], Meta.none));
563563- (("recommendations", Meta.none), Object ([
564564- (("type", Meta.none), String ("array", Meta.none));
565565- (("items", Meta.none), action_schema);
566566- ], Meta.none));
567567- (("warnings", Meta.none), Object ([
568568- (("type", Meta.none), String ("array", Meta.none));
569569- (("items", Meta.none), Object ([(("type", Meta.none), String ("string", Meta.none))], Meta.none));
570570- ], Meta.none));
571571- ], Meta.none));
572572- (("required", Meta.none), Array ([
573573- String ("repos", Meta.none);
574574- String ("recommendations", Meta.none);
575575- String ("warnings", Meta.none);
576576- ], Meta.none));
577577- ], Meta.none)
673673+ Object
674674+ ( [
675675+ (("type", Meta.none), String ("object", Meta.none));
676676+ ( ("properties", Meta.none),
677677+ Object
678678+ ( [
679679+ ( ("repos", Meta.none),
680680+ Object
681681+ ( [
682682+ (("type", Meta.none), String ("array", Meta.none));
683683+ (("items", Meta.none), repo_schema);
684684+ ],
685685+ Meta.none ) );
686686+ ( ("recommendations", Meta.none),
687687+ Object
688688+ ( [
689689+ (("type", Meta.none), String ("array", Meta.none));
690690+ (("items", Meta.none), action_schema);
691691+ ],
692692+ Meta.none ) );
693693+ ( ("warnings", Meta.none),
694694+ Object
695695+ ( [
696696+ (("type", Meta.none), String ("array", Meta.none));
697697+ ( ("items", Meta.none),
698698+ Object
699699+ ( [
700700+ ( ("type", Meta.none),
701701+ String ("string", Meta.none) );
702702+ ],
703703+ Meta.none ) );
704704+ ],
705705+ Meta.none ) );
706706+ ],
707707+ Meta.none ) );
708708+ ( ("required", Meta.none),
709709+ Array
710710+ ( [
711711+ String ("repos", Meta.none);
712712+ String ("recommendations", Meta.none);
713713+ String ("warnings", Meta.none);
714714+ ],
715715+ Meta.none ) );
716716+ ],
717717+ Meta.none )
718718+ in
719719+ let output_format =
720720+ Claude.Proto.Structured_output.of_json_schema output_schema
578721 in
579579- let output_format = Claude.Proto.Structured_output.of_json_schema output_schema in
580722 let options =
581581- Claude.Options.default
582582- |> Claude.Options.with_output_format output_format
723723+ Claude.Options.default |> Claude.Options.with_output_format output_format
583724 in
584725585726 let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
···587728588729 (* Stream Claude's activity to console *)
589730 let result = ref None in
590590- let handler = object
591591- inherit Claude.Handler.default
731731+ let handler =
732732+ object
733733+ inherit Claude.Handler.default
592734593593- method! on_text t =
594594- let content = Claude.Response.Text.content t in
595595- if String.length content > 0 then
596596- Log.app (fun m -> m "Claude: %s" content)
735735+ method! on_text t =
736736+ let content = Claude.Response.Text.content t in
737737+ if String.length content > 0 then
738738+ Log.app (fun m -> m "Claude: %s" content)
597739598598- method! on_tool_use t =
599599- let name = Claude.Response.Tool_use.name t in
600600- let input = Claude.Response.Tool_use.input t in
601601- (* Show tool being used with key parameters *)
602602- (match name with
603603- | "Bash" ->
604604- let cmd = Claude.Tool_input.get_string input "command" |> Option.value ~default:"" in
605605- let short_cmd = if String.length cmd > 60 then String.sub cmd 0 57 ^ "..." else cmd in
606606- Log.app (fun m -> m " [Bash] %s" short_cmd)
607607- | "Read" ->
608608- let path = Claude.Tool_input.get_string input "file_path" |> Option.value ~default:"" in
609609- Log.app (fun m -> m " [Read] %s" path)
610610- | "Grep" ->
611611- let pattern = Claude.Tool_input.get_string input "pattern" |> Option.value ~default:"" in
612612- Log.app (fun m -> m " [Grep] %s" pattern)
613613- | "Glob" ->
614614- let pattern = Claude.Tool_input.get_string input "pattern" |> Option.value ~default:"" in
615615- Log.app (fun m -> m " [Glob] %s" pattern)
616616- | _ ->
617617- Log.app (fun m -> m " [%s]" name))
740740+ method! on_tool_use t =
741741+ let name = Claude.Response.Tool_use.name t in
742742+ let input = Claude.Response.Tool_use.input t in
743743+ (* Show tool being used with key parameters *)
744744+ match name with
745745+ | "Bash" ->
746746+ let cmd =
747747+ Claude.Tool_input.get_string input "command"
748748+ |> Option.value ~default:""
749749+ in
750750+ let short_cmd =
751751+ if String.length cmd > 60 then String.sub cmd 0 57 ^ "..."
752752+ else cmd
753753+ in
754754+ Log.app (fun m -> m " [Bash] %s" short_cmd)
755755+ | "Read" ->
756756+ let path =
757757+ Claude.Tool_input.get_string input "file_path"
758758+ |> Option.value ~default:""
759759+ in
760760+ Log.app (fun m -> m " [Read] %s" path)
761761+ | "Grep" ->
762762+ let pattern =
763763+ Claude.Tool_input.get_string input "pattern"
764764+ |> Option.value ~default:""
765765+ in
766766+ Log.app (fun m -> m " [Grep] %s" pattern)
767767+ | "Glob" ->
768768+ let pattern =
769769+ Claude.Tool_input.get_string input "pattern"
770770+ |> Option.value ~default:""
771771+ in
772772+ Log.app (fun m -> m " [Glob] %s" pattern)
773773+ | _ -> Log.app (fun m -> m " [%s]" name)
618774619619- method! on_complete c =
620620- match Claude.Response.Complete.structured_output c with
621621- | Some json -> result := Some json
622622- | None -> Log.warn (fun m -> m "No structured output from Claude")
775775+ method! on_complete c =
776776+ match Claude.Response.Complete.structured_output c with
777777+ | Some json -> result := Some json
778778+ | None -> Log.warn (fun m -> m "No structured output from Claude")
623779624624- method! on_error e =
625625- Log.warn (fun m -> m "Claude error: %s" (Claude.Response.Error.message e))
626626- end in
780780+ method! on_error e =
781781+ Log.warn (fun m ->
782782+ m "Claude error: %s" (Claude.Response.Error.message e))
783783+ end
784784+ in
627785628786 Claude.Client.run client ~handler;
629787 !result
···655813 (match json with
656814 | Jsont.Object (obj, _) ->
657815 (* Parse repos *)
658658- List.iter (fun repo_json ->
816816+ List.iter
817817+ (fun repo_json ->
659818 match repo_json with
660819 | Jsont.Object (repo_obj, _) ->
661820 let name = get_string repo_obj "name" "" in
662662- let verse_analyses = List.filter_map (fun va_json ->
663663- match va_json with
664664- | Jsont.Object (va_obj, _) ->
665665- let handle = get_string va_obj "handle" "" in
666666- let commits = List.filter_map (fun c_json ->
667667- match c_json with
668668- | Jsont.Object (c_obj, _) ->
669669- Some {
670670- hash = get_string c_obj "hash" "";
671671- subject = get_string c_obj "subject" "";
672672- author = get_string c_obj "author" "";
673673- date = get_string c_obj "date" "";
674674- category = change_category_of_string (get_string c_obj "category" "other");
675675- priority = priority_of_string (get_string c_obj "priority" "low");
676676- recommendation = recommendation_of_string (get_string c_obj "recommendation" "review-first");
677677- conflict_risk = conflict_risk_of_string (get_string c_obj "conflict_risk" "low");
678678- commit_summary = get_string c_obj "summary" "";
679679- }
680680- | _ -> None)
681681- (get_array va_obj "commits")
682682- in
683683- let suggested_action = get_string_opt va_obj "suggested_action" in
684684- Some { handle; commits; suggested_action }
685685- | _ -> None)
686686- (get_array repo_obj "verse_analyses")
821821+ let verse_analyses =
822822+ List.filter_map
823823+ (fun va_json ->
824824+ match va_json with
825825+ | Jsont.Object (va_obj, _) ->
826826+ let handle = get_string va_obj "handle" "" in
827827+ let commits =
828828+ List.filter_map
829829+ (fun c_json ->
830830+ match c_json with
831831+ | Jsont.Object (c_obj, _) ->
832832+ Some
833833+ {
834834+ hash = get_string c_obj "hash" "";
835835+ subject = get_string c_obj "subject" "";
836836+ author = get_string c_obj "author" "";
837837+ date = get_string c_obj "date" "";
838838+ category =
839839+ change_category_of_string
840840+ (get_string c_obj "category" "other");
841841+ priority =
842842+ priority_of_string
843843+ (get_string c_obj "priority" "low");
844844+ recommendation =
845845+ recommendation_of_string
846846+ (get_string c_obj "recommendation"
847847+ "review-first");
848848+ conflict_risk =
849849+ conflict_risk_of_string
850850+ (get_string c_obj "conflict_risk"
851851+ "low");
852852+ commit_summary =
853853+ get_string c_obj "summary" "";
854854+ }
855855+ | _ -> None)
856856+ (get_array va_obj "commits")
857857+ in
858858+ let suggested_action =
859859+ get_string_opt va_obj "suggested_action"
860860+ in
861861+ Some { handle; commits; suggested_action }
862862+ | _ -> None)
863863+ (get_array repo_obj "verse_analyses")
687864 in
688865 if verse_analyses <> [] then
689689- repos := { name; local_sync = `In_sync; remote_ahead = 0; remote_behind = 0; verse_analyses } :: !repos
866866+ repos :=
867867+ {
868868+ name;
869869+ local_sync = `In_sync;
870870+ remote_ahead = 0;
871871+ remote_behind = 0;
872872+ verse_analyses;
873873+ }
874874+ :: !repos
690875 | _ -> ())
691876 (get_array obj "repos");
692877693878 (* Parse recommendations *)
694694- List.iter (fun rec_json ->
879879+ List.iter
880880+ (fun rec_json ->
695881 match rec_json with
696882 | Jsont.Object (rec_obj, _) ->
697697- let action_priority = priority_of_string (get_string rec_obj "priority" "low") in
883883+ let action_priority =
884884+ priority_of_string (get_string rec_obj "priority" "low")
885885+ in
698886 let description = get_string rec_obj "action" "" in
699887 let command = get_string_opt rec_obj "command" in
700700- recommendations := { action_priority; description; command } :: !recommendations
888888+ recommendations :=
889889+ { action_priority; description; command } :: !recommendations
701890 | _ -> ())
702891 (get_array obj "recommendations");
703892704893 (* Parse warnings *)
705705- List.iter (fun w_json ->
894894+ List.iter
895895+ (fun w_json ->
706896 match w_json with
707897 | Jsont.String (s, _) -> warnings := s :: !warnings
708898 | _ -> ())
···714904(** {1 Main Analysis} *)
715905716906(** Run the doctor analysis *)
717717-let analyze
718718- ~proc ~fs ~config ~verse_config ~clock
719719- ?package ?(no_sync=false) () =
720720- let _ = no_sync in (* Sync is run at CLI level before calling analyze *)
907907+let analyze ~proc ~fs ~config ~verse_config ~clock ?package ?(no_sync = false)
908908+ () =
909909+ let _ = no_sync in
910910+ (* Sync is run at CLI level before calling analyze *)
721911 let now = Eio.Time.now clock in
722722- let now_ptime = match Ptime.of_float_s now with
723723- | Some t -> t
724724- | None -> Ptime.v (0, 0L)
912912+ let now_ptime =
913913+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
725914 in
726915 let timestamp = Ptime.to_rfc3339 now_ptime ~tz_offset_s:0 in
727916 let workspace = Fpath.to_string (Verse_config.root verse_config) in
728917729918 (* Get status for all packages *)
730730- let packages = match Opam_repo.scan ~fs:(fs :> _ Eio.Path.t) (Config.Paths.opam_repo config) with
919919+ let packages =
920920+ match
921921+ Opam_repo.scan ~fs:(fs :> _ Eio.Path.t) (Config.Paths.opam_repo config)
922922+ with
731923 | Ok pkgs -> pkgs
732924 | Error _ -> []
733925 in
734926 let statuses = Status.compute_all ~proc ~fs ~config packages in
735927736928 (* Filter by package if specified *)
737737- let statuses = match package with
929929+ let statuses =
930930+ match package with
738931 | None -> statuses
739739- | Some name -> List.filter (fun (s : Status.t) -> Package.name s.package = name) statuses
932932+ | Some name ->
933933+ List.filter
934934+ (fun (s : Status.t) -> Package.name s.package = name)
935935+ statuses
740936 in
741937742938 (* Build warnings list *)
···753949 warnings := "monorepo has uncommitted changes" :: !warnings;
754950755951 (* Analyze all remotes for each checkout *)
756756- Log.app (fun m -> m "Analyzing remotes for %d repositories..." (List.length statuses));
952952+ Log.app (fun m ->
953953+ m "Analyzing remotes for %d repositories..." (List.length statuses));
757954 let checkouts_root = Config.Paths.checkouts config in
758758- let remotes_by_repo = List.filter_map (fun (status : Status.t) ->
759759- let name = Package.repo_name status.package in
760760- let checkout_dir = Fpath.(checkouts_root / name) in
761761- match status.checkout with
762762- | Status.Missing | Status.Not_a_repo -> None
763763- | _ ->
764764- let remotes = analyze_checkout_remotes ~proc ~fs ~checkout_dir in
765765- Some (name, remotes))
766766- statuses
955955+ let remotes_by_repo =
956956+ List.filter_map
957957+ (fun (status : Status.t) ->
958958+ let name = Package.repo_name status.package in
959959+ let checkout_dir = Fpath.(checkouts_root / name) in
960960+ match status.checkout with
961961+ | Status.Missing | Status.Not_a_repo -> None
962962+ | _ ->
963963+ let remotes = analyze_checkout_remotes ~proc ~fs ~checkout_dir in
964964+ Some (name, remotes))
965965+ statuses
767966 in
768967769968 (* Count repos with incoming changes *)
770770- let repos_with_incoming = List.filter (fun (_name, remotes) ->
771771- List.exists (fun r -> r.behind > 0) remotes)
772772- remotes_by_repo
969969+ let repos_with_incoming =
970970+ List.filter
971971+ (fun (_name, remotes) -> List.exists (fun r -> r.behind > 0) remotes)
972972+ remotes_by_repo
773973 in
774974775975 (* Build repo sync info from status *)
776776- let base_repos = List.map (fun (status : Status.t) ->
777777- let name = Package.repo_name status.package in
778778- let local_sync = match status.subtree_sync with
779779- | Status.In_sync -> `In_sync
780780- | Status.Subtree_behind n -> `Behind n
781781- | Status.Subtree_ahead n -> `Ahead n
782782- | Status.Trees_differ -> `Needs_sync
783783- | Status.Unknown -> `Needs_sync
784784- in
785785- let (remote_ahead, remote_behind) = match status.checkout with
786786- | Status.Clean ab -> (ab.ahead, ab.behind)
787787- | _ -> (0, 0)
788788- in
789789- { name; local_sync; remote_ahead; remote_behind; verse_analyses = [] })
790790- statuses
976976+ let base_repos =
977977+ List.map
978978+ (fun (status : Status.t) ->
979979+ let name = Package.repo_name status.package in
980980+ let local_sync =
981981+ match status.subtree_sync with
982982+ | Status.In_sync -> `In_sync
983983+ | Status.Subtree_behind n -> `Behind n
984984+ | Status.Subtree_ahead n -> `Ahead n
985985+ | Status.Trees_differ -> `Needs_sync
986986+ | Status.Unknown -> `Needs_sync
987987+ in
988988+ let remote_ahead, remote_behind =
989989+ match status.checkout with
990990+ | Status.Clean ab -> (ab.ahead, ab.behind)
991991+ | _ -> (0, 0)
992992+ in
993993+ { name; local_sync; remote_ahead; remote_behind; verse_analyses = [] })
994994+ statuses
791995 in
792996793997 (* If there are repos with incoming changes, analyze with Claude *)
794794- let (repos, claude_recommendations, claude_warnings) =
998998+ let repos, claude_recommendations, claude_warnings =
795999 if repos_with_incoming <> [] then begin
796796- Log.app (fun m -> m "Found %d repos with incoming changes, analyzing with Claude..."
797797- (List.length repos_with_incoming));
10001000+ Log.app (fun m ->
10011001+ m "Found %d repos with incoming changes, analyzing with Claude..."
10021002+ (List.length repos_with_incoming));
7981003 let status_summary = build_status_summary statuses in
7991004 let incoming_summary = build_incoming_summary remotes_by_repo in
8001005801801- match Eio.Switch.run (fun sw ->
802802- analyze_with_claude ~sw ~process_mgr:proc ~clock ~status_summary ~incoming_summary)
10061006+ match
10071007+ Eio.Switch.run (fun sw ->
10081008+ analyze_with_claude ~sw ~process_mgr:proc ~clock ~status_summary
10091009+ ~incoming_summary)
8031010 with
8041011 | Some json ->
805805- let (claude_repos, recs, warns) = parse_claude_response json in
10121012+ let claude_repos, recs, warns = parse_claude_response json in
8061013 (* Merge Claude repos with base repos *)
807807- let merged_repos = List.map (fun base_repo ->
808808- match List.find_opt (fun cr -> cr.name = base_repo.name) claude_repos with
809809- | Some cr -> { base_repo with verse_analyses = cr.verse_analyses }
810810- | None -> base_repo)
811811- base_repos
10141014+ let merged_repos =
10151015+ List.map
10161016+ (fun base_repo ->
10171017+ match
10181018+ List.find_opt
10191019+ (fun cr -> cr.name = base_repo.name)
10201020+ claude_repos
10211021+ with
10221022+ | Some cr ->
10231023+ { base_repo with verse_analyses = cr.verse_analyses }
10241024+ | None -> base_repo)
10251025+ base_repos
8121026 in
8131027 (merged_repos, recs, warns)
8141028 | None ->
8151029 Log.warn (fun m -> m "Claude analysis failed, using basic status");
8161030 (base_repos, [], [])
817817- end else begin
10311031+ end
10321032+ else begin
8181033 Log.app (fun m -> m "No incoming changes from remotes");
8191034 (base_repos, [], [])
8201035 end
8211036 in
822822-82310378241038 (* Compute summary *)
825825- let repos_need_sync = List.length (List.filter (fun r -> r.local_sync <> `In_sync) repos) in
826826- let repos_behind_upstream = List.length (List.filter (fun r -> r.remote_behind > 0) repos) in
827827- let verse_divergences = List.fold_left (fun acc r -> acc + List.length r.verse_analyses) 0 repos in
828828- let report_summary = {
829829- repos_total = List.length repos;
830830- repos_need_sync;
831831- repos_behind_upstream;
832832- verse_divergences;
833833- } in
10391039+ let repos_need_sync =
10401040+ List.length (List.filter (fun r -> r.local_sync <> `In_sync) repos)
10411041+ in
10421042+ let repos_behind_upstream =
10431043+ List.length (List.filter (fun r -> r.remote_behind > 0) repos)
10441044+ in
10451045+ let verse_divergences =
10461046+ List.fold_left (fun acc r -> acc + List.length r.verse_analyses) 0 repos
10471047+ in
10481048+ let report_summary =
10491049+ {
10501050+ repos_total = List.length repos;
10511051+ repos_need_sync;
10521052+ repos_behind_upstream;
10531053+ verse_divergences;
10541054+ }
10551055+ in
83410568351057 (* Build recommendations: start with Claude's, add our own *)
8361058 let recommendations = ref claude_recommendations in
83710598381060 (* Add recommendations for local sync issues *)
839839- if repos_need_sync > 0 && not (List.exists (fun r ->
840840- String.starts_with ~prefix:"Run monopam sync" r.description) !recommendations) then
841841- recommendations := {
842842- action_priority = Medium;
843843- description = Printf.sprintf "Run monopam sync to resolve %d local sync issues" repos_need_sync;
844844- command = Some "monopam sync";
845845- } :: !recommendations;
10611061+ if
10621062+ repos_need_sync > 0
10631063+ && not
10641064+ (List.exists
10651065+ (fun r ->
10661066+ String.starts_with ~prefix:"Run monopam sync" r.description)
10671067+ !recommendations)
10681068+ then
10691069+ recommendations :=
10701070+ {
10711071+ action_priority = Medium;
10721072+ description =
10731073+ Printf.sprintf "Run monopam sync to resolve %d local sync issues"
10741074+ repos_need_sync;
10751075+ command = Some "monopam sync";
10761076+ }
10771077+ :: !recommendations;
84610788471079 (* Add recommendations for repos behind upstream *)
848848- if repos_behind_upstream > 0 && not (List.exists (fun r ->
849849- String.starts_with ~prefix:"Pull upstream" r.description) !recommendations) then
850850- recommendations := {
851851- action_priority = Medium;
852852- description = Printf.sprintf "Pull upstream changes for %d repos" repos_behind_upstream;
853853- command = Some "monopam sync";
854854- } :: !recommendations;
10801080+ if
10811081+ repos_behind_upstream > 0
10821082+ && not
10831083+ (List.exists
10841084+ (fun r -> String.starts_with ~prefix:"Pull upstream" r.description)
10851085+ !recommendations)
10861086+ then
10871087+ recommendations :=
10881088+ {
10891089+ action_priority = Medium;
10901090+ description =
10911091+ Printf.sprintf "Pull upstream changes for %d repos"
10921092+ repos_behind_upstream;
10931093+ command = Some "monopam sync";
10941094+ }
10951095+ :: !recommendations;
85510968561097 (* Sort recommendations by priority *)
8571098 let priority_order = function
858858- | Critical -> 0 | High -> 1 | Medium -> 2 | Low -> 3
10991099+ | Critical -> 0
11001100+ | High -> 1
11011101+ | Medium -> 2
11021102+ | Low -> 3
8591103 in
860860- let recommendations = List.sort (fun a b ->
861861- compare (priority_order a.action_priority) (priority_order b.action_priority))
862862- !recommendations
11041104+ let recommendations =
11051105+ List.sort
11061106+ (fun a b ->
11071107+ compare
11081108+ (priority_order a.action_priority)
11091109+ (priority_order b.action_priority))
11101110+ !recommendations
8631111 in
86411128651113 let all_warnings = List.rev !warnings @ claude_warnings in
866866- { timestamp; workspace; report_summary; repos; recommendations; warnings = all_warnings }
11141114+ {
11151115+ timestamp;
11161116+ workspace;
11171117+ report_summary;
11181118+ repos;
11191119+ recommendations;
11201120+ warnings = all_warnings;
11211121+ }
86711228681123(** Encode report to JSON string *)
8691124let to_json report =
870870- match Jsont_bytesrw.encode_string ~format:Jsont.Indent report_jsont report with
11251125+ match
11261126+ Jsont_bytesrw.encode_string ~format:Jsont.Indent report_jsont report
11271127+ with
8711128 | Ok s -> s
8721129 | Error e -> failwith (Printf.sprintf "Failed to encode report: %s" e)
+18-32
lib/doctor.mli
···11(** Doctor command - Claude-powered workspace health analysis.
2233- Analyzes workspace state, verse member commits, and provides
44- actionable recommendations for maintaining your monorepo.
33+ Analyzes workspace state, verse member commits, and provides actionable
44+ recommendations for maintaining your monorepo.
5566 The doctor command uses Claude AI to analyze commits from verse
77 collaborators, categorizing them by type, priority, and risk level.
···3737 | Other
38383939(** Priority level for a change *)
4040-type priority =
4141- | Critical
4242- | High
4343- | Medium
4444- | Low
4040+type priority = Critical | High | Medium | Low
45414642(** Recommended action for a commit *)
4747-type recommendation =
4848- | Merge_now
4949- | Review_first
5050- | Skip
5151- | Needs_discussion
4343+type recommendation = Merge_now | Review_first | Skip | Needs_discussion
52445345(** Risk of conflicts when merging *)
5454-type conflict_risk =
5555- | None_risk
5656- | Low_risk
5757- | Medium_risk
5858- | High_risk
4646+type conflict_risk = None_risk | Low_risk | Medium_risk | High_risk
59476060-(** Analysis of a single commit from a verse member *)
6148type commit_analysis = {
6249 hash : string;
6350 subject : string;
···6956 conflict_risk : conflict_risk;
7057 commit_summary : string;
7158}
5959+(** Analysis of a single commit from a verse member *)
72607373-(** Analysis of commits from a specific verse member for a repo *)
7461type verse_analysis = {
7562 handle : string;
7663 commits : commit_analysis list;
7764 suggested_action : string option;
7865}
6666+(** Analysis of commits from a specific verse member for a repo *)
79678080-(** Sync status for a single repository *)
8168type repo_sync = {
8269 name : string;
8370 local_sync : [ `In_sync | `Ahead of int | `Behind of int | `Needs_sync ];
···8572 remote_behind : int;
8673 verse_analyses : verse_analysis list;
8774}
7575+(** Sync status for a single repository *)
88768989-(** Summary statistics *)
9077type report_summary = {
9178 repos_total : int;
9279 repos_need_sync : int;
9380 repos_behind_upstream : int;
9481 verse_divergences : int;
9582}
8383+(** Summary statistics *)
96849797-(** Actionable recommendation *)
9885type action = {
9986 action_priority : priority;
10087 description : string;
10188 command : string option;
10289}
9090+(** Actionable recommendation *)
10391104104-(** Full doctor report *)
10592type report = {
10693 timestamp : string;
10794 workspace : string;
···11097 recommendations : action list;
11198 warnings : string list;
11299}
100100+(** Full doctor report *)
113101114102(** {1 Pretty Printing} *)
115103···166154 By default, runs [monopam sync] first to ensure the workspace is up-to-date
167155 before analysis. Use [~no_sync:true] to skip the initial sync.
168156169169- Performs the following analysis:
170170- 1. Runs sync to update workspace (unless [~no_sync:true])
171171- 2. Computes status for all packages (or the specified package)
172172- 3. Checks for dirty state in opam-repo and monorepo
173173- 4. Analyzes fork relationships with verse members
174174- 5. Uses Claude AI to categorize and prioritize verse commits
175175- 6. Generates actionable recommendations
157157+ Performs the following analysis: 1. Runs sync to update workspace (unless
158158+ [~no_sync:true]) 2. Computes status for all packages (or the specified
159159+ package) 3. Checks for dirty state in opam-repo and monorepo 4. Analyzes
160160+ fork relationships with verse members 5. Uses Claude AI to categorize and
161161+ prioritize verse commits 6. Generates actionable recommendations
176162177177- The status output from [monopam status] is provided directly to Claude
178178- in the prompt, so Claude doesn't need to run it separately.
163163+ The status output from [monopam status] is provided directly to Claude in
164164+ the prompt, so Claude doesn't need to run it separately.
179165180166 @param proc Eio process manager
181167 @param fs Eio filesystem
···11+(** Dune project file parsing. *)
22+33+type source_info =
44+ | Github of { user : string; repo : string }
55+ | Gitlab of { user : string; repo : string }
66+ | Tangled of { host : string; repo : string } (** tangled.org style sources *)
77+ | Uri of { url : string; branch : string option }
88+99+type t = {
1010+ name : string;
1111+ source : source_info option;
1212+ homepage : string option;
1313+ packages : string list;
1414+}
1515+1616+module Sexp = Sexplib0.Sexp
1717+1818+(** Extract string from a Sexp.Atom, or None if it's a List *)
1919+let atom_string = function
2020+ | Sexp.Atom s -> Some s
2121+ | Sexp.List _ -> None
2222+2323+(** Parse source stanza: (source (github user/repo)) or (source (uri "url")) *)
2424+let parse_source_inner sexp =
2525+ match sexp with
2626+ | Sexp.List [ Sexp.Atom "github"; Sexp.Atom user_repo ] -> (
2727+ match String.split_on_char '/' user_repo with
2828+ | [ user; repo ] -> Some (Github { user; repo })
2929+ | _ -> None)
3030+ | Sexp.List [ Sexp.Atom "gitlab"; Sexp.Atom user_repo ] -> (
3131+ match String.split_on_char '/' user_repo with
3232+ | [ user; repo ] -> Some (Gitlab { user; repo })
3333+ | _ -> None)
3434+ | Sexp.List [ Sexp.Atom "tangled"; Sexp.Atom host_repo ] -> (
3535+ (* tangled sources: (tangled host.domain/repo) *)
3636+ match String.index_opt host_repo '/' with
3737+ | Some i ->
3838+ let host = String.sub host_repo 0 i in
3939+ let repo = String.sub host_repo (i + 1) (String.length host_repo - i - 1) in
4040+ Some (Tangled { host; repo })
4141+ | None -> None)
4242+ | Sexp.List [ Sexp.Atom "uri"; Sexp.Atom url ] ->
4343+ (* Check for branch in URI fragment *)
4444+ let uri = Uri.of_string url in
4545+ let branch = Uri.fragment uri in
4646+ let url_without_fragment =
4747+ Uri.with_fragment uri None |> Uri.to_string
4848+ in
4949+ Some (Uri { url = url_without_fragment; branch })
5050+ | Sexp.Atom url ->
5151+ (* Single atom URL (unlikely but handle it) *)
5252+ let uri = Uri.of_string url in
5353+ let branch = Uri.fragment uri in
5454+ let url_without_fragment =
5555+ Uri.with_fragment uri None |> Uri.to_string
5656+ in
5757+ Some (Uri { url = url_without_fragment; branch })
5858+ | _ -> None
5959+6060+(** Find name in (package (name foo) ...) stanza *)
6161+let rec find_package_name = function
6262+ | [] -> None
6363+ | Sexp.List [ Sexp.Atom "name"; Sexp.Atom name ] :: _ -> Some name
6464+ | _ :: rest -> find_package_name rest
6565+6666+(** Extract all package names from parsed sexps *)
6767+let extract_packages sexps =
6868+ List.filter_map
6969+ (function
7070+ | Sexp.List (Sexp.Atom "package" :: rest) -> find_package_name rest
7171+ | _ -> None)
7272+ sexps
7373+7474+(** Find a simple string field like (name foo) or (homepage "url") *)
7575+let find_string_field name sexps =
7676+ List.find_map
7777+ (function
7878+ | Sexp.List [ Sexp.Atom n; value ] when n = name -> atom_string value
7979+ | _ -> None)
8080+ sexps
8181+8282+(** Find source field: (source ...) *)
8383+let find_source sexps =
8484+ List.find_map
8585+ (function
8686+ | Sexp.List [ Sexp.Atom "source"; inner ] -> parse_source_inner inner
8787+ | _ -> None)
8888+ sexps
8989+9090+let parse content =
9191+ match Parsexp.Many.parse_string content with
9292+ | Error err ->
9393+ Error (Printf.sprintf "S-expression parse error: %s"
9494+ (Parsexp.Parse_error.message err))
9595+ | Ok sexps -> (
9696+ match find_string_field "name" sexps with
9797+ | None -> Error "dune-project missing (name ...) stanza"
9898+ | Some name ->
9999+ let source = find_source sexps in
100100+ let homepage = find_string_field "homepage" sexps in
101101+ let packages = extract_packages sexps in
102102+ Ok { name; source; homepage; packages })
103103+104104+(** Normalize a URL to have git+ prefix *)
105105+let normalize_git_url url =
106106+ if String.starts_with ~prefix:"git+" url then url
107107+ else if String.starts_with ~prefix:"git@" url then "git+" ^ url
108108+ else if String.starts_with ~prefix:"https://" url then "git+" ^ url
109109+ else if String.starts_with ~prefix:"http://" url then
110110+ "git+https" ^ String.sub url 4 (String.length url - 4)
111111+ else "git+" ^ url
112112+113113+(** Ensure URL ends with .git *)
114114+let ensure_git_suffix url =
115115+ if String.ends_with ~suffix:".git" url then url
116116+ else url ^ ".git"
117117+118118+let dev_repo_url t =
119119+ match t.source with
120120+ | Some (Github { user; repo }) ->
121121+ Ok (Printf.sprintf "git+https://github.com/%s/%s.git" user repo)
122122+ | Some (Gitlab { user; repo }) ->
123123+ Ok (Printf.sprintf "git+https://gitlab.com/%s/%s.git" user repo)
124124+ | Some (Tangled { host; repo }) ->
125125+ (* Tangled sources: https://tangled.sh/@handle/repo *)
126126+ Ok (Printf.sprintf "git+https://tangled.sh/@%s/%s.git" host repo)
127127+ | Some (Uri { url; _ }) ->
128128+ Ok (normalize_git_url (ensure_git_suffix url))
129129+ | None -> (
130130+ match t.homepage with
131131+ | Some homepage ->
132132+ Ok (normalize_git_url (ensure_git_suffix homepage))
133133+ | None ->
134134+ Error
135135+ (Printf.sprintf
136136+ "Package %s must declare source or homepage in dune-project"
137137+ t.name))
138138+139139+let url_with_branch t =
140140+ match dev_repo_url t with
141141+ | Error e -> Error e
142142+ | Ok url ->
143143+ let branch =
144144+ match t.source with
145145+ | Some (Uri { branch = Some b; _ }) -> b
146146+ | _ -> "main"
147147+ in
148148+ Ok (url ^ "#" ^ branch)
+42
lib/dune_project.mli
···11+(** Dune project file parsing.
22+33+ Parse dune-project s-expressions to extract package metadata needed
44+ for generating opam-repo entries. *)
55+66+(** Source information from dune-project. *)
77+type source_info =
88+ | Github of { user : string; repo : string }
99+ | Gitlab of { user : string; repo : string }
1010+ | Tangled of { host : string; repo : string } (** tangled.sh style sources *)
1111+ | Uri of { url : string; branch : string option }
1212+1313+(** Parsed dune-project file. *)
1414+type t = {
1515+ name : string; (** Project name from (name ...) stanza *)
1616+ source : source_info option; (** Source from (source ...) stanza *)
1717+ homepage : string option; (** Homepage from (homepage ...) stanza *)
1818+ packages : string list; (** Package names from (package (name ...)) stanzas *)
1919+}
2020+2121+val parse : string -> (t, string) result
2222+(** [parse content] parses a dune-project file content and extracts metadata.
2323+ Returns [Error msg] if parsing fails or required fields are missing. *)
2424+2525+val dev_repo_url : t -> (string, string) result
2626+(** [dev_repo_url t] derives the dev-repo URL from the parsed dune-project.
2727+ Returns a URL suitable for the opam dev-repo field (e.g., "git+https://...").
2828+2929+ URL derivation logic:
3030+ - [Github {user; repo}] -> "git+https://github.com/user/repo.git"
3131+ - [Gitlab {user; repo}] -> "git+https://gitlab.com/user/repo.git"
3232+ - [Uri {url; _}] -> url normalized with git+ prefix
3333+ - No source but homepage present -> homepage normalized with git+ prefix
3434+ - Neither source nor homepage -> Error *)
3535+3636+val url_with_branch : t -> (string, string) result
3737+(** [url_with_branch t] derives the URL with branch fragment for the opam url section.
3838+ Returns a URL with #branch suffix (e.g., "git+https://...#main").
3939+4040+ Branch derivation:
4141+ - [Uri {url; branch = Some b}] -> url#b
4242+ - Otherwise -> url#main *)
+1-1
lib/feature.ml
···1616 Some (Printf.sprintf "Run 'monopam feature remove %s' first if you want to recreate it" name)
1717 | Feature_not_found name ->
1818 Some (Printf.sprintf "Run 'monopam feature list' to see available features, or 'monopam feature add %s' to create it" name)
1919- | Config_error _ -> Some "Run 'monopam verse init' to create a workspace configuration"
1919+ | Config_error _ -> Some "Run 'monopam init' to create a workspace configuration"
20202121let pp_error_with_hint ppf e =
2222 pp_error ppf e;
+977
lib/fork_join.ml
···11+(** Fork and join operations for managing monorepo sources. *)
22+33+type error =
44+ | Config_error of string
55+ | Git_error of Git.error
66+ | Subtree_not_found of string
77+ | Src_already_exists of string
88+ | Src_not_found of string
99+ | Subtree_already_exists of string
1010+ | No_opam_files of string
1111+ | Verse_error of Verse.error
1212+ | User_cancelled
1313+1414+(** {1 Action Types} *)
1515+1616+(** An action to be performed during fork/join *)
1717+type action =
1818+ | Check_remote_exists of string (** URL - informational check *)
1919+ | Create_directory of Fpath.t
2020+ | Git_init of Fpath.t
2121+ | Git_config of { repo: Fpath.t; key: string; value: string } (** Set git config *)
2222+ | Git_clone of { url: string; dest: Fpath.t; branch: string }
2323+ | Git_subtree_split of { repo: Fpath.t; prefix: string }
2424+ | Git_subtree_add of { repo: Fpath.t; prefix: string; url: Uri.t; branch: string }
2525+ | Git_add_remote of { repo: Fpath.t; name: string; url: string }
2626+ | Git_push_ref of { repo: Fpath.t; target: string; ref_spec: string }
2727+ | Git_checkout of { repo: Fpath.t; branch: string }
2828+ | Git_branch_rename of { repo: Fpath.t; new_name: string } (** Rename current branch *)
2929+ | Copy_directory of { src: Fpath.t; dest: Fpath.t }
3030+ | Git_add_all of Fpath.t
3131+ | Git_commit of { repo: Fpath.t; message: string }
3232+ | Git_rm of { repo: Fpath.t; path: string; recursive: bool } (** Remove file/dir from git *)
3333+ | Update_sources_toml of { path: Fpath.t; name: string; entry: Sources_registry.entry }
3434+3535+(** Discovery information gathered during planning *)
3636+type discovery = {
3737+ mono_exists: bool;
3838+ src_exists: bool;
3939+ has_subtree_history: bool; (** Can we git subtree split? *)
4040+ remote_accessible: bool option; (** None = not checked, Some = result *)
4141+ opam_files: string list;
4242+ local_path_is_repo: bool option; (** For join from local dir *)
4343+}
4444+4545+(** A complete action plan *)
4646+type 'a action_plan = {
4747+ discovery: discovery;
4848+ actions: action list;
4949+ result: 'a; (** What we'll return on success *)
5050+ dry_run: bool;
5151+}
5252+5353+let pp_error ppf = function
5454+ | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
5555+ | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e
5656+ | Subtree_not_found name -> Fmt.pf ppf "Subtree not found in monorepo: %s" name
5757+ | Src_already_exists name -> Fmt.pf ppf "Source checkout already exists: src/%s" name
5858+ | Src_not_found name -> Fmt.pf ppf "Source checkout not found: src/%s" name
5959+ | Subtree_already_exists name -> Fmt.pf ppf "Subtree already exists in monorepo: mono/%s" name
6060+ | No_opam_files name -> Fmt.pf ppf "No .opam files found in subtree: %s" name
6161+ | Verse_error e -> Fmt.pf ppf "Verse error: %a" Verse.pp_error e
6262+ | User_cancelled -> Fmt.pf ppf "Operation cancelled by user"
6363+6464+let error_hint = function
6565+ | Config_error _ ->
6666+ Some "Run 'monopam init --handle <your-handle>' to create a workspace."
6767+ | Git_error (Git.Dirty_worktree _) ->
6868+ Some "Commit or stash your changes first: git status"
6969+ | Git_error _ -> None
7070+ | Subtree_not_found name ->
7171+ Some (Fmt.str "Check that mono/%s exists in your monorepo" name)
7272+ | Src_already_exists name ->
7373+ Some (Fmt.str "Remove or rename src/%s first, or choose a different name" name)
7474+ | Src_not_found name ->
7575+ Some (Fmt.str "Run 'monopam fork %s' first to create src/%s" name name)
7676+ | Subtree_already_exists name ->
7777+ Some (Fmt.str "Remove mono/%s first, or use a different name with --as" name)
7878+ | No_opam_files name ->
7979+ Some (Fmt.str "Add a .opam file to mono/%s before forking" name)
8080+ | Verse_error e -> Verse.error_hint e
8181+ | User_cancelled -> None
8282+8383+(** {1 Pretty Printers for Actions and Discovery} *)
8484+8585+let pp_action ppf = function
8686+ | Check_remote_exists url ->
8787+ Fmt.pf ppf "Check remote accessible: %s" url
8888+ | Create_directory path ->
8989+ Fmt.pf ppf "Create directory: %a" Fpath.pp path
9090+ | Git_init path ->
9191+ Fmt.pf ppf "Initialize git repository: %a" Fpath.pp path
9292+ | Git_config { repo = _; key; value } ->
9393+ Fmt.pf ppf "Set git config %s = %s" key value
9494+ | Git_clone { url; dest; branch } ->
9595+ Fmt.pf ppf "Clone %s (branch: %s) to %a" url branch Fpath.pp dest
9696+ | Git_subtree_split { repo = _; prefix } ->
9797+ Fmt.pf ppf "Split subtree history for '%s'" prefix
9898+ | Git_subtree_add { repo = _; prefix; url; branch } ->
9999+ Fmt.pf ppf "Add subtree '%s' from %s (branch: %s)" prefix (Uri.to_string url) branch
100100+ | Git_add_remote { repo = _; name; url } ->
101101+ Fmt.pf ppf "Add remote '%s' -> %s" name url
102102+ | Git_push_ref { repo = _; target; ref_spec } ->
103103+ Fmt.pf ppf "Push %s to %s" ref_spec target
104104+ | Git_checkout { repo = _; branch } ->
105105+ Fmt.pf ppf "Checkout branch '%s'" branch
106106+ | Git_branch_rename { repo = _; new_name } ->
107107+ Fmt.pf ppf "Rename current branch to '%s'" new_name
108108+ | Copy_directory { src; dest } ->
109109+ Fmt.pf ppf "Copy files from %a to %a" Fpath.pp src Fpath.pp dest
110110+ | Git_add_all path ->
111111+ Fmt.pf ppf "Stage all changes in %a" Fpath.pp path
112112+ | Git_commit { repo = _; message } ->
113113+ Fmt.pf ppf "Create commit: %s" message
114114+ | Git_rm { repo = _; path; recursive = _ } ->
115115+ Fmt.pf ppf "Remove '%s' from git" path
116116+ | Update_sources_toml { path = _; name; entry = _ } ->
117117+ Fmt.pf ppf "Update sources.toml for '%s'" name
118118+119119+let pp_discovery ppf d =
120120+ Fmt.pf ppf "@[<v>";
121121+ Fmt.pf ppf " mono/<name>/: %s@,"
122122+ (if d.mono_exists then "exists" else "does not exist");
123123+ Fmt.pf ppf " src/<name>/: %s@,"
124124+ (if d.src_exists then "exists" else "does not exist");
125125+ Fmt.pf ppf " Subtree history: %s@,"
126126+ (if d.has_subtree_history then "present" else "none (fresh package)");
127127+ (match d.remote_accessible with
128128+ | None -> ()
129129+ | Some true -> Fmt.pf ppf " Remote accessible: yes@,"
130130+ | Some false -> Fmt.pf ppf " Remote accessible: no@,");
131131+ (match d.local_path_is_repo with
132132+ | None -> ()
133133+ | Some true -> Fmt.pf ppf " Is git repo: yes@,"
134134+ | Some false -> Fmt.pf ppf " Is git repo: no@,");
135135+ if d.opam_files <> [] then
136136+ Fmt.pf ppf " Packages found: %a@," Fmt.(list ~sep:(any ", ") string) d.opam_files;
137137+ Fmt.pf ppf "@]"
138138+139139+let pp_action_plan : type a. a Fmt.t -> a action_plan Fmt.t = fun pp_result ppf plan ->
140140+ Fmt.pf ppf "@[<v>Discovery:@,%a@,@,Actions to perform:@," pp_discovery plan.discovery;
141141+ List.iteri (fun i action ->
142142+ Fmt.pf ppf " %d. %a@," (i + 1) pp_action action
143143+ ) plan.actions;
144144+ if plan.dry_run then
145145+ Fmt.pf ppf "@,(dry-run mode - no changes will be made)@,";
146146+ Fmt.pf ppf "@,Expected result:@, %a@]" pp_result plan.result
147147+148148+let pp_error_with_hint ppf e =
149149+ pp_error ppf e;
150150+ match error_hint e with
151151+ | Some hint -> Fmt.pf ppf "@.@[<v 2>Hint: %s@]" hint
152152+ | None -> ()
153153+154154+type fork_result = {
155155+ name : string;
156156+ split_commit : string;
157157+ src_path : Fpath.t;
158158+ push_url : string option;
159159+ packages_created : string list;
160160+}
161161+162162+type join_result = {
163163+ name : string;
164164+ source_url : string;
165165+ upstream_url : string option;
166166+ packages_added : string list;
167167+ from_handle : string option;
168168+}
169169+170170+let pp_fork_result ppf (r : fork_result) =
171171+ (* Only truncate if it looks like a git SHA (40 hex chars), otherwise show full string *)
172172+ let commit_display =
173173+ if String.length r.split_commit = 40 &&
174174+ String.for_all (fun c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) r.split_commit
175175+ then String.sub r.split_commit 0 7
176176+ else r.split_commit
177177+ in
178178+ Fmt.pf ppf "@[<v>Forked subtree '%s':@, Split commit: %s@, Local repo: %a@,"
179179+ r.name commit_display Fpath.pp r.src_path;
180180+ (match r.push_url with
181181+ | Some url -> Fmt.pf ppf " Push URL: %s@," url
182182+ | None -> ());
183183+ if r.packages_created <> [] then
184184+ Fmt.pf ppf " Packages: %a@]" Fmt.(list ~sep:(any ", ") string) r.packages_created
185185+ else
186186+ Fmt.pf ppf "@]"
187187+188188+let pp_join_result ppf (r : join_result) =
189189+ Fmt.pf ppf "@[<v>Joined repository '%s':@, Source: %s@,"
190190+ r.name r.source_url;
191191+ (match r.upstream_url with
192192+ | Some url -> Fmt.pf ppf " Upstream: %s@," url
193193+ | None -> ());
194194+ (match r.from_handle with
195195+ | Some h -> Fmt.pf ppf " From verse: %s@," h
196196+ | None -> ());
197197+ if r.packages_added <> [] then
198198+ Fmt.pf ppf " Packages: %a@]" Fmt.(list ~sep:(any ", ") string) r.packages_added
199199+ else
200200+ Fmt.pf ppf "@]"
201201+202202+(** Helper to check if a path is a directory *)
203203+let is_directory ~fs path =
204204+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
205205+ match Eio.Path.kind ~follow:true eio_path with
206206+ | `Directory -> true
207207+ | _ -> false
208208+ | exception _ -> false
209209+210210+(** Helper to create a directory if it doesn't exist *)
211211+let ensure_dir ~fs path =
212212+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
213213+ try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ()
214214+215215+(** Scan a directory for .opam files *)
216216+let find_opam_files ~fs path =
217217+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
218218+ try
219219+ Eio.Path.read_dir eio_path
220220+ |> List.filter (fun name -> String.ends_with ~suffix:".opam" name)
221221+ |> List.map (fun name ->
222222+ (* Extract package name from filename.opam *)
223223+ String.sub name 0 (String.length name - 5))
224224+ with Eio.Io _ -> []
225225+226226+(** Normalize URL to git+ format for dev-repo *)
227227+let normalize_git_url url =
228228+ if String.starts_with ~prefix:"git+" url then url
229229+ else if String.starts_with ~prefix:"git://" url then url
230230+ else if String.starts_with ~prefix:"https://" url then "git+" ^ url
231231+ else if String.starts_with ~prefix:"http://" url then "git+" ^ url
232232+ else url
233233+234234+(** Check if host is a tangled host *)
235235+let is_tangled_host = function
236236+ | Some "tangled.org" | Some "tangled.sh" -> true
237237+ | _ -> false
238238+239239+(** Convert a dev-repo URL to a push URL (SSH format for github/gitlab/tangled) *)
240240+let url_to_push_url ?knot url =
241241+ (* Strip git+ prefix if present *)
242242+ let url =
243243+ if String.starts_with ~prefix:"git+" url then
244244+ String.sub url 4 (String.length url - 4)
245245+ else url
246246+ in
247247+ let uri = Uri.of_string url in
248248+ let scheme = Uri.scheme uri in
249249+ let host = Uri.host uri in
250250+ let path = Uri.path uri in
251251+ match (scheme, host) with
252252+ | Some ("https" | "http"), Some "github.com" ->
253253+ (* https://github.com/user/repo.git -> git@github.com:user/repo.git *)
254254+ let path =
255255+ if String.length path > 0 && path.[0] = '/' then
256256+ String.sub path 1 (String.length path - 1)
257257+ else path
258258+ in
259259+ Printf.sprintf "git@github.com:%s" path
260260+ | Some ("https" | "http"), Some "gitlab.com" ->
261261+ (* https://gitlab.com/user/repo.git -> git@gitlab.com:user/repo.git *)
262262+ let path =
263263+ if String.length path > 0 && path.[0] = '/' then
264264+ String.sub path 1 (String.length path - 1)
265265+ else path
266266+ in
267267+ Printf.sprintf "git@gitlab.com:%s" path
268268+ | Some ("https" | "http"), _ when is_tangled_host host ->
269269+ (* https://tangled.sh/@handle/repo -> git@<knot>:handle/repo *)
270270+ let path =
271271+ if String.length path > 0 && path.[0] = '/' then
272272+ String.sub path 1 (String.length path - 1)
273273+ else path
274274+ in
275275+ (* Strip leading @ from handle if present *)
276276+ let path =
277277+ if String.length path > 0 && path.[0] = '@' then
278278+ String.sub path 1 (String.length path - 1)
279279+ else path
280280+ in
281281+ (* Strip .git suffix if present *)
282282+ let path =
283283+ if String.ends_with ~suffix:".git" path then
284284+ String.sub path 0 (String.length path - 4)
285285+ else path
286286+ in
287287+ (* Use provided knot or default to git.recoil.org *)
288288+ let knot_server = Option.value ~default:"git.recoil.org" knot in
289289+ Printf.sprintf "git@%s:%s" knot_server path
290290+ | _ ->
291291+ (* Return original URL for other cases *)
292292+ url
293293+294294+(** Check if a URL is in the user's own namespace (not a true fork) *)
295295+let is_own_namespace ~handle url =
296296+ (* Extract user/handle from URL and compare with config handle *)
297297+ let url =
298298+ if String.starts_with ~prefix:"git+" url then
299299+ String.sub url 4 (String.length url - 4)
300300+ else url
301301+ in
302302+ (* For SSH URLs like git@github.com:user/repo.git *)
303303+ if String.starts_with ~prefix:"git@" url then
304304+ match String.index_opt url ':' with
305305+ | Some i ->
306306+ let path = String.sub url (i + 1) (String.length url - i - 1) in
307307+ (* path is like "user/repo.git" or "handle/repo" *)
308308+ (match String.index_opt path '/' with
309309+ | Some j ->
310310+ let user = String.sub path 0 j in
311311+ (* Handle may be like "avsm" or "avsm.bsky.social" - compare first component *)
312312+ let handle_first =
313313+ match String.index_opt handle '.' with
314314+ | Some k -> String.sub handle 0 k
315315+ | None -> handle
316316+ in
317317+ String.equal user handle_first || String.equal user handle
318318+ | None -> false)
319319+ | None -> false
320320+ else
321321+ (* For HTTPS URLs like https://github.com/user/repo.git *)
322322+ let uri = Uri.of_string url in
323323+ let path = Uri.path uri in
324324+ let path =
325325+ if String.length path > 0 && path.[0] = '/' then
326326+ String.sub path 1 (String.length path - 1)
327327+ else path
328328+ in
329329+ (* path is like "user/repo.git" or "@handle/repo" *)
330330+ let path =
331331+ if String.length path > 0 && path.[0] = '@' then
332332+ String.sub path 1 (String.length path - 1)
333333+ else path
334334+ in
335335+ match String.index_opt path '/' with
336336+ | Some j ->
337337+ let user = String.sub path 0 j in
338338+ let handle_first =
339339+ match String.index_opt handle '.' with
340340+ | Some k -> String.sub handle 0 k
341341+ | None -> handle
342342+ in
343343+ String.equal user handle_first || String.equal user handle
344344+ | None -> false
345345+346346+(** Try to get a suggested push URL from dune-project in the subtree *)
347347+let suggest_push_url ~fs ?knot subtree_path =
348348+ let dune_project_path = Fpath.(subtree_path / "dune-project") in
349349+ let eio_path = Eio.Path.(fs / Fpath.to_string dune_project_path) in
350350+ try
351351+ let content = Eio.Path.load eio_path in
352352+ match Dune_project.parse content with
353353+ | Error _ -> None
354354+ | Ok dune_proj ->
355355+ match Dune_project.dev_repo_url dune_proj with
356356+ | Error _ -> None
357357+ | Ok dev_repo -> Some (url_to_push_url ?knot dev_repo)
358358+ with Eio.Io _ -> None
359359+360360+(** Extract name from URL (last path component without .git suffix) *)
361361+let name_from_url url =
362362+ let uri = Uri.of_string url in
363363+ let path = Uri.path uri in
364364+ (* Remove leading slash and .git suffix *)
365365+ let path = if String.length path > 0 && path.[0] = '/' then
366366+ String.sub path 1 (String.length path - 1)
367367+ else path in
368368+ let path = if String.ends_with ~suffix:".git" path then
369369+ String.sub path 0 (String.length path - 4)
370370+ else path in
371371+ (* Get last component *)
372372+ match String.rindex_opt path '/' with
373373+ | Some i -> String.sub path (i + 1) (String.length path - i - 1)
374374+ | None -> path
375375+376376+(** {1 Detection Functions} *)
377377+378378+(** Determine if input is a local path or URL *)
379379+let is_local_path s =
380380+ (* It's a URL if it starts with a scheme or looks like SSH URL *)
381381+ not (String.starts_with ~prefix:"http://" s ||
382382+ String.starts_with ~prefix:"https://" s ||
383383+ String.starts_with ~prefix:"git://" s ||
384384+ String.starts_with ~prefix:"git@" s ||
385385+ String.starts_with ~prefix:"ssh://" s ||
386386+ String.starts_with ~prefix:"git+" s)
387387+388388+(** Copy a directory tree recursively *)
389389+let copy_directory ~fs ~src ~dest =
390390+ let src_eio = Eio.Path.(fs / Fpath.to_string src) in
391391+ let dest_eio = Eio.Path.(fs / Fpath.to_string dest) in
392392+ let rec copy_rec src_path dest_path =
393393+ match Eio.Path.kind ~follow:false src_path with
394394+ | `Directory ->
395395+ (try Eio.Path.mkdirs ~perm:0o755 dest_path with Eio.Io _ -> ());
396396+ List.iter (fun name ->
397397+ (* Skip .git directory to avoid copying git internals *)
398398+ if name <> ".git" then begin
399399+ let src_child = Eio.Path.(src_path / name) in
400400+ let dest_child = Eio.Path.(dest_path / name) in
401401+ copy_rec src_child dest_child
402402+ end
403403+ ) (Eio.Path.read_dir src_path)
404404+ | `Regular_file ->
405405+ let content = Eio.Path.load src_path in
406406+ Eio.Path.save ~create:(`Or_truncate 0o644) dest_path content
407407+ | `Symbolic_link ->
408408+ (* Read symlink target and recreate it *)
409409+ let target = Eio.Path.read_link src_path in
410410+ (try Unix.symlink target (snd dest_path) with _ -> ())
411411+ | _ -> () (* Skip other file types *)
412412+ | exception _ -> ()
413413+ in
414414+ copy_rec src_eio dest_eio
415415+416416+(** {1 Plan Builders} *)
417417+418418+(** Build a fork plan - handles both subtree and fresh package scenarios.
419419+420420+ The fork workflow:
421421+ 1. Create src/<name>/ with the package content (split or copy)
422422+ 2. Remove mono/<name>/ from git
423423+ 3. Re-add mono/<name>/ as a proper subtree from src/<name>/
424424+425425+ This ensures the subtree relationship is properly established for sync. *)
426426+let plan_fork ~proc ~fs ~config ~name ?push_url ?(dry_run = false) () =
427427+ let monorepo = Verse_config.mono_path config in
428428+ let checkouts = Verse_config.src_path config in
429429+ let prefix = name in
430430+ let subtree_path = Fpath.(monorepo / prefix) in
431431+ let src_path = Fpath.(checkouts / name) in
432432+ let branch = Verse_config.default_branch in
433433+434434+ (* Gather discovery information *)
435435+ let mono_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix in
436436+ let src_exists = is_directory ~fs src_path in
437437+ let has_subtree_hist =
438438+ if mono_exists then Git.has_subtree_history ~proc ~fs ~repo:monorepo ~prefix ()
439439+ else false
440440+ in
441441+ let opam_files =
442442+ if mono_exists then find_opam_files ~fs subtree_path
443443+ else []
444444+ in
445445+446446+ let discovery = {
447447+ mono_exists;
448448+ src_exists;
449449+ has_subtree_history = has_subtree_hist;
450450+ remote_accessible = None; (* Could check if push_url is accessible *)
451451+ opam_files;
452452+ local_path_is_repo = None;
453453+ } in
454454+455455+ (* Validation *)
456456+ if not mono_exists then
457457+ Error (Subtree_not_found name)
458458+ else if src_exists then
459459+ Error (Src_already_exists name)
460460+ else if opam_files = [] then
461461+ Error (No_opam_files name)
462462+ else begin
463463+ (* Build actions for complete fork workflow:
464464+ 1. Create src/<name>/ with content
465465+ 2. Remove mono/<name>/ and commit
466466+ 3. Re-add as subtree from src/<name>/ *)
467467+ let create_src_actions =
468468+ if has_subtree_hist then
469469+ (* Subtree with history: split and push to new repo *)
470470+ [
471471+ Create_directory checkouts;
472472+ Git_subtree_split { repo = monorepo; prefix };
473473+ Git_init src_path;
474474+ (* Allow pushing to checked-out branch (for monopam sync) *)
475475+ Git_config { repo = src_path; key = "receive.denyCurrentBranch"; value = "updateInstead" };
476476+ Git_add_remote { repo = src_path; name = "mono"; url = Fpath.to_string monorepo };
477477+ Git_push_ref { repo = monorepo; target = Fpath.to_string src_path; ref_spec = "SPLIT_COMMIT:refs/heads/main" };
478478+ Git_checkout { repo = src_path; branch };
479479+ ]
480480+ else
481481+ (* Fresh package: copy files and create initial commit *)
482482+ [
483483+ Create_directory checkouts;
484484+ Create_directory src_path;
485485+ Git_init src_path;
486486+ (* Allow pushing to checked-out branch (for monopam sync) *)
487487+ Git_config { repo = src_path; key = "receive.denyCurrentBranch"; value = "updateInstead" };
488488+ Git_branch_rename { repo = src_path; new_name = branch };
489489+ Copy_directory { src = subtree_path; dest = src_path };
490490+ Git_add_all src_path;
491491+ Git_commit { repo = src_path; message = Fmt.str "Initial commit of %s" name };
492492+ ]
493493+ in
494494+495495+ (* Add remote if push_url provided *)
496496+ let remote_actions = match push_url with
497497+ | Some url -> [ Git_add_remote { repo = src_path; name = "origin"; url } ]
498498+ | None -> []
499499+ in
500500+501501+ (* Remove from mono and re-add as subtree *)
502502+ let rejoin_actions = [
503503+ Git_rm { repo = monorepo; path = prefix; recursive = true };
504504+ Git_commit { repo = monorepo; message = Fmt.str "Remove %s for fork" name };
505505+ Git_subtree_add { repo = monorepo; prefix; url = Uri.of_string (Fpath.to_string src_path); branch };
506506+ ] in
507507+508508+ (* Update sources.toml only if push_url is a true fork (different namespace) *)
509509+ let handle = Verse_config.handle config in
510510+ let sources_actions = match push_url with
511511+ | Some url when not (is_own_namespace ~handle url) -> [
512512+ Update_sources_toml {
513513+ path = Fpath.(monorepo / "sources.toml");
514514+ name;
515515+ entry = Sources_registry.{
516516+ url = normalize_git_url url;
517517+ upstream = None;
518518+ branch = Some branch;
519519+ reason = None;
520520+ origin = Some Fork;
521521+ };
522522+ };
523523+ ]
524524+ | Some _ -> [] (* Own namespace - no sources.toml entry needed *)
525525+ | None -> []
526526+ in
527527+528528+ let actions = create_src_actions @ remote_actions @ rejoin_actions @ sources_actions in
529529+530530+ let result = {
531531+ name;
532532+ split_commit = if has_subtree_hist then "(will be computed)" else "(fresh package)";
533533+ src_path;
534534+ push_url;
535535+ packages_created = opam_files;
536536+ } in
537537+538538+ Ok { discovery; actions; result; dry_run }
539539+ end
540540+541541+(** Build a join plan - handles both URL and local path *)
542542+let plan_join ~proc ~fs ~config ~source ?name ?upstream ?(dry_run = false) () =
543543+ let is_local = is_local_path source in
544544+ let name = match name with Some n -> n | None -> name_from_url source in
545545+ let monorepo = Verse_config.mono_path config in
546546+ let checkouts = Verse_config.src_path config in
547547+ let prefix = name in
548548+ let src_path = Fpath.(checkouts / name) in
549549+550550+ (* Gather discovery information *)
551551+ let subtree_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix in
552552+ let src_exists = is_directory ~fs src_path in
553553+ let local_is_repo =
554554+ if is_local then begin
555555+ match Fpath.of_string source with
556556+ | Ok path -> Some (Git.is_repo ~proc ~fs path)
557557+ | Error _ -> Some false
558558+ end else None
559559+ in
560560+561561+ let discovery = {
562562+ mono_exists = subtree_exists;
563563+ src_exists;
564564+ has_subtree_history = false;
565565+ remote_accessible = None;
566566+ opam_files = []; (* Will be discovered after join *)
567567+ local_path_is_repo = local_is_repo;
568568+ } in
569569+570570+ (* Validation *)
571571+ if subtree_exists then
572572+ Error (Subtree_already_exists name)
573573+ else begin
574574+ let branch = Verse_config.default_branch in
575575+ let actions =
576576+ if is_local then begin
577577+ (* Join from local directory *)
578578+ match Fpath.of_string source with
579579+ | Error (`Msg msg) -> raise (Invalid_argument msg)
580580+ | Ok local_path ->
581581+ let has_repo = Option.value ~default:false local_is_repo in
582582+ if has_repo then
583583+ (* Local git repo - use it directly *)
584584+ [
585585+ Create_directory checkouts;
586586+ Copy_directory { src = local_path; dest = src_path };
587587+ Git_subtree_add { repo = monorepo; prefix; url = Uri.of_string (Fpath.to_string src_path); branch };
588588+ ]
589589+ else
590590+ (* Local directory without git - init and commit first *)
591591+ [
592592+ Create_directory checkouts;
593593+ Create_directory src_path;
594594+ Git_init src_path;
595595+ Copy_directory { src = local_path; dest = src_path };
596596+ Git_add_all src_path;
597597+ Git_commit { repo = src_path; message = Fmt.str "Initial commit of %s" name };
598598+ Git_branch_rename { repo = src_path; new_name = branch }; (* Ensure branch is named correctly *)
599599+ Git_subtree_add { repo = monorepo; prefix; url = Uri.of_string (Fpath.to_string src_path); branch };
600600+ ]
601601+ end else begin
602602+ (* Join from URL (existing behavior) *)
603603+ let url_uri = Uri.of_string source in
604604+ let base_actions = [
605605+ Create_directory checkouts;
606606+ Git_clone { url = source; dest = src_path; branch };
607607+ Git_subtree_add { repo = monorepo; prefix; url = url_uri; branch };
608608+ ] in
609609+ let sources_actions = match upstream with
610610+ | Some _ ->
611611+ [Update_sources_toml {
612612+ path = Fpath.(monorepo / "sources.toml");
613613+ name;
614614+ entry = Sources_registry.{
615615+ url = normalize_git_url source;
616616+ upstream = Option.map normalize_git_url upstream;
617617+ branch = Some branch;
618618+ reason = None;
619619+ origin = Some Join;
620620+ };
621621+ }]
622622+ | None -> []
623623+ in
624624+ base_actions @ sources_actions
625625+ end
626626+ in
627627+628628+ (* Peek at opam files if local *)
629629+ let opam_preview =
630630+ if is_local then
631631+ match Fpath.of_string source with
632632+ | Ok path -> find_opam_files ~fs path
633633+ | Error _ -> []
634634+ else []
635635+ in
636636+637637+ let result = {
638638+ name;
639639+ source_url = source;
640640+ upstream_url = upstream;
641641+ packages_added = opam_preview;
642642+ from_handle = None;
643643+ } in
644644+645645+ Ok { discovery = { discovery with opam_files = opam_preview }; actions; result; dry_run }
646646+ end
647647+648648+(** Build a rejoin plan - add existing src/<name> back into mono/<name> *)
649649+let plan_rejoin ~proc ~fs ~config ~name ?(dry_run = false) () =
650650+ let monorepo = Verse_config.mono_path config in
651651+ let checkouts = Verse_config.src_path config in
652652+ let prefix = name in
653653+ let src_path = Fpath.(checkouts / name) in
654654+655655+ (* Gather discovery information *)
656656+ let subtree_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix in
657657+ let src_exists = is_directory ~fs src_path in
658658+ let src_is_repo = if src_exists then Git.is_repo ~proc ~fs src_path else false in
659659+ let opam_files = if src_exists then find_opam_files ~fs src_path else [] in
660660+661661+ let discovery = {
662662+ mono_exists = subtree_exists;
663663+ src_exists;
664664+ has_subtree_history = false;
665665+ remote_accessible = None;
666666+ opam_files;
667667+ local_path_is_repo = Some src_is_repo;
668668+ } in
669669+670670+ (* Validation *)
671671+ if subtree_exists then
672672+ Error (Subtree_already_exists name)
673673+ else if not src_exists then
674674+ Error (Src_not_found name)
675675+ else if not src_is_repo then
676676+ Error (Config_error (Fmt.str "src/%s exists but is not a git repository" name))
677677+ else begin
678678+ let branch = Verse_config.default_branch in
679679+ let actions = [
680680+ Git_subtree_add { repo = monorepo; prefix; url = Uri.of_string (Fpath.to_string src_path); branch };
681681+ ] in
682682+683683+ let result = {
684684+ name;
685685+ source_url = Fpath.to_string src_path;
686686+ upstream_url = None;
687687+ packages_added = opam_files;
688688+ from_handle = None;
689689+ } in
690690+691691+ Ok { discovery; actions; result; dry_run }
692692+ end
693693+694694+(** {1 Plan Execution} *)
695695+696696+(** State tracked during plan execution *)
697697+type exec_state = {
698698+ mutable split_commit: string option;
699699+}
700700+701701+(** Execute a single action *)
702702+let execute_action ~proc ~fs ~state action =
703703+ match action with
704704+ | Check_remote_exists _url ->
705705+ (* Informational only - always succeeds *)
706706+ Ok ()
707707+ | Create_directory path ->
708708+ ensure_dir ~fs path;
709709+ Ok ()
710710+ | Git_init path ->
711711+ Git.init ~proc ~fs path |> Result.map_error (fun e -> Git_error e)
712712+ | Git_config { repo; key; value } ->
713713+ Git.config ~proc ~fs ~key ~value repo |> Result.map_error (fun e -> Git_error e)
714714+ | Git_clone { url; dest; branch } ->
715715+ Git.clone ~proc ~fs ~url:(Uri.of_string url) ~branch dest
716716+ |> Result.map_error (fun e -> Git_error e)
717717+ | Git_subtree_split { repo; prefix } ->
718718+ Git.Subtree.split ~proc ~fs ~repo ~prefix ()
719719+ |> Result.map (fun commit -> state.split_commit <- Some commit)
720720+ |> Result.map_error (fun e -> Git_error e)
721721+ | Git_subtree_add { repo; prefix; url; branch } ->
722722+ Git.Subtree.add ~proc ~fs ~repo ~prefix ~url ~branch ()
723723+ |> Result.map_error (fun e -> Git_error e)
724724+ | Git_add_remote { repo; name; url } ->
725725+ Git.add_remote ~proc ~fs ~name ~url repo
726726+ |> Result.map_error (fun e -> Git_error e)
727727+ | Git_push_ref { repo; target; ref_spec } ->
728728+ (* Replace SPLIT_COMMIT placeholder with actual commit if available *)
729729+ let ref_spec =
730730+ match state.split_commit with
731731+ | Some commit -> String.concat "" (String.split_on_char 'S' (String.concat commit (String.split_on_char 'S' ref_spec)))
732732+ |> fun s -> if String.starts_with ~prefix:"PLIT_COMMIT" s then
733733+ Option.value ~default:ref_spec state.split_commit ^ String.sub s 11 (String.length s - 11)
734734+ else s
735735+ | None -> ref_spec
736736+ in
737737+ (* Better replacement: look for SPLIT_COMMIT literal *)
738738+ let ref_spec =
739739+ match state.split_commit with
740740+ | Some commit ->
741741+ if String.length ref_spec >= 12 && String.sub ref_spec 0 12 = "SPLIT_COMMIT" then
742742+ commit ^ String.sub ref_spec 12 (String.length ref_spec - 12)
743743+ else ref_spec
744744+ | None -> ref_spec
745745+ in
746746+ Git.push_ref ~proc ~fs ~repo ~target ~ref_spec ()
747747+ |> Result.map_error (fun e -> Git_error e)
748748+ | Git_checkout { repo; branch } ->
749749+ Git.checkout ~proc ~fs ~branch repo
750750+ |> Result.map_error (fun e -> Git_error e)
751751+ | Git_branch_rename { repo; new_name } ->
752752+ Git.branch_rename ~proc ~fs ~new_name repo
753753+ |> Result.map_error (fun e -> Git_error e)
754754+ | Copy_directory { src; dest } ->
755755+ copy_directory ~fs ~src ~dest;
756756+ Ok ()
757757+ | Git_add_all path ->
758758+ Git.add_all ~proc ~fs path
759759+ |> Result.map_error (fun e -> Git_error e)
760760+ | Git_commit { repo; message } ->
761761+ Git.commit ~proc ~fs ~message repo
762762+ |> Result.map_error (fun e -> Git_error e)
763763+ | Git_rm { repo; path; recursive } ->
764764+ Git.rm ~proc ~fs ~recursive repo path
765765+ |> Result.map_error (fun e -> Git_error e)
766766+ | Update_sources_toml { path; name; entry } ->
767767+ let sources =
768768+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) path with
769769+ | Ok s -> s
770770+ | Error _ -> Sources_registry.empty
771771+ in
772772+ let sources = Sources_registry.add sources ~subtree:name entry in
773773+ (match Sources_registry.save ~fs:(fs :> _ Eio.Path.t) path sources with
774774+ | Ok () -> Ok ()
775775+ | Error msg -> Error (Config_error (Fmt.str "Failed to update sources.toml: %s" msg)))
776776+777777+(** Execute a complete fork action plan *)
778778+let execute_fork_plan ~proc ~fs plan =
779779+ if plan.dry_run then
780780+ Ok plan.result
781781+ else begin
782782+ let state = { split_commit = None } in
783783+ let rec run_actions = function
784784+ | [] -> Ok ()
785785+ | action :: rest ->
786786+ match execute_action ~proc ~fs ~state action with
787787+ | Error e -> Error e
788788+ | Ok () -> run_actions rest
789789+ in
790790+ match run_actions plan.actions with
791791+ | Error e -> Error e
792792+ | Ok () ->
793793+ (* Update result with actual split commit if available *)
794794+ let result : fork_result =
795795+ match state.split_commit with
796796+ | Some commit -> { plan.result with split_commit = commit }
797797+ | None -> plan.result
798798+ in
799799+ Ok result
800800+ end
801801+802802+(** Execute a complete join action plan *)
803803+let execute_join_plan ~proc ~fs plan =
804804+ if plan.dry_run then
805805+ Ok plan.result
806806+ else begin
807807+ let state = { split_commit = None } in
808808+ let rec run_actions = function
809809+ | [] -> Ok ()
810810+ | action :: rest ->
811811+ match execute_action ~proc ~fs ~state action with
812812+ | Error e -> Error e
813813+ | Ok () -> run_actions rest
814814+ in
815815+ match run_actions plan.actions with
816816+ | Error e -> Error e
817817+ | Ok () -> Ok plan.result
818818+ end
819819+820820+(** {1 Legacy API (using plans internally)} *)
821821+822822+let fork ~proc ~fs ~config ~name ?push_url ?(dry_run = false) () =
823823+ let monorepo = Verse_config.mono_path config in
824824+ let checkouts = Verse_config.src_path config in
825825+ let prefix = name in
826826+ let subtree_path = Fpath.(monorepo / prefix) in
827827+ let src_path = Fpath.(checkouts / name) in
828828+ (* Validate: mono/<name>/ must exist *)
829829+ if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then
830830+ Error (Subtree_not_found name)
831831+ (* Validate: src/<name>/ must not exist *)
832832+ else if is_directory ~fs src_path then
833833+ Error (Src_already_exists name)
834834+ else begin
835835+ (* Find .opam files in subtree *)
836836+ let packages = find_opam_files ~fs subtree_path in
837837+ if packages = [] then
838838+ Error (No_opam_files name)
839839+ else if dry_run then
840840+ Ok { name; split_commit = "(dry-run)"; src_path; push_url; packages_created = packages }
841841+ else begin
842842+ (* Split the subtree to get history *)
843843+ match Git.Subtree.split ~proc ~fs ~repo:monorepo ~prefix () with
844844+ | Error e -> Error (Git_error e)
845845+ | Ok split_commit ->
846846+ (* Ensure src/ exists *)
847847+ ensure_dir ~fs checkouts;
848848+ (* Initialize new git repo at src/<name>/ *)
849849+ match Git.init ~proc ~fs src_path with
850850+ | Error e -> Error (Git_error e)
851851+ | Ok () ->
852852+ (* Add 'origin' remote pointing to monorepo path temporarily *)
853853+ let mono_str = Fpath.to_string monorepo in
854854+ (match Git.add_remote ~proc ~fs ~name:"mono" ~url:mono_str src_path with
855855+ | Error e -> Error (Git_error e)
856856+ | Ok () ->
857857+ (* Push split commit to local repo *)
858858+ let ref_spec = split_commit ^ ":refs/heads/main" in
859859+ match Git.push_ref ~proc ~fs ~repo:monorepo ~target:(Fpath.to_string src_path) ~ref_spec () with
860860+ | Error e -> Error (Git_error e)
861861+ | Ok () ->
862862+ (* Checkout main branch *)
863863+ (match Git.checkout ~proc ~fs ~branch:"main" src_path with
864864+ | Error e -> Error (Git_error e)
865865+ | Ok () ->
866866+ (* Set push URL if provided *)
867867+ let push_result =
868868+ match push_url with
869869+ | Some url ->
870870+ (match Git.add_remote ~proc ~fs ~name:"origin" ~url src_path with
871871+ | Error e -> Error (Git_error e)
872872+ | Ok () -> Ok ())
873873+ | None -> Ok ()
874874+ in
875875+ match push_result with
876876+ | Error _ as e -> e
877877+ | Ok () ->
878878+ (* Only update sources.toml if there's a push URL *)
879879+ (match push_url with
880880+ | Some url ->
881881+ let sources_path = Fpath.(monorepo / "sources.toml") in
882882+ let sources =
883883+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
884884+ | Ok s -> s
885885+ | Error _ -> Sources_registry.empty
886886+ in
887887+ let entry = Sources_registry.{
888888+ url = normalize_git_url url;
889889+ upstream = None;
890890+ branch = Some "main";
891891+ reason = None;
892892+ origin = Some Fork;
893893+ } in
894894+ let sources = Sources_registry.add sources ~subtree:name entry in
895895+ (match Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
896896+ | Ok () -> ()
897897+ | Error msg -> Logs.warn (fun m -> m "Failed to update sources.toml: %s" msg))
898898+ | None -> ());
899899+ Ok { name; split_commit; src_path; push_url; packages_created = packages }))
900900+ end
901901+ end
902902+903903+let join ~proc ~fs ~config ~url ?name ?upstream ?(dry_run = false) () =
904904+ let name = match name with Some n -> n | None -> name_from_url url in
905905+ let monorepo = Verse_config.mono_path config in
906906+ let checkouts = Verse_config.src_path config in
907907+ let prefix = name in
908908+ let subtree_path = Fpath.(monorepo / prefix) in
909909+ let src_path = Fpath.(checkouts / name) in
910910+ (* Validate: mono/<name>/ must not exist *)
911911+ if Git.Subtree.exists ~fs ~repo:monorepo ~prefix then
912912+ Error (Subtree_already_exists name)
913913+ else if dry_run then
914914+ Ok { name; source_url = url; upstream_url = upstream; packages_added = []; from_handle = None }
915915+ else begin
916916+ (* Ensure src/ exists *)
917917+ ensure_dir ~fs checkouts;
918918+ (* Clone to src/<name>/ *)
919919+ let branch = Verse_config.default_branch in
920920+ let uri = Uri.of_string url in
921921+ match Git.clone ~proc ~fs ~url:uri ~branch src_path with
922922+ | Error e -> Error (Git_error e)
923923+ | Ok () ->
924924+ (* Add subtree to monorepo *)
925925+ match Git.Subtree.add ~proc ~fs ~repo:monorepo ~prefix ~url:uri ~branch () with
926926+ | Error e -> Error (Git_error e)
927927+ | Ok () ->
928928+ (* Find .opam files in the new subtree *)
929929+ let packages = find_opam_files ~fs subtree_path in
930930+ (* Only update sources.toml if there's an upstream to track *)
931931+ (match upstream with
932932+ | Some _ ->
933933+ let sources_path = Fpath.(monorepo / "sources.toml") in
934934+ let sources =
935935+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
936936+ | Ok s -> s
937937+ | Error _ -> Sources_registry.empty
938938+ in
939939+ let entry = Sources_registry.{
940940+ url = normalize_git_url url;
941941+ upstream = Option.map normalize_git_url upstream;
942942+ branch = Some branch;
943943+ reason = None;
944944+ origin = Some Join;
945945+ } in
946946+ let sources = Sources_registry.add sources ~subtree:name entry in
947947+ (match Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
948948+ | Ok () -> ()
949949+ | Error msg -> Logs.warn (fun m -> m "Failed to update sources.toml: %s" msg))
950950+ | None -> ());
951951+ Ok { name; source_url = url; upstream_url = upstream; packages_added = packages; from_handle = None }
952952+ end
953953+954954+let join_from_verse ~proc ~fs ~config ~verse_config ~package ~handle ~fork_url ?(dry_run = false) () =
955955+ (* First use verse fork to set up the opam entries *)
956956+ match Verse.fork ~proc ~fs ~config:verse_config ~handle ~package ~fork_url ~dry_run () with
957957+ | Error e -> Error (Verse_error e)
958958+ | Ok fork_result ->
959959+ if dry_run then
960960+ Ok {
961961+ name = fork_result.subtree_name;
962962+ source_url = fork_url;
963963+ upstream_url = Some fork_result.upstream_url;
964964+ packages_added = fork_result.packages_forked;
965965+ from_handle = Some handle;
966966+ }
967967+ else begin
968968+ (* Now join the repository *)
969969+ let name = fork_result.subtree_name in
970970+ match join ~proc ~fs ~config ~url:fork_url ~name ~upstream:fork_result.upstream_url ~dry_run () with
971971+ | Error e -> Error e
972972+ | Ok join_result ->
973973+ Ok { join_result with
974974+ packages_added = fork_result.packages_forked;
975975+ from_handle = Some handle;
976976+ }
977977+ end
+295
lib/fork_join.mli
···11+(** Fork and join operations for managing monorepo sources.
22+33+ This module provides operations to:
44+ - Fork: Split a monorepo subtree into its own repository in src/
55+ - Join: Bring an external repository into the monorepo as a subtree
66+77+ Both operations update sources.toml to track the origin of each source.
88+99+ The module supports an action-based workflow where commands:
1010+ 1. Analyze current state
1111+ 2. Build a list of actions with reasoning
1212+ 3. Display the plan with discovery details
1313+ 4. Prompt for confirmation (or skip with [--yes])
1414+ 5. Execute actions sequentially *)
1515+1616+(** {1 Error Types} *)
1717+1818+type error =
1919+ | Config_error of string (** Configuration error *)
2020+ | Git_error of Git.error (** Git operation failed *)
2121+ | Subtree_not_found of string (** Subtree not found in monorepo *)
2222+ | Src_already_exists of string (** Source checkout already exists *)
2323+ | Src_not_found of string (** Source checkout not found *)
2424+ | Subtree_already_exists of string (** Subtree already exists in monorepo *)
2525+ | No_opam_files of string (** No .opam files found in subtree *)
2626+ | Verse_error of Verse.error (** Error from verse operations *)
2727+ | User_cancelled (** User declined to proceed *)
2828+2929+val pp_error : error Fmt.t
3030+(** [pp_error] formats errors. *)
3131+3232+val pp_error_with_hint : error Fmt.t
3333+(** [pp_error_with_hint] formats errors with helpful hints. *)
3434+3535+val error_hint : error -> string option
3636+(** [error_hint e] returns a hint string for the given error, if available. *)
3737+3838+(** {1 Action Types} *)
3939+4040+(** An action to be performed during fork/join *)
4141+type action =
4242+ | Check_remote_exists of string (** URL - informational check *)
4343+ | Create_directory of Fpath.t
4444+ | Git_init of Fpath.t
4545+ | Git_config of { repo: Fpath.t; key: string; value: string } (** Set git config *)
4646+ | Git_clone of { url: string; dest: Fpath.t; branch: string }
4747+ | Git_subtree_split of { repo: Fpath.t; prefix: string }
4848+ | Git_subtree_add of { repo: Fpath.t; prefix: string; url: Uri.t; branch: string }
4949+ | Git_add_remote of { repo: Fpath.t; name: string; url: string }
5050+ | Git_push_ref of { repo: Fpath.t; target: string; ref_spec: string }
5151+ | Git_checkout of { repo: Fpath.t; branch: string }
5252+ | Git_branch_rename of { repo: Fpath.t; new_name: string } (** Rename current branch *)
5353+ | Copy_directory of { src: Fpath.t; dest: Fpath.t }
5454+ | Git_add_all of Fpath.t
5555+ | Git_commit of { repo: Fpath.t; message: string }
5656+ | Git_rm of { repo: Fpath.t; path: string; recursive: bool } (** Remove from git *)
5757+ | Update_sources_toml of { path: Fpath.t; name: string; entry: Sources_registry.entry }
5858+5959+(** Discovery information gathered during planning *)
6060+type discovery = {
6161+ mono_exists: bool; (** Does mono/<name>/ exist? *)
6262+ src_exists: bool; (** Does src/<name>/ exist? *)
6363+ has_subtree_history: bool; (** Can we git subtree split? *)
6464+ remote_accessible: bool option; (** None = not checked, Some = result *)
6565+ opam_files: string list; (** Package names found from .opam files *)
6666+ local_path_is_repo: bool option; (** For join from local dir *)
6767+}
6868+6969+(** A complete action plan *)
7070+type 'a action_plan = {
7171+ discovery: discovery;
7272+ actions: action list;
7373+ result: 'a; (** What we'll return on success *)
7474+ dry_run: bool;
7575+}
7676+7777+val pp_action : action Fmt.t
7878+(** [pp_action] formats a single action. *)
7979+8080+val pp_discovery : discovery Fmt.t
8181+(** [pp_discovery] formats discovery information. *)
8282+8383+val pp_action_plan : 'a Fmt.t -> 'a action_plan Fmt.t
8484+(** [pp_action_plan pp_result] formats a complete action plan. *)
8585+8686+(** {1 Detection Functions} *)
8787+8888+val is_local_path : string -> bool
8989+(** [is_local_path s] returns true if [s] looks like a local filesystem path
9090+ rather than a URL. *)
9191+9292+val suggest_push_url : fs:Eio.Fs.dir_ty Eio.Path.t -> ?knot:string -> Fpath.t -> string option
9393+(** [suggest_push_url ~fs ?knot subtree_path] tries to derive a push URL from the
9494+ dune-project file in the subtree. Returns [Some url] if a source URL can
9595+ be found and converted to SSH push format, [None] otherwise.
9696+9797+ @param knot Optional git push server for tangled URLs (default: git.recoil.org) *)
9898+9999+(** {1 Result Types} *)
100100+101101+(** Result of a fork operation. *)
102102+type fork_result = {
103103+ name : string; (** Subtree/repository name *)
104104+ split_commit : string; (** Git commit SHA from subtree split *)
105105+ src_path : Fpath.t; (** Path to the new source checkout *)
106106+ push_url : string option; (** Remote push URL if provided *)
107107+ packages_created : string list; (** Package names from .opam files *)
108108+}
109109+110110+val pp_fork_result : fork_result Fmt.t
111111+(** [pp_fork_result] formats a fork result. *)
112112+113113+(** Result of a join operation. *)
114114+type join_result = {
115115+ name : string; (** Subtree/repository name *)
116116+ source_url : string; (** URL the repository was cloned from *)
117117+ upstream_url : string option; (** Original upstream if this is a fork *)
118118+ packages_added : string list; (** Package names from .opam files *)
119119+ from_handle : string option; (** Verse handle if joined from verse *)
120120+}
121121+122122+val pp_join_result : join_result Fmt.t
123123+(** [pp_join_result] formats a join result. *)
124124+125125+(** {1 Plan Builders} *)
126126+127127+val plan_fork :
128128+ proc:_ Eio.Process.mgr ->
129129+ fs:Eio.Fs.dir_ty Eio.Path.t ->
130130+ config:Verse_config.t ->
131131+ name:string ->
132132+ ?push_url:string ->
133133+ ?dry_run:bool ->
134134+ unit ->
135135+ (fork_result action_plan, error) result
136136+(** [plan_fork ~proc ~fs ~config ~name ?push_url ?dry_run ()] builds a fork plan.
137137+138138+ This analyzes the current state and builds a list of actions to:
139139+ - For subtrees with history: split subtree, create repo, push history
140140+ - For fresh packages: create repo, copy files, initial commit
141141+142142+ The plan can be displayed to the user and executed with [execute_fork_plan].
143143+144144+ @param name Name of the subtree to fork (directory name under mono/)
145145+ @param push_url Optional remote URL to add as origin for pushing
146146+ @param dry_run If true, mark plan as dry-run (execute will skip actions) *)
147147+148148+val plan_join :
149149+ proc:_ Eio.Process.mgr ->
150150+ fs:Eio.Fs.dir_ty Eio.Path.t ->
151151+ config:Verse_config.t ->
152152+ source:string ->
153153+ ?name:string ->
154154+ ?upstream:string ->
155155+ ?dry_run:bool ->
156156+ unit ->
157157+ (join_result action_plan, error) result
158158+(** [plan_join ~proc ~fs ~config ~source ?name ?upstream ?dry_run ()] builds a join plan.
159159+160160+ This analyzes the source (URL or local path) and builds a list of actions to:
161161+ - For URLs: clone repo, add subtree
162162+ - For local directories: copy/init repo, add subtree
163163+164164+ The plan can be displayed to the user and executed with [execute_join_plan].
165165+166166+ @param source Git URL or local filesystem path to join
167167+ @param name Override the subtree directory name (default: derived from source)
168168+ @param upstream Original upstream URL if this is your fork
169169+ @param dry_run If true, mark plan as dry-run (execute will skip actions) *)
170170+171171+val plan_rejoin :
172172+ proc:_ Eio.Process.mgr ->
173173+ fs:Eio.Fs.dir_ty Eio.Path.t ->
174174+ config:Verse_config.t ->
175175+ name:string ->
176176+ ?dry_run:bool ->
177177+ unit ->
178178+ (join_result action_plan, error) result
179179+(** [plan_rejoin ~proc ~fs ~config ~name ?dry_run ()] builds a rejoin plan.
180180+181181+ This is used to add an existing src/<name>/ repository back into mono/<name>/
182182+ as a subtree. Useful after forking a package and removing it from the monorepo.
183183+184184+ Requires:
185185+ - src/<name>/ must exist and be a git repository
186186+ - mono/<name>/ must not exist
187187+188188+ The plan can be displayed to the user and executed with [execute_join_plan].
189189+190190+ @param name Name of the subtree (directory name under src/ and mono/)
191191+ @param dry_run If true, mark plan as dry-run (execute will skip actions) *)
192192+193193+(** {1 Plan Execution} *)
194194+195195+val execute_fork_plan :
196196+ proc:_ Eio.Process.mgr ->
197197+ fs:Eio.Fs.dir_ty Eio.Path.t ->
198198+ fork_result action_plan ->
199199+ (fork_result, error) result
200200+(** [execute_fork_plan ~proc ~fs plan] executes a fork action plan.
201201+202202+ Returns the fork result with the actual split commit (if applicable).
203203+ If the plan is marked as dry-run, returns the plan's result without
204204+ executing any actions. *)
205205+206206+val execute_join_plan :
207207+ proc:_ Eio.Process.mgr ->
208208+ fs:Eio.Fs.dir_ty Eio.Path.t ->
209209+ join_result action_plan ->
210210+ (join_result, error) result
211211+(** [execute_join_plan ~proc ~fs plan] executes a join action plan.
212212+213213+ If the plan is marked as dry-run, returns the plan's result without
214214+ executing any actions. *)
215215+216216+(** {1 Fork Operations} *)
217217+218218+val fork :
219219+ proc:_ Eio.Process.mgr ->
220220+ fs:Eio.Fs.dir_ty Eio.Path.t ->
221221+ config:Verse_config.t ->
222222+ name:string ->
223223+ ?push_url:string ->
224224+ ?dry_run:bool ->
225225+ unit ->
226226+ (fork_result, error) result
227227+(** [fork ~proc ~fs ~config ~name ?push_url ?dry_run ()] splits a monorepo
228228+ subtree into its own repository.
229229+230230+ This operation:
231231+ 1. Validates mono/<name>/ exists
232232+ 2. Validates src/<name>/ does not exist
233233+ 3. Uses [git subtree split] to extract history
234234+ 4. Creates a new git repo at src/<name>/
235235+ 5. Pushes the split commit to the new repo
236236+ 6. Updates sources.toml with [origin = "fork"]
237237+ 7. Auto-discovers packages from .opam files
238238+239239+ @param name Name of the subtree to fork (directory name under mono/)
240240+ @param push_url Optional remote URL to add as origin for pushing
241241+ @param dry_run If true, validate and report what would be done *)
242242+243243+(** {1 Join Operations} *)
244244+245245+val join :
246246+ proc:_ Eio.Process.mgr ->
247247+ fs:Eio.Fs.dir_ty Eio.Path.t ->
248248+ config:Verse_config.t ->
249249+ url:string ->
250250+ ?name:string ->
251251+ ?upstream:string ->
252252+ ?dry_run:bool ->
253253+ unit ->
254254+ (join_result, error) result
255255+(** [join ~proc ~fs ~config ~url ?name ?upstream ?dry_run ()] brings an external
256256+ repository into the monorepo.
257257+258258+ This operation:
259259+ 1. Derives name from URL if not provided
260260+ 2. Validates mono/<name>/ does not exist
261261+ 3. Clones the repository to src/<name>/
262262+ 4. Uses [git subtree add] to bring into monorepo
263263+ 5. Updates sources.toml with [origin = "join"]
264264+ 6. Auto-discovers packages from .opam files
265265+266266+ @param url Git URL to clone from
267267+ @param name Override the subtree directory name (default: derived from URL)
268268+ @param upstream Original upstream URL if this is your fork of another project
269269+ @param dry_run If true, validate and report what would be done *)
270270+271271+val join_from_verse :
272272+ proc:_ Eio.Process.mgr ->
273273+ fs:Eio.Fs.dir_ty Eio.Path.t ->
274274+ config:Verse_config.t ->
275275+ verse_config:Verse_config.t ->
276276+ package:string ->
277277+ handle:string ->
278278+ fork_url:string ->
279279+ ?dry_run:bool ->
280280+ unit ->
281281+ (join_result, error) result
282282+(** [join_from_verse ~proc ~fs ~config ~verse_config ~package ~handle ~fork_url
283283+ ?dry_run ()] joins a package from a verse member's repository.
284284+285285+ This combines [Verse.fork] (to set up opam entries) with [join]:
286286+ 1. Looks up the package in verse/<handle>-opam/
287287+ 2. Finds all packages sharing the same git repository
288288+ 3. Creates opam entries pointing to your fork
289289+ 4. Clones and adds the subtree
290290+291291+ @param verse_config Verse configuration (for accessing verse/ directory)
292292+ @param package Package name to look up
293293+ @param handle Verse member handle (e.g., "avsm.bsky.social")
294294+ @param fork_url Your fork URL
295295+ @param dry_run If true, validate and report what would be done *)
+314-162
lib/forks.ml
···11(** Fork graph discovery via verse opam repos.
2233- Scans verse opam repos to discover dev-repo URLs, adds git remotes
44- to local checkouts, and computes fork relationships. *)
33+ Scans verse opam repos to discover dev-repo URLs, adds git remotes to local
44+ checkouts, and computes fork relationships. *)
5566let src = Logs.Src.create "monopam.forks" ~doc:"Fork analysis"
77+78module Log = (val Logs.src_log src : Logs.LOG)
8999-(** A dev-repo source from a specific member *)
1010+(* ==================== Fetch Cache ==================== *)
1111+1212+(** Default cache timeout in seconds (1 hour) *)
1313+let default_cache_timeout = 3600.0
1414+1515+(** In-memory cache of last fetch times *)
1616+let fetch_cache : (string, float) Hashtbl.t = Hashtbl.create 64
1717+1818+(** Cache file path - uses XDG cache directory via Verse_config *)
1919+let cache_file_path () =
2020+ Fpath.(to_string (Verse_config.cache_dir () / "fetch-cache.json"))
2121+2222+(** Load cache from disk *)
2323+let load_cache () =
2424+ let path = cache_file_path () in
2525+ if Sys.file_exists path then begin
2626+ try
2727+ let content = In_channel.with_open_text path In_channel.input_all in
2828+ (* Simple JSON parsing for {"key": timestamp, ...} *)
2929+ let content = String.trim content in
3030+ if String.length content > 2 then begin
3131+ let inner = String.sub content 1 (String.length content - 2) in
3232+ let pairs = String.split_on_char ',' inner in
3333+ List.iter (fun pair ->
3434+ let pair = String.trim pair in
3535+ match String.split_on_char ':' pair with
3636+ | [key; value] ->
3737+ let key = String.trim key in
3838+ let value = String.trim value in
3939+ (* Strip quotes from key *)
4040+ let key = if String.length key > 2 && key.[0] = '"' then
4141+ String.sub key 1 (String.length key - 2)
4242+ else key
4343+ in
4444+ (match float_of_string_opt value with
4545+ | Some ts -> Hashtbl.replace fetch_cache key ts
4646+ | None -> ())
4747+ | _ -> ())
4848+ pairs
4949+ end
5050+ with _ -> ()
5151+ end
5252+5353+(** Save cache to disk *)
5454+let save_cache () =
5555+ let path = cache_file_path () in
5656+ try
5757+ (* Create directory if needed *)
5858+ let dir = Filename.dirname path in
5959+ if not (Sys.file_exists dir) then
6060+ ignore (Sys.command (Printf.sprintf "mkdir -p %s" (Filename.quote dir)));
6161+ (* Write cache as JSON *)
6262+ Out_channel.with_open_text path (fun oc ->
6363+ output_string oc "{\n";
6464+ let first = ref true in
6565+ Hashtbl.iter (fun key ts ->
6666+ if not !first then output_string oc ",\n";
6767+ first := false;
6868+ Printf.fprintf oc " \"%s\": %.0f" key ts)
6969+ fetch_cache;
7070+ output_string oc "\n}\n")
7171+ with _ -> ()
7272+7373+(** Check if a fetch is needed for a cache key *)
7474+let needs_fetch ~refresh ~timeout key =
7575+ if refresh then true
7676+ else begin
7777+ (* Load cache on first access *)
7878+ if Hashtbl.length fetch_cache = 0 then load_cache ();
7979+ match Hashtbl.find_opt fetch_cache key with
8080+ | None -> true
8181+ | Some last_fetch ->
8282+ let now = Unix.gettimeofday () in
8383+ now -. last_fetch > timeout
8484+ end
8585+8686+(** Record a successful fetch *)
8787+let record_fetch key =
8888+ let now = Unix.gettimeofday () in
8989+ Hashtbl.replace fetch_cache key now;
9090+ save_cache ()
9191+1092type repo_source = {
1111- handle : string; (** Member handle or "me" *)
1212- url : Uri.t; (** Normalized git URL *)
1313- packages : string list; (** Opam packages from this repo *)
9393+ handle : string; (** Member handle or "me" *)
9494+ url : Uri.t; (** Normalized git URL *)
9595+ packages : string list; (** Opam packages from this repo *)
1496}
9797+(** A dev-repo source from a specific member *)
15981699(** Fork relationship between two sources *)
17100type relationship =
1818- | Same_url (** Same git URL *)
1919- | Same_commit (** Different URLs but same HEAD *)
2020- | I_am_ahead of int (** They forked from me, I'm N commits ahead *)
2121- | I_am_behind of int (** I forked from them, they're N commits ahead *)
101101+ | Same_url (** Same git URL *)
102102+ | Same_commit (** Different URLs but same HEAD *)
103103+ | I_am_ahead of int (** They forked from me, I'm N commits ahead *)
104104+ | I_am_behind of int (** I forked from them, they're N commits ahead *)
22105 | Diverged of { common_ancestor : string; my_ahead : int; their_ahead : int }
2323- | Unrelated (** No common history *)
2424- | Not_fetched (** Remote not yet fetched *)
106106+ | Unrelated (** No common history *)
107107+ | Not_fetched (** Remote not yet fetched *)
251082626-(** Analysis result for a single repository *)
27109type repo_analysis = {
2828- repo_name : string; (** Repository basename *)
2929- my_source : repo_source option; (** My dev-repo if I have it *)
110110+ repo_name : string; (** Repository basename *)
111111+ my_source : repo_source option; (** My dev-repo if I have it *)
30112 verse_sources : (string * repo_source * relationship) list;
3131- (** (handle, source, relationship to me) *)
113113+ (** (handle, source, relationship to me) *)
32114}
115115+(** Analysis result for a single repository *)
33116117117+type t = { repos : repo_analysis list }
34118(** Full fork analysis result *)
3535-type t = {
3636- repos : repo_analysis list;
3737-}
3811939120let pp_relationship ppf = function
40121 | Same_url -> Fmt.string ppf "same URL"
···46127 | Unrelated -> Fmt.string ppf "unrelated"
47128 | Not_fetched -> Fmt.string ppf "not fetched"
481294949-let pp_repo_source ppf src =
5050- Fmt.pf ppf "%s" (Uri.to_string src.url)
130130+let pp_repo_source ppf src = Fmt.pf ppf "%s" (Uri.to_string src.url)
5113152132let pp_repo_analysis ppf analysis =
53133 Fmt.pf ppf "@[<v 2>%s:@," analysis.repo_name;
···81161 | I_am_ahead n -> Fmt.(styled `Cyan (fun ppf -> pf ppf "-%d")) ppf n
82162 | I_am_behind n -> Fmt.(styled `Red (fun ppf -> pf ppf "+%d")) ppf n
83163 | Diverged { common_ancestor = _; my_ahead; their_ahead } ->
8484- Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "+%d/-%d" a b)) ppf (their_ahead, my_ahead)
164164+ Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "+%d/-%d" a b))
165165+ ppf (their_ahead, my_ahead)
85166 | Unrelated -> Fmt.(styled `Magenta string) ppf "?"
86167 | Not_fetched -> Fmt.(styled `Faint string) ppf "~"
87168···91172 List.filter (fun (_, _, rel) -> is_actionable rel) analysis.verse_sources
92173 in
93174 let in_sync =
9494- List.for_all (fun (_, _, rel) ->
9595- match rel with Same_url | Same_commit -> true | _ -> false)
175175+ List.for_all
176176+ (fun (_, _, rel) ->
177177+ match rel with Same_url | Same_commit -> true | _ -> false)
96178 analysis.verse_sources
97179 in
98180 let all_not_fetched =
9999- List.for_all (fun (_, _, rel) ->
100100- match rel with Not_fetched -> true | _ -> false)
181181+ List.for_all
182182+ (fun (_, _, rel) -> match rel with Not_fetched -> true | _ -> false)
101183 analysis.verse_sources
102184 in
103185 (actionable, in_sync, all_not_fetched)
···106188let abbrev_handle h =
107189 (* Use first part before dot, max 3 chars *)
108190 match String.split_on_char '.' h with
109109- | first :: _ -> if String.length first <= 4 then first else String.sub first 0 3
191191+ | first :: _ ->
192192+ if String.length first <= 4 then first else String.sub first 0 3
110193 | [] -> h
111194112195(** Print a list of (handle, rel) pairs with colors *)
113196let pp_changes ppf actionable =
114197 let first = ref true in
115115- List.iter (fun (h, _, rel) ->
116116- if not !first then Fmt.pf ppf " ";
117117- first := false;
118118- Fmt.pf ppf "%s%a" (abbrev_handle h) pp_rel_short rel)
198198+ List.iter
199199+ (fun (h, _, rel) ->
200200+ if not !first then Fmt.pf ppf " ";
201201+ first := false;
202202+ Fmt.pf ppf "%s%a" (abbrev_handle h) pp_rel_short rel)
119203 actionable
120204121205(** Succinct summary: dense one-line-per-repo format *)
···127211 let in_sync = ref [] in
128212 let not_mine = ref [] in
129213130130- List.iter (fun r ->
131131- let (actionable, is_in_sync, _) = summarize_repo r in
132132- match r.my_source with
133133- | None ->
134134- not_mine := r :: !not_mine
135135- | Some _ when actionable <> [] ->
136136- with_actions := (r, actionable) :: !with_actions
137137- | Some _ when is_in_sync ->
138138- in_sync := r :: !in_sync
139139- | Some _ ->
140140- (* Has verse sources but all same URL - treat as in sync *)
141141- in_sync := r :: !in_sync)
214214+ List.iter
215215+ (fun r ->
216216+ let actionable, is_in_sync, _ = summarize_repo r in
217217+ match r.my_source with
218218+ | None -> not_mine := r :: !not_mine
219219+ | Some _ when actionable <> [] ->
220220+ with_actions := (r, actionable) :: !with_actions
221221+ | Some _ when is_in_sync -> in_sync := r :: !in_sync
222222+ | Some _ ->
223223+ (* Has verse sources but all same URL - treat as in sync *)
224224+ in_sync := r :: !in_sync)
142225 t.repos;
143226144227 (* Print header with counts *)
···146229 let sync_count = List.length !in_sync in
147230 let other_count = List.length !not_mine in
148231 Fmt.pf ppf "%a %a need attention, %a synced, %a others\n"
149149- Fmt.(styled `Bold string) "Verse:"
150150- Fmt.(styled (if action_count > 0 then `Red else `Green) int) action_count
151151- Fmt.(styled `Green int) sync_count
152152- Fmt.(styled `Faint int) other_count;
232232+ Fmt.(styled `Bold string)
233233+ "Verse:"
234234+ Fmt.(styled (if action_count > 0 then `Red else `Green) int)
235235+ action_count
236236+ Fmt.(styled `Green int)
237237+ sync_count
238238+ Fmt.(styled `Faint int)
239239+ other_count;
153240154241 (* Print repos needing attention - dense format *)
155242 if !with_actions <> [] then
156156- List.iter (fun (r, actionable) ->
157157- Fmt.pf ppf " %-22s %a\n" r.repo_name pp_changes actionable)
243243+ List.iter
244244+ (fun (r, actionable) ->
245245+ Fmt.pf ppf " %-22s %a\n" r.repo_name pp_changes actionable)
158246 (List.rev !with_actions);
159247160248 (* Print in-sync repos if show_all *)
161249 if show_all && !in_sync <> [] then begin
162162- let in_sync_sorted = List.sort (fun a b -> String.compare a.repo_name b.repo_name) !in_sync in
163163- List.iter (fun r ->
164164- Fmt.pf ppf " %-22s %a\n" r.repo_name Fmt.(styled `Green string) "=")
250250+ let in_sync_sorted =
251251+ List.sort (fun a b -> String.compare a.repo_name b.repo_name) !in_sync
252252+ in
253253+ List.iter
254254+ (fun r ->
255255+ Fmt.pf ppf " %-22s %a\n" r.repo_name Fmt.(styled `Green string) "=")
165256 in_sync_sorted
166257 end;
167258···169260 if !not_mine <> [] then begin
170261 if show_all then begin
171262 (* List each repo with ~ *)
172172- let not_mine_sorted = List.sort (fun a b -> String.compare a.repo_name b.repo_name) !not_mine in
173173- List.iter (fun r ->
174174- let handles = List.map (fun (h, _, _) -> abbrev_handle h) r.verse_sources
175175- |> List.sort_uniq String.compare in
176176- Fmt.pf ppf " %-22s %a\n" r.repo_name
177177- Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s)) (String.concat "," handles))
263263+ let not_mine_sorted =
264264+ List.sort
265265+ (fun a b -> String.compare a.repo_name b.repo_name)
266266+ !not_mine
267267+ in
268268+ List.iter
269269+ (fun r ->
270270+ let handles =
271271+ List.map (fun (h, _, _) -> abbrev_handle h) r.verse_sources
272272+ |> List.sort_uniq String.compare
273273+ in
274274+ Fmt.pf ppf " %-22s %a\n" r.repo_name
275275+ Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
276276+ (String.concat "," handles))
178277 not_mine_sorted
179179- end else begin
278278+ end
279279+ else begin
180280 (* Compact summary *)
181281 let grouped = Hashtbl.create 16 in
182182- List.iter (fun r ->
183183- List.iter (fun (h, _, _) ->
184184- let existing = try Hashtbl.find grouped h with Not_found -> [] in
185185- Hashtbl.replace grouped h (r.repo_name :: existing))
186186- r.verse_sources)
282282+ List.iter
283283+ (fun r ->
284284+ List.iter
285285+ (fun (h, _, _) ->
286286+ let existing =
287287+ try Hashtbl.find grouped h with Not_found -> []
288288+ in
289289+ Hashtbl.replace grouped h (r.repo_name :: existing))
290290+ r.verse_sources)
187291 !not_mine;
188188- Fmt.pf ppf " %a " Fmt.(styled (`Bold) string) "Others:";
292292+ Fmt.pf ppf " %a " Fmt.(styled `Bold string) "Others:";
189293 let first = ref true in
190190- Hashtbl.iter (fun h repos ->
191191- if not !first then Fmt.pf ppf ", ";
192192- first := false;
193193- Fmt.(styled `Faint (fun ppf (h, n) -> pf ppf "%s(%d)" h n)) ppf (abbrev_handle h, List.length repos))
294294+ Hashtbl.iter
295295+ (fun h repos ->
296296+ if not !first then Fmt.pf ppf ", ";
297297+ first := false;
298298+ Fmt.(styled `Faint (fun ppf (h, n) -> pf ppf "%s(%d)" h n))
299299+ ppf
300300+ (abbrev_handle h, List.length repos))
194301 grouped;
195302 Fmt.pf ppf "\n"
196303 end
···199306200307let pp_summary ppf t = pp_summary' ~show_all:false ppf t
201308202202-(** Normalize a git URL for comparison.
203203- Handles: git+https, https, git@, with/without .git suffix *)
309309+(** Normalize a git URL for comparison. Handles: git+https, https, git@,
310310+ with/without .git suffix *)
204311let normalize_url url =
205312 let s = Uri.to_string url in
206313 (* Strip git+ prefix *)
207207- let s = if String.starts_with ~prefix:"git+" s then
314314+ let s =
315315+ if String.starts_with ~prefix:"git+" s then
208316 String.sub s 4 (String.length s - 4)
209317 else s
210318 in
···214322 "https://github.com/" ^ String.sub s 15 (String.length s - 15)
215323 else if String.starts_with ~prefix:"git@gitlab.com:" s then
216324 "https://gitlab.com/" ^ String.sub s 15 (String.length s - 15)
217217- else if String.starts_with ~prefix:"git@git.recoil.org:" s then
218218- "https://git.recoil.org/" ^ String.sub s 19 (String.length s - 19)
325325+ else if String.starts_with ~prefix:"git@git." s then
326326+ (* Generic git.<domain>: pattern - convert git@git.<domain>:path to https://git.<domain>/path *)
327327+ match String.index_opt s ':' with
328328+ | Some colon_pos ->
329329+ let host = String.sub s 4 (colon_pos - 4) in (* "git.<domain>" *)
330330+ let path = String.sub s (colon_pos + 1) (String.length s - colon_pos - 1) in
331331+ "https://" ^ host ^ "/" ^ path
332332+ | None -> s
219333 else s
220334 in
221335 (* Strip .git suffix *)
222222- let s = if String.ends_with ~suffix:".git" s then
336336+ let s =
337337+ if String.ends_with ~suffix:".git" s then
223338 String.sub s 0 (String.length s - 4)
224339 else s
225340 in
226341 (* Strip trailing slash *)
227227- let s = if String.ends_with ~suffix:"/" s then
228228- String.sub s 0 (String.length s - 1)
342342+ let s =
343343+ if String.ends_with ~suffix:"/" s then String.sub s 0 (String.length s - 1)
229344 else s
230345 in
231346 Uri.of_string s
···257372 let versions = Eio.Path.read_dir eio_pkg in
258373 match versions with
259374 | [] -> None
260260- | version :: _ ->
375375+ | version :: _ -> (
261376 let opam_path = Fpath.(pkg_dir / version / "opam") in
262377 let eio_opam = Eio.Path.(fs / Fpath.to_string opam_path) in
263378 try
264379 let content = Eio.Path.load eio_opam in
265265- let opamfile = OpamParser.FullPos.string content (Fpath.to_string opam_path) in
380380+ let opamfile =
381381+ OpamParser.FullPos.string content (Fpath.to_string opam_path)
382382+ in
266383 match Opam_repo.find_dev_repo opamfile.file_contents with
267384 | None -> None
268385 | Some url_str ->
269386 if Opam_repo.is_git_url url_str then
270387 Some (pkg_name, Opam_repo.normalize_git_url url_str)
271388 else None
272272- with _ -> None
389389+ with _ -> None)
273390 with _ -> None)
274391 package_names
275392 with _ -> []
276393277277-(** Fetch a verse opam repo *)
278278-let fetch_verse_opam_repo ~proc ~fs path =
279279- let cwd = Eio.Path.(fs / Fpath.to_string path) in
280280- let cmd = ["git"; "fetch"; "--quiet"] in
281281- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp path);
282282- Eio.Switch.run @@ fun sw ->
283283- let child = Eio.Process.spawn proc ~sw ~cwd
284284- ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
285285- ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
286286- cmd
287287- in
288288- match Eio.Process.await child with
289289- | `Exited 0 -> ()
290290- | _ -> Log.debug (fun m -> m "Failed to fetch %a" Fpath.pp path)
394394+(** Fetch a verse opam repo (with caching) *)
395395+let fetch_verse_opam_repo ~proc ~fs ~refresh path =
396396+ let cache_key = "verse-opam/" ^ Fpath.to_string path in
397397+ if not (needs_fetch ~refresh ~timeout:default_cache_timeout cache_key) then begin
398398+ Log.debug (fun m -> m "Skipping fetch for %a (cached)" Fpath.pp path);
399399+ ()
400400+ end else begin
401401+ let cwd = Eio.Path.(fs / Fpath.to_string path) in
402402+ let cmd = ["git"; "fetch"; "--quiet"] in
403403+ Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp path);
404404+ Eio.Switch.run @@ fun sw ->
405405+ let child = Eio.Process.spawn proc ~sw ~cwd
406406+ ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
407407+ ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
408408+ cmd
409409+ in
410410+ match Eio.Process.await child with
411411+ | `Exited 0 -> record_fetch cache_key
412412+ | _ -> Log.debug (fun m -> m "Failed to fetch %a" Fpath.pp path)
413413+ end
291414292415(** Scan all verse opam repos and build a map: repo_basename -> [(handle, url, [packages])] *)
293293-let scan_all_verse_opam_repos ~proc ~fs ~verse_path () =
416416+let scan_all_verse_opam_repos ~proc ~fs ~verse_path ~refresh () =
294417 let eio_verse = Eio.Path.(fs / Fpath.to_string verse_path) in
295418 let entries = try Eio.Path.read_dir eio_verse with _ -> [] in
296419 (* Find opam repo directories (ending in -opam) *)
297420 let opam_dirs = List.filter (fun name -> String.ends_with ~suffix:"-opam" name) entries in
298298- (* Fetch each opam repo first *)
299299- Log.info (fun m -> m "Fetching %d verse opam repos" (List.length opam_dirs));
421421+ (* Fetch each opam repo first (respecting cache unless refresh) *)
422422+ Log.info (fun m -> m "Checking %d verse opam repos" (List.length opam_dirs));
300423 List.iter (fun opam_dir ->
301424 let opam_path = Fpath.(verse_path / opam_dir) in
302302- fetch_verse_opam_repo ~proc ~fs opam_path)
425425+ fetch_verse_opam_repo ~proc ~fs ~refresh opam_path)
303426 opam_dirs;
304427 (* Build map: repo_basename -> [(handle, url, [packages])] *)
305428 let repo_map = Hashtbl.create 64 in
306429 List.iter
307430 (fun opam_dir ->
308308- let handle = String.sub opam_dir 0 (String.length opam_dir - 5) in (* strip -opam *)
431431+ let handle = String.sub opam_dir 0 (String.length opam_dir - 5) in
432432+ (* strip -opam *)
309433 let opam_path = Fpath.(verse_path / opam_dir) in
310434 let pkg_urls = scan_verse_opam_repo ~fs opam_path in
311435 (* Group by repo basename *)
···313437 List.iter
314438 (fun (pkg_name, url) ->
315439 let repo = repo_basename url in
316316- let existing = try Hashtbl.find by_repo repo with Not_found -> (url, []) in
317317- let (existing_url, pkgs) = existing in
440440+ let existing =
441441+ try Hashtbl.find by_repo repo with Not_found -> (url, [])
442442+ in
443443+ let existing_url, pkgs = existing in
318444 Hashtbl.replace by_repo repo (existing_url, pkg_name :: pkgs))
319445 pkg_urls;
320446 (* Add to main map *)
321447 Hashtbl.iter
322448 (fun repo (url, pkgs) ->
323449 let source = { handle; url; packages = pkgs } in
324324- let existing = try Hashtbl.find repo_map repo with Not_found -> [] in
450450+ let existing =
451451+ try Hashtbl.find repo_map repo with Not_found -> []
452452+ in
325453 Hashtbl.replace repo_map repo (source :: existing))
326454 by_repo)
327455 opam_dirs;
···337465 (fun pkg ->
338466 let repo = Package.repo_name pkg in
339467 let url = Package.dev_repo pkg in
340340- let existing = try Hashtbl.find repo_map repo with Not_found -> (url, []) in
341341- let (_, pkgs) = existing in
468468+ let existing =
469469+ try Hashtbl.find repo_map repo with Not_found -> (url, [])
470470+ in
471471+ let _, pkgs = existing in
342472 Hashtbl.replace repo_map repo (url, Package.name pkg :: pkgs))
343473 packages;
344474 repo_map
···349479(** Check if a remote exists *)
350480let remote_exists ~proc ~fs ~repo remote_name =
351481 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
352352- let result = Eio.Switch.run @@ fun sw ->
482482+ let result =
483483+ Eio.Switch.run @@ fun sw ->
353484 let buf = Buffer.create 256 in
354354- let child = Eio.Process.spawn proc ~sw ~cwd
355355- ~stdout:(Eio.Flow.buffer_sink buf)
485485+ let child =
486486+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
356487 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
357357- ["git"; "remote"; "get-url"; remote_name]
488488+ [ "git"; "remote"; "get-url"; remote_name ]
358489 in
359359- match Eio.Process.await child with
360360- | `Exited 0 -> true
361361- | _ -> false
490490+ match Eio.Process.await child with `Exited 0 -> true | _ -> false
362491 in
363492 result
364493365494(** Add a git remote *)
366495let add_remote ~proc ~fs ~repo ~name ~url () =
367496 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
368368- let cmd = ["git"; "remote"; "add"; name; Uri.to_string url] in
369369- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
497497+ let cmd = [ "git"; "remote"; "add"; name; Uri.to_string url ] in
498498+ Log.debug (fun m ->
499499+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
370500 Eio.Switch.run @@ fun sw ->
371371- let child = Eio.Process.spawn proc ~sw ~cwd
501501+ let child =
502502+ Eio.Process.spawn proc ~sw ~cwd
372503 ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
373504 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
374505 cmd
···377508 | `Exited 0 -> Ok ()
378509 | _ -> Error "Failed to add remote"
379510380380-(** Fetch a remote *)
381381-let fetch_remote ~proc ~fs ~repo ~remote () =
382382- let cwd = Eio.Path.(fs / Fpath.to_string repo) in
383383- let cmd = ["git"; "fetch"; remote] in
384384- Log.info (fun m -> m "Fetching %s in %a" remote Fpath.pp repo);
385385- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
386386- Eio.Switch.run @@ fun sw ->
387387- let child = Eio.Process.spawn proc ~sw ~cwd
388388- ~stdout:(Eio.Flow.buffer_sink (Buffer.create 256))
389389- ~stderr:(Eio.Flow.buffer_sink (Buffer.create 256))
390390- cmd
391391- in
392392- match Eio.Process.await child with
393393- | `Exited 0 -> Ok ()
394394- | _ -> Error "Failed to fetch remote"
511511+(** Fetch a remote (with caching) *)
512512+let fetch_remote ~proc ~fs ~repo ~remote ~refresh () =
513513+ let cache_key = Printf.sprintf "checkout/%s/%s" (Fpath.to_string repo) remote in
514514+ if not (needs_fetch ~refresh ~timeout:default_cache_timeout cache_key) then begin
515515+ Log.debug (fun m -> m "Skipping fetch for %s in %a (cached)" remote Fpath.pp repo);
516516+ Ok () (* Return Ok since we have cached data *)
517517+ end else begin
518518+ let cwd = Eio.Path.(fs / Fpath.to_string repo) in
519519+ let cmd = ["git"; "fetch"; remote] in
520520+ Log.info (fun m -> m "Fetching %s in %a" remote Fpath.pp repo);
521521+ Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
522522+ Eio.Switch.run @@ fun sw ->
523523+ let child = Eio.Process.spawn proc ~sw ~cwd
524524+ ~stdout:(Eio.Flow.buffer_sink (Buffer.create 256))
525525+ ~stderr:(Eio.Flow.buffer_sink (Buffer.create 256))
526526+ cmd
527527+ in
528528+ match Eio.Process.await child with
529529+ | `Exited 0 -> record_fetch cache_key; Ok ()
530530+ | _ -> Error "Failed to fetch remote"
531531+ end
395532396533(** Get the commit SHA for a ref *)
397534let get_ref_commit ~proc ~fs ~repo ref_name =
398535 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
399399- let cmd = ["git"; "rev-parse"; ref_name] in
400400- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
536536+ let cmd = [ "git"; "rev-parse"; ref_name ] in
537537+ Log.debug (fun m ->
538538+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
401539 Eio.Switch.run @@ fun sw ->
402540 let buf = Buffer.create 64 in
403403- let child = Eio.Process.spawn proc ~sw ~cwd
404404- ~stdout:(Eio.Flow.buffer_sink buf)
541541+ let child =
542542+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
405543 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
406544 cmd
407545 in
···416554 match (my_commit, their_commit) with
417555 | None, _ | _, None -> Not_fetched
418556 | Some my_sha, Some their_sha when my_sha = their_sha -> Same_commit
419419- | Some my_sha, Some their_sha ->
557557+ | Some my_sha, Some their_sha -> (
420558 (* Check ancestry *)
421559 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
422560 let is_ancestor commit1 commit2 =
423423- let cmd = ["git"; "merge-base"; "--is-ancestor"; commit1; commit2] in
424424- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
561561+ let cmd = [ "git"; "merge-base"; "--is-ancestor"; commit1; commit2 ] in
562562+ Log.debug (fun m ->
563563+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
425564 Eio.Switch.run @@ fun sw ->
426426- let child = Eio.Process.spawn proc ~sw ~cwd
565565+ let child =
566566+ Eio.Process.spawn proc ~sw ~cwd
427567 ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
428568 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
429569 cmd
430570 in
431431- match Eio.Process.await child with
432432- | `Exited 0 -> true
433433- | _ -> false
571571+ match Eio.Process.await child with `Exited 0 -> true | _ -> false
434572 in
435573 let count_commits base head =
436436- let cmd = ["git"; "rev-list"; "--count"; base ^ ".." ^ head] in
437437- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
574574+ let cmd = [ "git"; "rev-list"; "--count"; base ^ ".." ^ head ] in
575575+ Log.debug (fun m ->
576576+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
438577 Eio.Switch.run @@ fun sw ->
439578 let buf = Buffer.create 16 in
440440- let child = Eio.Process.spawn proc ~sw ~cwd
441441- ~stdout:(Eio.Flow.buffer_sink buf)
579579+ let child =
580580+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
442581 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
443582 cmd
444583 in
445584 match Eio.Process.await child with
446446- | `Exited 0 -> (try int_of_string (String.trim (Buffer.contents buf)) with _ -> 0)
585585+ | `Exited 0 -> (
586586+ try int_of_string (String.trim (Buffer.contents buf)) with _ -> 0)
447587 | _ -> 0
448588 in
449589 let my_is_ancestor = is_ancestor my_sha their_sha in
450590 let their_is_ancestor = is_ancestor their_sha my_sha in
451591 match (my_is_ancestor, their_is_ancestor) with
452452- | true, true -> Same_commit (* shouldn't happen if SHAs differ *)
592592+ | true, true -> Same_commit (* shouldn't happen if SHAs differ *)
453593 | true, false ->
454594 (* My commit is ancestor of theirs -> I'm behind *)
455595 let behind = count_commits my_sha their_sha in
···458598 (* Their commit is ancestor of mine -> I'm ahead *)
459599 let ahead = count_commits their_sha my_sha in
460600 I_am_ahead ahead
461461- | false, false ->
601601+ | false, false -> (
462602 (* Check for common ancestor *)
463463- let cmd = ["git"; "merge-base"; my_sha; their_sha] in
464464- Log.debug (fun m -> m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
603603+ let cmd = [ "git"; "merge-base"; my_sha; their_sha ] in
604604+ Log.debug (fun m ->
605605+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
465606 let merge_base =
466607 Eio.Switch.run @@ fun sw ->
467608 let buf = Buffer.create 64 in
468468- let child = Eio.Process.spawn proc ~sw ~cwd
469469- ~stdout:(Eio.Flow.buffer_sink buf)
609609+ let child =
610610+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
470611 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
471612 cmd
472613 in
···479620 | Some base ->
480621 let my_ahead = count_commits base my_sha in
481622 let their_ahead = count_commits base their_sha in
482482- Diverged { common_ancestor = base; my_ahead; their_ahead }
623623+ Diverged { common_ancestor = base; my_ahead; their_ahead }))
483624484625(** Compute fork analysis for all repos *)
485485-let compute ~proc ~fs ~verse_config ~monopam_config () =
626626+let compute ~proc ~fs ~verse_config ~monopam_config ?(refresh=false) () =
486627 let verse_path = Verse_config.verse_path verse_config in
487628 let opam_repo_path = Config.Paths.opam_repo monopam_config in
488629 let checkouts_path = Config.Paths.checkouts monopam_config in
···493634494635 (* Scan verse opam repos *)
495636 Log.info (fun m -> m "Scanning verse opam repos");
496496- let verse_repos = scan_all_verse_opam_repos ~proc ~fs ~verse_path () in
637637+ let verse_repos = scan_all_verse_opam_repos ~proc ~fs ~verse_path ~refresh () in
497638498639 (* Build combined list of all repo names *)
499640 let all_repos = Hashtbl.create 64 in
···530671 match my_source with
531672 | Some my when urls_equal my.url src.url -> Same_url
532673 | _ when not have_checkout -> Not_fetched
533533- | _ ->
674674+ | _ -> (
534675 let remote_name = verse_remote_name src.handle in
535676 (* Add remote if needed *)
536536- if not (remote_exists ~proc ~fs ~repo:checkout_path remote_name) then begin
537537- Log.info (fun m -> m "Adding remote %s -> %a" remote_name Uri.pp src.url);
538538- ignore (add_remote ~proc ~fs ~repo:checkout_path ~name:remote_name ~url:src.url ())
677677+ if
678678+ not
679679+ (remote_exists ~proc ~fs ~repo:checkout_path
680680+ remote_name)
681681+ then begin
682682+ Log.info (fun m ->
683683+ m "Adding remote %s -> %a" remote_name Uri.pp
684684+ src.url);
685685+ ignore
686686+ (add_remote ~proc ~fs ~repo:checkout_path
687687+ ~name:remote_name ~url:src.url ())
539688 end;
540540- (* Fetch remote *)
541541- (match fetch_remote ~proc ~fs ~repo:checkout_path ~remote:remote_name () with
689689+ (* Fetch remote (respecting cache unless refresh) *)
690690+ match fetch_remote ~proc ~fs ~repo:checkout_path ~remote:remote_name ~refresh () with
542691 | Error _ -> Not_fetched
543692 | Ok () ->
544693 (* Compare refs *)
545694 let my_ref = "origin/main" in
546695 let their_ref = remote_name ^ "/main" in
547547- compare_refs ~proc ~fs ~repo:checkout_path ~my_ref ~their_ref ())
696696+ compare_refs ~proc ~fs ~repo:checkout_path ~my_ref
697697+ ~their_ref ())
548698 in
549699 (src.handle, src, rel))
550700 verse_sources
···554704 all_repos []
555705 in
556706 (* Sort by repo name *)
557557- let repos = List.sort (fun a b -> String.compare a.repo_name b.repo_name) analyses in
707707+ let repos =
708708+ List.sort (fun a b -> String.compare a.repo_name b.repo_name) analyses
709709+ in
558710 { repos }
+28-27
lib/forks.mli
···11(** Fork graph discovery via verse opam repos.
2233- Scans verse opam repos to discover dev-repo URLs, adds git remotes
44- to local checkouts, and computes fork relationships. *)
33+ Scans verse opam repos to discover dev-repo URLs, adds git remotes to local
44+ checkouts, and computes fork relationships. *)
5566(** {1 Types} *)
7788-(** A dev-repo source from a specific member *)
98type repo_source = {
1010- handle : string; (** Member handle or "me" *)
1111- url : Uri.t; (** Normalized git URL *)
1212- packages : string list; (** Opam packages from this repo *)
99+ handle : string; (** Member handle or "me" *)
1010+ url : Uri.t; (** Normalized git URL *)
1111+ packages : string list; (** Opam packages from this repo *)
1312}
1313+(** A dev-repo source from a specific member *)
14141515(** Fork relationship between two sources *)
1616type relationship =
1717- | Same_url (** Same git URL *)
1818- | Same_commit (** Different URLs but same HEAD *)
1919- | I_am_ahead of int (** They forked from me, I'm N commits ahead *)
2020- | I_am_behind of int (** I forked from them, they're N commits ahead *)
1717+ | Same_url (** Same git URL *)
1818+ | Same_commit (** Different URLs but same HEAD *)
1919+ | I_am_ahead of int (** They forked from me, I'm N commits ahead *)
2020+ | I_am_behind of int (** I forked from them, they're N commits ahead *)
2121 | Diverged of { common_ancestor : string; my_ahead : int; their_ahead : int }
2222- | Unrelated (** No common history *)
2323- | Not_fetched (** Remote not yet fetched *)
2222+ | Unrelated (** No common history *)
2323+ | Not_fetched (** Remote not yet fetched *)
24242525-(** Analysis result for a single repository *)
2625type repo_analysis = {
2727- repo_name : string; (** Repository basename *)
2828- my_source : repo_source option; (** My dev-repo if I have it *)
2626+ repo_name : string; (** Repository basename *)
2727+ my_source : repo_source option; (** My dev-repo if I have it *)
2928 verse_sources : (string * repo_source * relationship) list;
3030- (** (handle, source, relationship to me) *)
2929+ (** (handle, source, relationship to me) *)
3130}
3131+(** Analysis result for a single repository *)
32323333+type t = { repos : repo_analysis list }
3334(** Full fork analysis result *)
3434-type t = {
3535- repos : repo_analysis list;
3636-}
37353836(** {1 Pretty Printing} *)
39374038val pp_relationship : relationship Fmt.t
4139val pp_repo_source : repo_source Fmt.t
4240val pp_repo_analysis : repo_analysis Fmt.t
4141+4342val pp : t Fmt.t
4443(** Verbose output with full URLs for each repo. *)
45444645val pp_summary : t Fmt.t
4747-(** Succinct summary: one line per repo with emphasis on repos where
4848- others have commits not in mine. *)
4646+(** Succinct summary: one line per repo with emphasis on repos where others have
4747+ commits not in mine. *)
49485049val pp_summary' : show_all:bool -> t Fmt.t
5150(** [pp_summary' ~show_all] formats a succinct summary. When [show_all] is true,
5251 lists all repos that others have but you don't. *)
53525453val is_actionable : relationship -> bool
5555-(** [is_actionable rel] returns [true] if the relationship indicates
5656- that others have commits I should consider pulling (I_am_behind or Diverged). *)
5454+(** [is_actionable rel] returns [true] if the relationship indicates that others
5555+ have commits I should consider pulling (I_am_behind or Diverged). *)
57565857(** {1 URL Utilities} *)
59586059val normalize_url : Uri.t -> Uri.t
6161-(** [normalize_url url] normalizes a git URL for comparison.
6262- Converts SSH to HTTPS, strips git+ prefix and .git suffix. *)
6060+(** [normalize_url url] normalizes a git URL for comparison. Converts SSH to
6161+ HTTPS, strips git+ prefix and .git suffix. *)
63626463val urls_equal : Uri.t -> Uri.t -> bool
6564(** [urls_equal url1 url2] checks if two URLs refer to the same repo. *)
···7473 fs:Eio.Fs.dir_ty Eio.Path.t ->
7574 verse_config:Verse_config.t ->
7675 monopam_config:Config.t ->
7676+ ?refresh:bool ->
7777 unit ->
7878 t
7979-(** [compute ~proc ~fs ~verse_config ~monopam_config ()] performs full fork
7979+(** [compute ~proc ~fs ~verse_config ~monopam_config ?refresh ()] performs full fork
8080 analysis by:
8181 1. Scanning my opam repo for dev-repo URLs
8282 2. Scanning all verse opam repos for dev-repo URLs
8383 3. Adding git remotes to my checkouts for each member's fork
8484 4. Fetching remotes and comparing commit histories
85858686- This is an expensive operation as it fetches from all verse member remotes. *)
8686+ Fetches are cached for 1 hour by default. Use [~refresh:true] to force
8787+ fresh fetches from all remotes. *)
+222-56
lib/git.ml
···11-(* Convert Uri.t to string for git commands, handling SSH URLs properly.
22- Uri.to_string percent-encodes special characters like @, but SSH URLs
33- like git@host:path need to be passed through unencoded. *)
44-let uri_to_git_url url =
55- let s = Uri.to_string url in
66- Uri.pct_decode s
77-81type cmd_result = { exit_code : int; stdout : string; stderr : string }
92103type error =
···5750 let result = run_git ~proc ~cwd args in
5851 if result.exit_code = 0 then Ok result.stdout
5952 else Error (Command_failed (String.concat " " ("git" :: args), result))
5353+5454+(** Helper for substring check *)
5555+let string_contains ~needle haystack =
5656+ let needle_len = String.length needle in
5757+ let haystack_len = String.length haystack in
5858+ if needle_len > haystack_len then false
5959+ else
6060+ let rec check i =
6161+ if i + needle_len > haystack_len then false
6262+ else if String.sub haystack i needle_len = needle then true
6363+ else check (i + 1)
6464+ in
6565+ check 0
6666+6767+(** Check if an error is a retryable HTTP server error (5xx) or network error *)
6868+let is_retryable_error result =
6969+ let stderr = result.stderr in
7070+ (* Common patterns for HTTP 5xx errors in git output *)
7171+ String.length stderr > 0 &&
7272+ (string_contains ~needle:"500" stderr ||
7373+ string_contains ~needle:"502" stderr ||
7474+ string_contains ~needle:"503" stderr ||
7575+ string_contains ~needle:"504" stderr ||
7676+ string_contains ~needle:"HTTP 5" stderr ||
7777+ string_contains ~needle:"http 5" stderr ||
7878+ string_contains ~needle:"Internal Server Error" stderr ||
7979+ string_contains ~needle:"Bad Gateway" stderr ||
8080+ string_contains ~needle:"Service Unavailable" stderr ||
8181+ string_contains ~needle:"Gateway Timeout" stderr ||
8282+ (* RPC failures (common git smart HTTP errors) *)
8383+ string_contains ~needle:"RPC failed" stderr ||
8484+ string_contains ~needle:"curl" stderr ||
8585+ string_contains ~needle:"unexpected disconnect" stderr ||
8686+ string_contains ~needle:"the remote end hung up" stderr ||
8787+ string_contains ~needle:"early EOF" stderr ||
8888+ (* Connection errors *)
8989+ string_contains ~needle:"Connection refused" stderr ||
9090+ string_contains ~needle:"Connection reset" stderr ||
9191+ string_contains ~needle:"Connection timed out" stderr ||
9292+ string_contains ~needle:"Could not resolve host" stderr ||
9393+ string_contains ~needle:"Failed to connect" stderr ||
9494+ string_contains ~needle:"Network is unreachable" stderr ||
9595+ string_contains ~needle:"Temporary failure" stderr)
9696+9797+(** Run a git command with retry logic for network errors.
9898+ Retries up to [max_retries] times with exponential backoff starting at [initial_delay_ms]. *)
9999+let run_git_ok_with_retry ~proc ~cwd ?(max_retries = 3) ?(initial_delay_ms = 2000) args =
100100+ let rec attempt n delay_ms =
101101+ let result = run_git ~proc ~cwd args in
102102+ if result.exit_code = 0 then Ok result.stdout
103103+ else if n < max_retries && is_retryable_error result then begin
104104+ (* Log the retry *)
105105+ Logs.warn (fun m ->
106106+ m "Git command failed with retryable error, retrying in %dms (%d/%d): %s"
107107+ delay_ms (n + 1) max_retries result.stderr);
108108+ (* Sleep before retry - convert ms to seconds for Unix.sleepf *)
109109+ Unix.sleepf (float_of_int delay_ms /. 1000.0);
110110+ (* Exponential backoff: double the delay for next attempt *)
111111+ attempt (n + 1) (delay_ms * 2)
112112+ end
113113+ else Error (Command_failed (String.concat " " ("git" :: args), result))
114114+ in
115115+ attempt 0 initial_delay_ms
6011661117let path_to_eio ~(fs : Eio.Fs.dir_ty Eio.Path.t) path =
62118 let dir, _ = fs in
···67123 try
68124 let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in
69125 result.exit_code = 0
7070- with Eio.Io _ -> false (* Directory doesn't exist or not accessible *)
126126+ with Eio.Io _ -> false (* Directory doesn't exist or not accessible *)
7112772128let is_dirty ~proc ~fs path =
73129 let cwd = path_to_eio ~fs path in
···91147 let parent = Fpath.parent target in
92148 let cwd = Eio.Path.(fs / Fpath.to_string parent) in
93149 let target_name = Fpath.basename target in
9494- let url_str = uri_to_git_url url in
9595- run_git_ok ~proc ~cwd [ "clone"; "--branch"; branch; url_str; target_name ]
150150+ let url_str = Uri.to_string url in
151151+ run_git_ok_with_retry ~proc ~cwd [ "clone"; "--branch"; branch; url_str; target_name ]
96152 |> Result.map ignore
9715398154let fetch ~proc ~fs ?(remote = "origin") path =
99155 let cwd = path_to_eio ~fs path in
100100- run_git_ok ~proc ~cwd [ "fetch"; remote ] |> Result.map ignore
156156+ run_git_ok_with_retry ~proc ~cwd [ "fetch"; remote ] |> Result.map ignore
101157102158let fetch_all ~proc ~fs path =
103159 let cwd = path_to_eio ~fs path in
104104- run_git_ok ~proc ~cwd [ "fetch"; "--all" ] |> Result.map ignore
160160+ run_git_ok_with_retry ~proc ~cwd [ "fetch"; "--all" ] |> Result.map ignore
105161106162let merge_ff ~proc ~fs ?(remote = "origin") ?branch path =
107163 let cwd = path_to_eio ~fs path in
···120176 | Some b -> [ "pull"; remote; b ]
121177 | None -> [ "pull"; remote ]
122178 in
123123- run_git_ok ~proc ~cwd args |> Result.map ignore
179179+ run_git_ok_with_retry ~proc ~cwd args |> Result.map ignore
124180125181let fetch_and_reset ~proc ~fs ?(remote = "origin") ~branch path =
126182 let cwd = path_to_eio ~fs path in
127127- match run_git_ok ~proc ~cwd [ "fetch"; remote ] with
183183+ match run_git_ok_with_retry ~proc ~cwd [ "fetch"; remote ] with
128184 | Error e -> Error e
129185 | Ok _ ->
130186 let upstream = remote ^ "/" ^ branch in
···167223 if exists ~fs ~repo ~prefix then Error (Subtree_prefix_exists prefix)
168224 else
169225 let cwd = path_to_eio ~fs repo in
170170- let url_str = uri_to_git_url url in
171171- run_git_ok ~proc ~cwd
226226+ let url_str = Uri.to_string url in
227227+ run_git_ok_with_retry ~proc ~cwd
172228 [ "subtree"; "add"; "--prefix"; prefix; url_str; branch; "--squash" ]
173229 |> Result.map ignore
174230···176232 if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
177233 else
178234 let cwd = path_to_eio ~fs repo in
179179- let url_str = uri_to_git_url url in
180180- run_git_ok ~proc ~cwd
235235+ let url_str = Uri.to_string url in
236236+ run_git_ok_with_retry ~proc ~cwd
181237 [ "subtree"; "pull"; "--prefix"; prefix; url_str; branch; "--squash" ]
182238 |> Result.map ignore
183239···185241 if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
186242 else
187243 let cwd = path_to_eio ~fs repo in
188188- let url_str = uri_to_git_url url in
189189- run_git_ok ~proc ~cwd
244244+ let url_str = Uri.to_string url in
245245+ run_git_ok_with_retry ~proc ~cwd
190246 [ "subtree"; "push"; "--prefix"; prefix; url_str; branch ]
191247 |> Result.map ignore
192248···214270 | Some b -> b
215271 | None -> Option.value ~default:"main" (current_branch ~proc ~fs path)
216272 in
217217- run_git_ok ~proc ~cwd [ "push"; remote; branch ] |> Result.map ignore
273273+ run_git_ok_with_retry ~proc ~cwd [ "push"; remote; branch ] |> Result.map ignore
274274+275275+let push_ref ~proc ~fs ~repo ~target ~ref_spec () =
276276+ let cwd = path_to_eio ~fs repo in
277277+ run_git_ok ~proc ~cwd [ "push"; target; ref_spec ] |> Result.map ignore
218278219279let set_push_url ~proc ~fs ?(remote = "origin") ~url path =
220280 let cwd = path_to_eio ~fs path in
···243303244304let add_remote ~proc ~fs ~name ~url path =
245305 let cwd = path_to_eio ~fs path in
246246- run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ]
247247- |> Result.map ignore
306306+ run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ] |> Result.map ignore
248307249308let remove_remote ~proc ~fs ~name path =
250309 let cwd = path_to_eio ~fs path in
251251- run_git_ok ~proc ~cwd [ "remote"; "remove"; name ]
252252- |> Result.map ignore
310310+ run_git_ok ~proc ~cwd [ "remote"; "remove"; name ] |> Result.map ignore
253311254312let set_remote_url ~proc ~fs ~name ~url path =
255313 let cwd = path_to_eio ~fs path in
256256- run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ]
257257- |> Result.map ignore
314314+ run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ] |> Result.map ignore
258315259316let ensure_remote ~proc ~fs ~name ~url path =
260317 let remotes = list_remotes ~proc ~fs path in
···264321 | Some existing_url when existing_url = url -> Ok ()
265322 | _ -> set_remote_url ~proc ~fs ~name ~url path
266323 end
267267- else
268268- add_remote ~proc ~fs ~name ~url path
324324+ else add_remote ~proc ~fs ~name ~url path
269325270326type log_entry = {
271327 hash : string;
···311367 let args =
312368 match until with Some u -> args @ [ "--until=" ^ u ] | None -> args
313369 in
314314- let args = match filter_path with Some p -> args @ [ "--"; p ] | None -> args in
370370+ let args =
371371+ match filter_path with Some p -> args @ [ "--"; p ] | None -> args
372372+ in
315373 match run_git_ok ~proc ~cwd args with
316374 | Ok output -> Ok (parse_log_entries output)
317375 | Error e -> Error e
···321379 let format_arg = "--format=%H%n%an%n%aI%n%s%n%b%x00" in
322380 let range = Printf.sprintf "%s..%s" base tip in
323381 let args = [ "log"; format_arg; range ] in
324324- let args = match max_count with
382382+ let args =
383383+ match max_count with
325384 | Some n -> args @ [ "-n"; string_of_int n ]
326385 | None -> args
327386 in
···329388 | Ok output -> Ok (parse_log_entries output)
330389 | Error e -> Error e
331390391391+let show_patch ~proc ~fs ~commit repo_path =
392392+ let cwd = path_to_eio ~fs repo_path in
393393+ run_git_ok ~proc ~cwd [ "show"; "--patch"; "--stat"; commit ]
394394+332395(** Parse a subtree merge/squash commit message to extract the upstream commit range.
333396 Messages look like: "Squashed 'prefix/' changes from abc123..def456"
334397 or "Squashed 'prefix/' content from commit abc123"
···339402 let len = String.length s in
340403 let rec find_end i =
341404 if i >= len then i
342342- else match s.[i] with
343343- | '0'..'9' | 'a'..'f' -> find_end (i + 1)
344344- | _ -> i
405405+ else
406406+ match s.[i] with '0' .. '9' | 'a' .. 'f' -> find_end (i + 1) | _ -> i
345407 in
346408 let end_pos = find_end start in
347409 if end_pos > start then Some (String.sub s start (end_pos - start))
···352414 match String.index_opt subject '.' with
353415 | Some i when i + 1 < String.length subject && subject.[i + 1] = '.' ->
354416 extract_hex subject (i + 2)
355355- | _ ->
417417+ | _ -> (
356418 (* Pattern 2: "Squashed 'prefix/' content from commit abc123" *)
357357- (match String.split_on_char ' ' subject |> List.rev with
419419+ match String.split_on_char ' ' subject |> List.rev with
358420 | last :: "commit" :: "from" :: _ -> extract_hex last 0
359359- | _ -> None)
360360- (* Pattern 3: "Add 'prefix/' from commit abc123" *)
421421+ | _ -> None) (* Pattern 3: "Add 'prefix/' from commit abc123" *)
361422 else if String.starts_with ~prefix:"Add '" subject then
362423 match String.split_on_char ' ' subject |> List.rev with
363424 | last :: "commit" :: "from" :: _ -> extract_hex last 0
364425 | _ -> None
365365- else
366366- None
426426+ else None
367427368368-(** Find the last subtree-related commit for a given prefix.
369369- Searches git log for commits with subtree merge/squash messages. *)
428428+(** Find the last subtree-related commit for a given prefix. Searches git log
429429+ for commits with subtree merge/squash messages. *)
370430let subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () =
371431 let cwd = path_to_eio ~fs repo in
372432 (* Search for subtree-related commits - don't use path filter as it can miss merge commits *)
373433 let grep_pattern = Printf.sprintf "^Squashed '%s/'" prefix in
374374- match run_git_ok ~proc ~cwd
375375- [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ] with
434434+ match
435435+ run_git_ok ~proc ~cwd [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ]
436436+ with
376437 | Error _ -> None
377377- | Ok "" ->
438438+ | Ok "" -> (
378439 (* Try alternate pattern: Add 'prefix/' from commit *)
379440 let add_pattern = Printf.sprintf "^Add '%s/'" prefix in
380380- (match run_git_ok ~proc ~cwd
381381- [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ] with
441441+ match
442442+ run_git_ok ~proc ~cwd
443443+ [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ]
444444+ with
382445 | Error _ -> None
383446 | Ok "" -> None
384384- | Ok line ->
447447+ | Ok line -> (
385448 (* line is "abc1234 Add 'prefix/' from commit ..." *)
386449 let hash = String.sub line 0 (min 7 (String.length line)) in
387450 (* Get the full commit message to parse *)
388451 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
389452 | Error _ -> None
390390- | Ok subject -> parse_subtree_message subject)
391391- | Ok line ->
453453+ | Ok subject -> parse_subtree_message subject))
454454+ | Ok line -> (
392455 let hash = String.sub line 0 (min 7 (String.length line)) in
393456 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
394457 | Error _ -> None
395395- | Ok subject -> parse_subtree_message subject
458458+ | Ok subject -> parse_subtree_message subject)
396459397460(** Check if commit1 is an ancestor of commit2. *)
398461let is_ancestor ~proc ~fs ~repo ~commit1 ~commit2 () =
399462 let cwd = path_to_eio ~fs repo in
400400- let result = run_git ~proc ~cwd
401401- [ "merge-base"; "--is-ancestor"; commit1; commit2 ] in
463463+ let result =
464464+ run_git ~proc ~cwd [ "merge-base"; "--is-ancestor"; commit1; commit2 ]
465465+ in
402466 result.exit_code = 0
403467404468(** Find the merge-base (common ancestor) of two commits. *)
···409473(** Count commits between two commits (exclusive of base, inclusive of head). *)
410474let count_commits_between ~proc ~fs ~repo ~base ~head () =
411475 let cwd = path_to_eio ~fs repo in
412412- match run_git_ok ~proc ~cwd
413413- [ "rev-list"; "--count"; base ^ ".." ^ head ] with
476476+ match run_git_ok ~proc ~cwd [ "rev-list"; "--count"; base ^ ".." ^ head ] with
414477 | Error _ -> 0
415415- | Ok s -> try int_of_string (String.trim s) with _ -> 0
478478+ | Ok s -> ( try int_of_string (String.trim s) with _ -> 0)
479479+480480+(** {1 Worktree Operations} *)
416481417482module Worktree = struct
418483 type entry = {
···495560 let worktrees = list ~proc ~fs repo in
496561 List.exists (fun e -> Fpath.equal e.path path) worktrees
497562end
563563+564564+let cherry_pick ~proc ~fs ~commit path =
565565+ let cwd = path_to_eio ~fs path in
566566+ run_git_ok ~proc ~cwd [ "cherry-pick"; commit ] |> Result.map ignore
567567+568568+let merge ~proc ~fs ~ref_name ?(ff_only=false) path =
569569+ let cwd = path_to_eio ~fs path in
570570+ let args = ["merge"] @ (if ff_only then ["--ff-only"] else []) @ [ref_name] in
571571+ run_git_ok ~proc ~cwd args |> Result.map ignore
572572+573573+(** {1 Diff Operations} *)
574574+575575+let diff_trees ~proc ~fs ~source ~target =
576576+ (* Use git diff --no-index to compare two directory trees.
577577+ This works even if neither directory is a git repo.
578578+ Exit code 0 = no diff, exit code 1 = diff found, other = error *)
579579+ let cwd = path_to_eio ~fs (Fpath.v ".") in
580580+ let source_str = Fpath.to_string source in
581581+ let target_str = Fpath.to_string target in
582582+ let result =
583583+ run_git ~proc ~cwd
584584+ [
585585+ "diff";
586586+ "--no-index";
587587+ "--binary";
588588+ (* Handle binary files *)
589589+ "--no-color";
590590+ target_str;
591591+ (* old = checkout *)
592592+ source_str (* new = monorepo subtree *);
593593+ ]
594594+ in
595595+ match result.exit_code with
596596+ | 0 ->
597597+ (* No differences *)
598598+ Ok ""
599599+ | 1 ->
600600+ (* Differences found - this is success for diff *)
601601+ Ok result.stdout
602602+ | _ ->
603603+ (* Actual error *)
604604+ Error
605605+ (Command_failed
606606+ (String.concat " " [ "git"; "diff"; "--no-index" ], result))
607607+608608+let apply_diff ~proc ~fs ~cwd ~diff =
609609+ if String.length diff = 0 then Ok ()
610610+ else
611611+ let cwd_eio = path_to_eio ~fs cwd in
612612+ (* Apply the diff using git apply.
613613+ We need to handle the path rewriting since git diff --no-index
614614+ uses absolute or relative paths as prefixes. *)
615615+ let cmd = [ "apply"; "--binary"; "-p1"; "-" ] in
616616+ let buf_stdout = Buffer.create 256 in
617617+ let buf_stderr = Buffer.create 256 in
618618+ Eio.Switch.run @@ fun sw ->
619619+ let child =
620620+ Eio.Process.spawn proc ~sw ~cwd:cwd_eio
621621+ ~stdin:(Eio.Flow.string_source diff)
622622+ ~stdout:(Eio.Flow.buffer_sink buf_stdout)
623623+ ~stderr:(Eio.Flow.buffer_sink buf_stderr)
624624+ ("git" :: cmd)
625625+ in
626626+ let exit_status = Eio.Process.await child in
627627+ match exit_status with
628628+ | `Exited 0 -> Ok ()
629629+ | `Exited n | `Signaled n ->
630630+ Error
631631+ (Command_failed
632632+ ( String.concat " " ("git" :: cmd),
633633+ {
634634+ exit_code = n;
635635+ stdout = Buffer.contents buf_stdout;
636636+ stderr = Buffer.contents buf_stderr;
637637+ } ))
638638+639639+let add_all ~proc ~fs path =
640640+ let cwd = path_to_eio ~fs path in
641641+ run_git_ok ~proc ~cwd [ "add"; "-A" ] |> Result.map ignore
642642+643643+let commit ~proc ~fs ~message path =
644644+ let cwd = path_to_eio ~fs path in
645645+ run_git_ok ~proc ~cwd [ "commit"; "-m"; message ] |> Result.map ignore
646646+647647+let rm ~proc ~fs ~recursive path target =
648648+ let cwd = path_to_eio ~fs path in
649649+ let args = if recursive then [ "rm"; "-r"; target ] else [ "rm"; target ] in
650650+ run_git_ok ~proc ~cwd args |> Result.map ignore
651651+652652+let config ~proc ~fs ~key ~value path =
653653+ let cwd = path_to_eio ~fs path in
654654+ run_git_ok ~proc ~cwd [ "config"; key; value ] |> Result.map ignore
655655+656656+let has_subtree_history ~proc ~fs ~repo ~prefix () =
657657+ (* Check if there's subtree commit history for this prefix.
658658+ Returns true if we can find a subtree-related commit message. *)
659659+ subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () |> Option.is_some
660660+661661+let branch_rename ~proc ~fs ~new_name path =
662662+ let cwd = path_to_eio ~fs path in
663663+ run_git_ok ~proc ~cwd [ "branch"; "-M"; new_name ] |> Result.map ignore
+146-7
lib/git.mli
···274274 @param remote Remote name (default: "origin")
275275 @param branch Branch to push (default: current branch) *)
276276277277+val push_ref :
278278+ proc:_ Eio.Process.mgr ->
279279+ fs:Eio.Fs.dir_ty Eio.Path.t ->
280280+ repo:Fpath.t ->
281281+ target:string ->
282282+ ref_spec:string ->
283283+ unit ->
284284+ (unit, error) result
285285+(** [push_ref ~proc ~fs ~repo ~target ~ref_spec ()] pushes a specific ref to a
286286+ target repository or path.
287287+288288+ @param repo Path to the git repository to push from
289289+ @param target Target repository path or remote name
290290+ @param ref_spec The refspec to push (e.g., "abc123:refs/heads/main") *)
291291+277292val set_push_url :
278293 proc:_ Eio.Process.mgr ->
279294 fs:Eio.Fs.dir_ty Eio.Path.t ->
···293308 ?remote:string ->
294309 Fpath.t ->
295310 string option
296296-(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote,
297297- or [None] if not set or the remote doesn't exist.
311311+(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote, or
312312+ [None] if not set or the remote doesn't exist.
298313299314 @param remote Remote name (default: "origin") *)
300315···339354 url:string ->
340355 Fpath.t ->
341356 (unit, error) result
342342-(** [set_remote_url ~proc ~fs ~name ~url path] updates the URL for an existing remote. *)
357357+(** [set_remote_url ~proc ~fs ~name ~url path] updates the URL for an existing
358358+ remote. *)
343359344360val ensure_remote :
345361 proc:_ Eio.Process.mgr ->
···348364 url:string ->
349365 Fpath.t ->
350366 (unit, error) result
351351-(** [ensure_remote ~proc ~fs ~name ~url path] ensures a remote exists with the given URL.
352352- If the remote exists with a different URL, it is updated.
353353- If the remote doesn't exist, it is added. *)
367367+(** [ensure_remote ~proc ~fs ~name ~url path] ensures a remote exists with the
368368+ given URL. If the remote exists with a different URL, it is updated. If the
369369+ remote doesn't exist, it is added. *)
354370355371(** {1 Commit History} *)
356372···386402 ?max_count:int ->
387403 Fpath.t ->
388404 (log_entry list, error) result
389389-(** [log_range ~proc ~fs ~base ~tip ?max_count repo] retrieves commits between refs.
405405+(** [log_range ~proc ~fs ~base ~tip ?max_count repo] retrieves commits between
406406+ refs.
390407391408 Gets commits reachable from [tip] but not from [base] (i.e., [base..tip]).
392409···395412 @param max_count Maximum number of commits to return
396413 @param repo Path to the git repository *)
397414415415+val show_patch :
416416+ proc:_ Eio.Process.mgr ->
417417+ fs:Eio.Fs.dir_ty Eio.Path.t ->
418418+ commit:string ->
419419+ Fpath.t ->
420420+ (string, error) result
421421+(** [show_patch ~proc ~fs ~commit repo] returns the patch content for a commit.
422422+423423+ Runs [git show --patch --stat commit] to get the full diff with stats. *)
424424+398425(** {1 Subtree Commit Analysis} *)
399426400427val parse_subtree_message : string -> string option
···514541 bool
515542 (** [exists ~proc ~fs ~repo ~path] returns true if a worktree exists at [path]. *)
516543end
544544+545545+(** {1 Cherry-pick Operations} *)
546546+547547+val cherry_pick :
548548+ proc:_ Eio.Process.mgr ->
549549+ fs:Eio.Fs.dir_ty Eio.Path.t ->
550550+ commit:string ->
551551+ Fpath.t ->
552552+ (unit, error) result
553553+(** [cherry_pick ~proc ~fs ~commit path] applies a single commit to the current branch.
554554+555555+ @param commit The commit hash to cherry-pick
556556+ @param path Path to the repository *)
557557+558558+val merge :
559559+ proc:_ Eio.Process.mgr ->
560560+ fs:Eio.Fs.dir_ty Eio.Path.t ->
561561+ ref_name:string ->
562562+ ?ff_only:bool ->
563563+ Fpath.t ->
564564+ (unit, error) result
565565+(** [merge ~proc ~fs ~ref_name ?ff_only path] merges a ref into the current branch.
566566+567567+ @param ref_name The ref to merge (e.g., "verse/handle/main")
568568+ @param ff_only If true, only allow fast-forward merges (default: false)
569569+ @param path Path to the repository *)
570570+571571+(** {1 Diff Operations} *)
572572+573573+val diff_trees :
574574+ proc:_ Eio.Process.mgr ->
575575+ fs:Eio.Fs.dir_ty Eio.Path.t ->
576576+ source:Fpath.t ->
577577+ target:Fpath.t ->
578578+ (string, error) result
579579+(** [diff_trees ~proc ~fs ~source ~target] generates a diff between two
580580+ directory trees using [git diff --no-index].
581581+582582+ Returns [Ok ""] if the trees are identical, [Ok diff] with the diff content
583583+ if they differ, or [Error] if the diff command fails.
584584+585585+ @param source The source directory (typically the monorepo subtree)
586586+ @param target The target directory (typically the checkout) *)
587587+588588+val apply_diff :
589589+ proc:_ Eio.Process.mgr ->
590590+ fs:Eio.Fs.dir_ty Eio.Path.t ->
591591+ cwd:Fpath.t ->
592592+ diff:string ->
593593+ (unit, error) result
594594+(** [apply_diff ~proc ~fs ~cwd ~diff] applies a diff to the directory at [cwd].
595595+596596+ Uses [git apply] to apply the diff. Returns [Ok ()] if the diff was applied
597597+ successfully or was empty, [Error] if the apply failed. *)
598598+599599+val add_all :
600600+ proc:_ Eio.Process.mgr ->
601601+ fs:Eio.Fs.dir_ty Eio.Path.t ->
602602+ Fpath.t ->
603603+ (unit, error) result
604604+(** [add_all ~proc ~fs path] stages all changes (git add -A) in the repository
605605+ at [path]. *)
606606+607607+val commit :
608608+ proc:_ Eio.Process.mgr ->
609609+ fs:Eio.Fs.dir_ty Eio.Path.t ->
610610+ message:string ->
611611+ Fpath.t ->
612612+ (unit, error) result
613613+(** [commit ~proc ~fs ~message path] creates a commit with the given message
614614+ in the repository at [path]. *)
615615+616616+val rm :
617617+ proc:_ Eio.Process.mgr ->
618618+ fs:Eio.Fs.dir_ty Eio.Path.t ->
619619+ recursive:bool ->
620620+ Fpath.t ->
621621+ string ->
622622+ (unit, error) result
623623+(** [rm ~proc ~fs ~recursive path target] removes [target] from the git index
624624+ in the repository at [path]. If [recursive] is true, removes directories
625625+ recursively (git rm -r). *)
626626+627627+val config :
628628+ proc:_ Eio.Process.mgr ->
629629+ fs:Eio.Fs.dir_ty Eio.Path.t ->
630630+ key:string ->
631631+ value:string ->
632632+ Fpath.t ->
633633+ (unit, error) result
634634+(** [config ~proc ~fs ~key ~value path] sets a git config value in the
635635+ repository at [path]. *)
636636+637637+val has_subtree_history :
638638+ proc:_ Eio.Process.mgr ->
639639+ fs:Eio.Fs.dir_ty Eio.Path.t ->
640640+ repo:Fpath.t ->
641641+ prefix:string ->
642642+ unit ->
643643+ bool
644644+(** [has_subtree_history ~proc ~fs ~repo ~prefix ()] returns true if the
645645+ prefix has subtree commit history (i.e., was added via git subtree add).
646646+ Returns false for fresh local packages that were never part of a subtree. *)
647647+648648+val branch_rename :
649649+ proc:_ Eio.Process.mgr ->
650650+ fs:Eio.Fs.dir_ty Eio.Path.t ->
651651+ new_name:string ->
652652+ Fpath.t ->
653653+ (unit, error) result
654654+(** [branch_rename ~proc ~fs ~new_name path] renames the current branch
655655+ to [new_name] in the repository at [path]. Uses [git branch -M]. *)
+1393-459
lib/monopam.ml
···1111module Forks = Forks
1212module Doctor = Doctor
1313module Feature = Feature
1414+module Dune_project = Dune_project
1515+module Opam_transform = Opam_transform
1616+module Sources_registry = Sources_registry
1717+module Fork_join = Fork_join
1818+module Site = Site
14191520let src = Logs.Src.create "monopam" ~doc:"Monopam operations"
1621···2126 | Repo_error of Opam_repo.error
2227 | Git_error of Git.error
2328 | Dirty_state of Package.t list
2929+ | Monorepo_dirty
2430 | Package_not_found of string
2531 | Claude_error of string
2632···3238 Fmt.pf ppf "Dirty packages: %a"
3339 Fmt.(list ~sep:comma (using Package.name string))
3440 pkgs
4141+ | Monorepo_dirty -> Fmt.pf ppf "Monorepo has uncommitted changes"
3542 | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name
3643 | Claude_error msg -> Fmt.pf ppf "Claude error: %s" msg
37443838-(** Returns a hint string for the given error, or None if no hint is available. *)
4545+(** Returns a hint string for the given error, or None if no hint is available.
4646+*)
3947let error_hint = function
4048 | Config_error _ ->
4141- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace."
4949+ Some
5050+ "Run 'monopam init --handle <your-handle>' to create a workspace."
4251 | Repo_error (Opam_repo.No_dev_repo _) ->
4343- Some "Add a 'dev-repo' field to the package's opam file pointing to a git URL."
5252+ Some
5353+ "Add a 'dev-repo' field to the package's opam file pointing to a git \
5454+ URL."
4455 | Repo_error (Opam_repo.Not_git_remote _) ->
4556 Some "The dev-repo must be a git URL (git+https:// or git://)."
4657 | Repo_error _ -> None
···5465 Some "Check that the remote is configured: git remote -v"
5566 | Git_error (Git.Branch_not_found _) ->
5667 Some "Check available branches: git branch -a"
5757- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git push" cmd ->
6868+ | Git_error (Git.Command_failed (cmd, _))
6969+ when String.starts_with ~prefix:"git push" cmd ->
5870 Some "Check your network connection and git credentials."
5959- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git subtree" cmd ->
7171+ | Git_error (Git.Command_failed (cmd, _))
7272+ when String.starts_with ~prefix:"git subtree" cmd ->
6073 Some "Run 'monopam status' to check repository state."
6174 | Git_error _ -> None
6275 | Dirty_state _ ->
6363- Some "Commit changes in the monorepo first: cd mono && git add -A && git commit"
7676+ Some
7777+ "Commit changes in the monorepo first: cd mono && git add -A && git \
7878+ commit"
7979+ | Monorepo_dirty ->
8080+ Some "Commit or stash your changes first: git status && git add -A && git commit"
6481 | Package_not_found _ ->
6582 Some "Check available packages: ls opam-repo/packages/"
6683 | Claude_error msg when String.starts_with ~prefix:"Failed to decode" msg ->
···132149 (fun pkg ->
133150 let repo = Package.repo_name pkg in
134151 let name = Package.name pkg in
135135- let existing = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
152152+ let existing =
153153+ try Hashtbl.find registered_by_repo repo with Not_found -> []
154154+ in
136155 Hashtbl.replace registered_by_repo repo (name :: existing))
137156 pkgs;
138157 (* Get unique subtree directories *)
···154173 let repo = Package.repo_name pkg in
155174 let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in
156175 let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in
157157- let registered = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
176176+ let registered =
177177+ try Hashtbl.find registered_by_repo repo with Not_found -> []
178178+ in
158179 try
159180 Eio.Path.read_dir eio_path
160181 |> List.filter_map (fun name ->
···166187 with Eio.Io _ -> [])
167188 repos
168189190190+(** Information about a package discovered from the monorepo. *)
191191+type monorepo_package = {
192192+ pkg_name : string;
193193+ subtree : string;
194194+ dev_repo : string;
195195+ url_src : string;
196196+ opam_content : string;
197197+}
198198+199199+(** Discover packages from monorepo subtrees by parsing dune-project files.
200200+ If [sources] is provided, it overrides the dev-repo URL for matching subtrees. *)
201201+let discover_packages_from_monorepo ~fs ~config ?(sources = Sources_registry.empty) () =
202202+ let fs = fs_typed fs in
203203+ let monorepo = Config.Paths.monorepo config in
204204+ let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
205205+206206+ (* List all subdirectories of monorepo *)
207207+ let subdirs =
208208+ try
209209+ Eio.Path.read_dir monorepo_eio
210210+ |> List.filter (fun name ->
211211+ let child = Eio.Path.(monorepo_eio / name) in
212212+ match Eio.Path.kind ~follow:false child with
213213+ | `Directory -> true
214214+ | _ -> false)
215215+ with Eio.Io _ -> []
216216+ in
217217+218218+ Log.debug (fun m -> m "Found %d subdirectories in monorepo" (List.length subdirs));
219219+220220+ (* Process each subdirectory *)
221221+ let packages, errors =
222222+ List.fold_left
223223+ (fun (pkgs, errs) subtree ->
224224+ let subtree_path = Eio.Path.(monorepo_eio / subtree) in
225225+ let dune_project_path = Eio.Path.(subtree_path / "dune-project") in
226226+227227+ (* Check if dune-project exists *)
228228+ match Eio.Path.kind ~follow:false dune_project_path with
229229+ | `Regular_file -> (
230230+ (* Parse dune-project *)
231231+ let content =
232232+ try Some (Eio.Path.load dune_project_path)
233233+ with Eio.Io _ -> None
234234+ in
235235+ match content with
236236+ | None -> (pkgs, errs)
237237+ | Some content -> (
238238+ match Dune_project.parse content with
239239+ | Error msg ->
240240+ Log.warn (fun m ->
241241+ m "Failed to parse %s/dune-project: %s" subtree msg);
242242+ (pkgs, msg :: errs)
243243+ | Ok dune_proj -> (
244244+ (* Find all .opam files in subtree first - we need them for opam-repo fallback *)
245245+ let opam_files =
246246+ try
247247+ Eio.Path.read_dir subtree_path
248248+ |> List.filter (fun name ->
249249+ Filename.check_suffix name ".opam")
250250+ with Eio.Io _ -> []
251251+ in
252252+253253+ (* URL resolution order:
254254+ 1. Explicit sources.toml entry for this subtree
255255+ 2. dune-project source/homepage
256256+ 3. sources.toml default_url_base + subtree name *)
257257+ let sources_override = Sources_registry.find sources ~subtree in
258258+259259+ let derive_from_dune () =
260260+ match
261261+ ( Dune_project.dev_repo_url dune_proj,
262262+ Dune_project.url_with_branch dune_proj )
263263+ with
264264+ | Ok dev_repo, Ok url_src -> Some (dev_repo, url_src)
265265+ | Error _, _ | _, Error _ -> None
266266+ in
267267+268268+ let derive_from_default_base () =
269269+ (* Use default_url_base from sources.toml to construct URL *)
270270+ match Sources_registry.derive_url sources ~subtree with
271271+ | Some dev_repo ->
272272+ Log.debug (fun m ->
273273+ m "Using default_url_base for %s: %s" subtree dev_repo);
274274+ Some (dev_repo, dev_repo ^ "#main")
275275+ | None -> None
276276+ in
277277+278278+ let dev_repo_and_url =
279279+ match sources_override with
280280+ | Some entry ->
281281+ (* Use explicit sources.toml entry *)
282282+ let dev_repo = entry.Sources_registry.url in
283283+ let branch =
284284+ match entry.Sources_registry.branch with
285285+ | Some b -> b
286286+ | None -> (
287287+ (* Try to get branch from dune-project, default to main *)
288288+ match dune_proj.source with
289289+ | Some (Dune_project.Uri { branch = Some b; _ }) -> b
290290+ | _ -> "main")
291291+ in
292292+ Log.debug (fun m ->
293293+ m "Using sources.toml entry for %s: %s" subtree dev_repo);
294294+ Some (dev_repo, dev_repo ^ "#" ^ branch)
295295+ | None -> (
296296+ match derive_from_dune () with
297297+ | Some result -> Some result
298298+ | None -> (
299299+ match derive_from_default_base () with
300300+ | Some result -> Some result
301301+ | None ->
302302+ Log.warn (fun m ->
303303+ m "Cannot derive dev-repo for %s (no source in dune-project or sources.toml)" subtree);
304304+ None))
305305+ in
306306+ match dev_repo_and_url with
307307+ | None -> (pkgs, "Cannot derive dev-repo" :: errs)
308308+ | Some (dev_repo, url_src) ->
309309+ Log.debug (fun m ->
310310+ m "Found %d opam files in %s" (List.length opam_files)
311311+ subtree);
312312+ (* Transform each opam file *)
313313+ let new_pkgs =
314314+ List.filter_map
315315+ (fun opam_file ->
316316+ let pkg_name =
317317+ Filename.chop_suffix opam_file ".opam"
318318+ in
319319+ let opam_path =
320320+ Eio.Path.(subtree_path / opam_file)
321321+ in
322322+ try
323323+ let raw_content = Eio.Path.load opam_path in
324324+ let opam_content =
325325+ Opam_transform.transform ~content:raw_content
326326+ ~dev_repo ~url_src
327327+ in
328328+ Some
329329+ { pkg_name; subtree; dev_repo; url_src; opam_content }
330330+ with Eio.Io _ -> None)
331331+ opam_files
332332+ in
333333+ (new_pkgs @ pkgs, errs))))
334334+ | _ ->
335335+ (* No dune-project, skip *)
336336+ Log.debug (fun m -> m "No dune-project in %s, skipping" subtree);
337337+ (pkgs, errs)
338338+ | exception Eio.Io _ ->
339339+ (pkgs, errs))
340340+ ([], []) subdirs
341341+ in
342342+343343+ if errors <> [] then
344344+ Log.warn (fun m ->
345345+ m "Encountered %d errors during monorepo discovery" (List.length errors));
346346+347347+ Log.info (fun m ->
348348+ m "Discovered %d packages from monorepo" (List.length packages));
349349+ Ok (List.rev packages)
350350+169351let get_branch ~config pkg =
170170- let default = Config.default_branch config in
352352+ let default = Config.default_branch in
171353 match Package.branch pkg with
172354 | Some b -> b
173355 | None ->
···241423 else dev_repo
242424 in
243425 let repo_cell =
244244- if i = 0 then Printf.sprintf "[**%s**](%s)" repo display_url
245245- else ""
426426+ if i = 0 then Printf.sprintf "[**%s**](%s)" repo display_url else ""
246427 in
247428 let synopsis = Option.value ~default:"" (Package.synopsis pkg) in
248429 Buffer.add_string buf
249249- (Printf.sprintf "| %s | %s | %s |\n" repo_cell
250250- (Package.name pkg) synopsis))
430430+ (Printf.sprintf "| %s | %s | %s |\n" repo_cell (Package.name pkg)
431431+ synopsis))
251432 pkgs)
252433 grouped;
253434 Buffer.add_string buf "\n---\n\n";
···366547(** Collect all external dependencies by scanning monorepo subtree directories.
367548 This scans all .opam files in each subtree directory to find dependencies,
368549 ensuring we get dependencies from all packages in a directory, not just
369369- those registered in the opam overlay.
370370- Returns a sorted, deduplicated list of package names that are dependencies
371371- but not packages in the repo itself. *)
550550+ those registered in the opam overlay. Returns a sorted, deduplicated list of
551551+ package names that are dependencies but not packages in the repo itself. *)
372552let collect_external_deps ~fs ~config pkgs =
373553 let monorepo = Config.Paths.monorepo config in
374554 (* Get unique repos to avoid scanning the same directory multiple times *)
···412592 (* Filter out packages that are in the repo *)
413593 List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps
414594415415-(** Generate dune-project content for the monorepo root.
416416- Lists all external dependencies as a virtual package. *)
595595+(** Generate dune-project content for the monorepo root. Lists all external
596596+ dependencies as a virtual package. *)
417597let generate_dune_project ~fs ~config pkgs =
418598 let external_deps = collect_external_deps ~fs ~config pkgs in
419599 let buf = Buffer.create 1024 in
···459639 Eio.Switch.run (fun sw ->
460640 let child =
461641 Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
462462- [ "git"; "commit"; "-m"; "Update dune-project with external dependencies" ]
642642+ [
643643+ "git";
644644+ "commit";
645645+ "-m";
646646+ "Update dune-project with external dependencies";
647647+ ]
463648 in
464649 ignore (Eio.Process.await child));
465650 Log.app (fun m ->
···613798 Log.app (fun m -> m "Updated CLAUDE.md")
614799 end
615800801801+(** Check if a host is a tangled server *)
802802+let is_tangled_host = function
803803+ | Some "tangled.org" | Some "tangled.sh" -> true
804804+ | _ -> false
805805+616806(** Convert a clone URL to a push URL.
617807 - GitHub HTTPS URLs are converted to SSH format
618618- - Tangled URLs (tangled.org) are converted to git.recoil.org SSH format
619619- - Other URLs are returned unchanged *)
620620-let url_to_push_url uri =
808808+ - Tangled URLs (tangled.org/tangled.sh) are converted to SSH format using the knot server
809809+ - Other URLs are returned unchanged
810810+ @param knot Git push server hostname. Defaults to git.recoil.org if not provided. *)
811811+let url_to_push_url ?knot uri =
621812 let scheme = Uri.scheme uri in
622813 let host = Uri.host uri in
623814 let path = Uri.path uri in
624815 match (scheme, host) with
625816 | Some ("https" | "http"), Some "github.com" ->
626817 (* https://github.com/user/repo.git -> git@github.com:user/repo.git *)
627627- let path = if String.length path > 0 && path.[0] = '/' then
628628- String.sub path 1 (String.length path - 1)
629629- else path in
818818+ let path =
819819+ if String.length path > 0 && path.[0] = '/' then
820820+ String.sub path 1 (String.length path - 1)
821821+ else path
822822+ in
630823 Printf.sprintf "git@github.com:%s" path
631631- | Some ("https" | "http"), Some "tangled.org" ->
632632- (* https://tangled.org/@anil.recoil.org/foo -> git@git.recoil.org:anil.recoil.org/foo *)
633633- let path = if String.length path > 0 && path.[0] = '/' then
634634- String.sub path 1 (String.length path - 1)
635635- else path in
636636- (* Strip leading @ from username if present *)
637637- let path = if String.length path > 0 && path.[0] = '@' then
638638- String.sub path 1 (String.length path - 1)
639639- else path in
824824+ | Some ("https" | "http"), _ when is_tangled_host host ->
825825+ (* https://tangled.org/@handle/repo -> git@<knot>:handle/repo *)
826826+ let path =
827827+ if String.length path > 0 && path.[0] = '/' then
828828+ String.sub path 1 (String.length path - 1)
829829+ else path
830830+ in
831831+ (* Strip leading @ from handle if present *)
832832+ let path =
833833+ if String.length path > 0 && path.[0] = '@' then
834834+ String.sub path 1 (String.length path - 1)
835835+ else path
836836+ in
640837 (* Strip .git suffix if present *)
641641- let path = if String.ends_with ~suffix:".git" path then
642642- String.sub path 0 (String.length path - 4)
643643- else path in
644644- Printf.sprintf "git@git.recoil.org:%s" path
838838+ let path =
839839+ if String.ends_with ~suffix:".git" path then
840840+ String.sub path 0 (String.length path - 4)
841841+ else path
842842+ in
843843+ (* Use provided knot or default to git.recoil.org *)
844844+ let knot_server = Option.value ~default:"git.recoil.org" knot in
845845+ Printf.sprintf "git@%s:%s" knot_server path
645846 | _ ->
646847 (* Return original URL for other cases *)
647848 Uri.to_string uri
···689890let pull_subtree ~proc ~fs ~config pkg =
690891 let fs = fs_typed fs in
691892 let monorepo = Config.Paths.monorepo config in
893893+ let checkouts_root = Config.Paths.checkouts config in
692894 let prefix = Package.subtree_prefix pkg in
693895 let branch = get_branch ~config pkg in
694694- let url = Package.dev_repo pkg in
896896+ (* Pull from local checkout, not remote URL - ensures push/pull use same source *)
897897+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
898898+ let url = Uri.of_string (Fpath.to_string checkout_dir) in
695899 if Git.Subtree.exists ~fs ~repo:monorepo ~prefix then begin
696696- Log.info (fun m -> m "Pulling subtree %s" prefix);
900900+ Log.info (fun m -> m "Pulling subtree %s from %a" prefix Fpath.pp checkout_dir);
697901 match Git.Subtree.pull ~proc ~fs ~repo:monorepo ~prefix ~url ~branch () with
698902 | Ok () -> Ok false (* not newly added *)
699903 | Error e -> Error (Git_error e)
700904 end
701905 else begin
702702- Log.info (fun m -> m "Adding subtree %s" prefix);
906906+ Log.info (fun m -> m "Adding subtree %s from %a" prefix Fpath.pp checkout_dir);
703907 match Git.Subtree.add ~proc ~fs ~repo:monorepo ~prefix ~url ~branch () with
704908 | Ok () -> Ok true (* newly added *)
705909 | Error e -> Error (Git_error e)
···743947 else begin
744948 (* Opam repo doesn't exist - clone it if we have a URL *)
745949 match opam_repo_url with
746746- | Some url ->
747747- Log.info (fun m -> m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
950950+ | Some url -> (
951951+ Log.info (fun m ->
952952+ m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
748953 let url = Uri.of_string url in
749749- let branch = Config.default_branch config in
750750- (match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
954954+ let branch = Config.default_branch in
955955+ match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
751956 | Ok () -> Log.info (fun m -> m "Opam repo cloned successfully")
752752- | Error e -> Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e))
957957+ | Error e ->
958958+ Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e)
959959+ )
753960 | None ->
754754- Log.info (fun m -> m "Opam repo at %a does not exist and no URL provided" Fpath.pp opam_repo)
961961+ Log.info (fun m ->
962962+ m "Opam repo at %a does not exist and no URL provided" Fpath.pp
963963+ opam_repo)
755964 end;
756965 (* Ensure directories exist before computing status *)
757966 ensure_checkouts_dir ~fs:fs_t ~config;
···8931102 end
8941103 end)
8951104896896-let run_git_in ~proc ~cwd args =
897897- Eio.Switch.run @@ fun sw ->
898898- let buf_stdout = Buffer.create 256 in
899899- let buf_stderr = Buffer.create 256 in
900900- let child =
901901- Eio.Process.spawn proc ~sw ~cwd
902902- ~stdout:(Eio.Flow.buffer_sink buf_stdout)
903903- ~stderr:(Eio.Flow.buffer_sink buf_stderr)
904904- ("git" :: args)
905905- in
906906- match Eio.Process.await child with
907907- | `Exited 0 -> Ok (Buffer.contents buf_stdout |> String.trim)
908908- | _ ->
909909- let result =
910910- Git.
911911- {
912912- exit_code = 1;
913913- stdout = Buffer.contents buf_stdout;
914914- stderr = Buffer.contents buf_stderr;
915915- }
916916- in
917917- Error (Git.Command_failed (String.concat " " ("git" :: args), result))
918918-9191105let push_one ~proc ~fs ~config pkg =
9201106 let ( let* ) r f =
9211107 Result.bind (Result.map_error (fun e -> Git_error e) r) f
···9261112 let checkouts_root = Config.Paths.checkouts config in
9271113 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
9281114 let branch = get_branch ~config pkg in
929929- let sync_branch = "monopam-sync" in
9301115 if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then begin
9311116 Log.debug (fun m -> m "Subtree %s not in monorepo, skipping" prefix);
9321117 Ok ()
···9411126 in
9421127 let* () =
9431128 if needs_clone then begin
944944- Log.info (fun m ->
945945- m "Creating checkout for %s" (Package.repo_name pkg));
11291129+ Log.info (fun m -> m "Creating checkout for %s" (Package.repo_name pkg));
9461130 ensure_checkout ~proc ~fs:(fs :> _ Eio.Path.t) ~config pkg
9471131 end
9481132 else Ok ()
9491133 in
950950- let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
951951- let checkout_path = Fpath.to_string checkout_dir in
952952- (* Push subtree to a sync branch (avoids "branch is checked out" error) *)
953953- Log.info (fun m -> m "Pushing subtree %s to checkout" prefix);
954954- let* _ =
955955- run_git_in ~proc ~cwd:monorepo_eio
956956- [
957957- "subtree"; "push"; "--prefix"; prefix; checkout_path; sync_branch;
958958- ]
959959- in
960960- (* Merge sync branch into the target branch in checkout *)
961961- Log.debug (fun m -> m "Merging %s into %s" sync_branch branch);
962962- let* _ =
963963- run_git_in ~proc ~cwd:checkout_eio
964964- [ "merge"; "--ff-only"; sync_branch ]
965965- in
966966- (* Delete the sync branch *)
967967- Log.debug (fun m -> m "Cleaning up %s branch" sync_branch);
968968- ignore
969969- (run_git_in ~proc ~cwd:checkout_eio [ "branch"; "-d"; sync_branch ]);
11341134+ (* Use git subtree push to export commits to the checkout.
11351135+ This preserves commit identity, ensuring round-trips converge. *)
11361136+ let checkout_url = Uri.of_string (Fpath.to_string checkout_dir) in
11371137+ Log.info (fun m -> m "Subtree push %s -> %a" prefix Fpath.pp checkout_dir);
11381138+ let* () = Git.Subtree.push ~proc ~fs ~repo:monorepo ~prefix ~url:checkout_url ~branch () in
9701139 Ok ()
9711140 end
9721141···10121181 | Ok pushed_repos ->
10131182 if upstream && pushed_repos <> [] then begin
10141183 Log.info (fun m ->
10151015- m "Pushing %d repos to upstream" (List.length pushed_repos));
11841184+ m "Pushing %d repos to upstream (parallel)"
11851185+ (List.length pushed_repos));
10161186 let checkouts_root = Config.Paths.checkouts config in
10171017- let total = List.length pushed_repos in
10181018- let rec push_upstream i = function
10191019- | [] -> Ok ()
10201020- | pkg :: rest -> (
11871187+ (* Push to remotes in parallel, limited to 2 concurrent pushes *)
11881188+ let push_results =
11891189+ Eio.Fiber.List.map ~max_fibers:2
11901190+ (fun pkg ->
10211191 let checkout_dir =
10221192 Package.checkout_dir ~checkouts_root pkg
10231193 in
10241194 let branch = get_branch ~config pkg in
10251025- (* Configure push URL (rewriting GitHub/tangled URLs to SSH) *)
10261195 let push_url = url_to_push_url (Package.dev_repo pkg) in
10271196 Log.info (fun m ->
10281028- m "[%d/%d] Pushing %s to %s" i total
10291029- (Package.repo_name pkg) push_url);
11971197+ m "Pushing %s to %s" (Package.repo_name pkg) push_url);
10301198 (* Set the push URL for origin *)
10311031- (match Git.set_push_url ~proc ~fs:fs_t ~url:push_url checkout_dir with
10321032- | Ok () -> ()
10331033- | Error e ->
10341034- Log.warn (fun m ->
10351035- m "Failed to set push URL: %a" Git.pp_error e));
11991199+ (match
12001200+ Git.set_push_url ~proc ~fs:fs_t ~url:push_url
12011201+ checkout_dir
12021202+ with
12031203+ | Ok () -> ()
12041204+ | Error e ->
12051205+ Log.warn (fun m ->
12061206+ m "Failed to set push URL: %a" Git.pp_error e));
10361207 match
10371208 Git.push_remote ~proc ~fs:fs_t ~branch checkout_dir
10381209 with
···10401211 Log.app (fun m ->
10411212 m " Pushed %s to %s (%s)" (Package.repo_name pkg)
10421213 push_url branch);
10431043- push_upstream (i + 1) rest
12141214+ Ok ()
10441215 | Error e -> Error (Git_error e))
12161216+ pushed_repos
10451217 in
10461046- push_upstream 1 pushed_repos
12181218+ (* Return first error if any *)
12191219+ match List.find_opt Result.is_error push_results with
12201220+ | Some (Error e) -> Error e
12211221+ | _ -> Ok ()
10471222 end
10481223 else Ok ()
10491224 end
···10741249 | `Push_remote -> Fmt.string ppf "push-remote"
1075125010761251let pp_sync_failure ppf f =
10771077- Fmt.pf ppf "%s (%a): %a" f.repo_name pp_sync_phase f.phase Git.pp_error f.error
12521252+ Fmt.pf ppf "%s (%a): %a" f.repo_name pp_sync_phase f.phase Git.pp_error
12531253+ f.error
1078125410791255let pp_sync_summary ppf s =
10801256 Fmt.pf ppf "Synced: %d, Unchanged: %d, Pulled: %d commits, Pushed: %d commits"
10811257 s.repos_synced s.repos_unchanged s.commits_pulled s.commits_pushed;
10821258 if s.errors <> [] then
10831083- Fmt.pf ppf "@.Errors (%d):@. @[<v>%a@]"
10841084- (List.length s.errors)
10851085- Fmt.(list ~sep:cut pp_sync_failure) s.errors
12591259+ Fmt.pf ppf "@.Errors (%d):@. @[<v>%a@]" (List.length s.errors)
12601260+ Fmt.(list ~sep:cut pp_sync_failure)
12611261+ s.errors
1086126210871263(* Helper to ensure checkout exists, returning whether it was cloned *)
10881264let ensure_checkout_safe ~proc ~fs ~config pkg =
···11011277 Log.info (fun m ->
11021278 m "Cloning %s from %a (branch: %s)" (Package.repo_name pkg) Uri.pp
11031279 (Package.dev_repo pkg) branch);
11041104- match Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir with
11051105- | Ok () -> Ok (true, 0)
12801280+ match
12811281+ Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir
12821282+ with
12831283+ | Ok () ->
12841284+ (* Configure checkout to accept pushes to current branch.
12851285+ This allows pushing from monorepo subtrees to the checkout. *)
12861286+ let cwd = Eio.Path.(fs / Fpath.to_string checkout_dir) in
12871287+ Eio.Switch.run (fun sw ->
12881288+ let child =
12891289+ Eio.Process.spawn proc ~sw ~cwd
12901290+ [ "git"; "config"; "receive.denyCurrentBranch"; "updateInstead" ]
12911291+ in
12921292+ ignore (Eio.Process.await child));
12931293+ Ok (true, 0)
11061294 | Error e -> Error e
11071295 end
11081108- else Ok (false, 0)
12961296+ else begin
12971297+ (* Ensure existing checkout is configured to accept pushes *)
12981298+ let cwd = Eio.Path.(fs / Fpath.to_string checkout_dir) in
12991299+ Eio.Switch.run (fun sw ->
13001300+ let child =
13011301+ Eio.Process.spawn proc ~sw ~cwd
13021302+ [ "git"; "config"; "receive.denyCurrentBranch"; "updateInstead" ]
13031303+ in
13041304+ ignore (Eio.Process.await child));
13051305+ Ok (false, 0)
13061306+ end
1109130711101308(* Fetch a single checkout - safe for parallel execution *)
11111309let fetch_checkout_safe ~proc ~fs ~config pkg =
···11471345 Log.info (fun m -> m "Pushing %s to %s" (Package.repo_name pkg) push_url);
11481346 (* Set the push URL for origin *)
11491347 (match Git.set_push_url ~proc ~fs ~url:push_url checkout_dir with
11501150- | Ok () -> ()
11511151- | Error e ->
11521152- Log.warn (fun m -> m "Failed to set push URL: %a" Git.pp_error e));
13481348+ | Ok () -> ()
13491349+ | Error e -> Log.warn (fun m -> m "Failed to set push URL: %a" Git.pp_error e));
11531350 Git.push_remote ~proc ~fs ~branch checkout_dir
1154135111551352(* Sanitize handle for use as git remote name *)
11561353let sanitize_remote_name handle =
11571354 (* Replace @ and . with - for valid git remote names *)
11581158- String.map (function
11591159- | '@' | '.' -> '-'
11601160- | c -> c) handle
13551355+ String.map (function '@' | '.' -> '-' | c -> c) handle
1161135611621357(* Ensure verse remotes for a single repo *)
11631358let ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg =
···11701365 else begin
11711366 (* Get all verse members who have this repo *)
11721367 let members_with_repo =
11731173- Hashtbl.find_opt verse_subtrees repo_name
11741174- |> Option.value ~default:[]
13681368+ Hashtbl.find_opt verse_subtrees repo_name |> Option.value ~default:[]
11751369 in
1176137011771371 (* Get current remotes *)
11781372 let current_remotes = Git.list_remotes ~proc ~fs checkout_dir in
11791373 let verse_remotes =
11801180- List.filter (fun r -> String.starts_with ~prefix:"verse-" r) current_remotes
13741374+ List.filter
13751375+ (fun r -> String.starts_with ~prefix:"verse-" r)
13761376+ current_remotes
11811377 in
1182137811831379 (* Build set of expected verse remotes *)
11841380 let expected_remotes =
11851185- List.map (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle) members_with_repo
13811381+ List.map
13821382+ (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle)
13831383+ members_with_repo
11861384 in
1187138511881386 (* Add/update remotes for verse members *)
11891189- List.iter (fun (handle, verse_mono_path) ->
13871387+ List.iter
13881388+ (fun (handle, verse_mono_path) ->
11901389 let remote_name = "verse-" ^ sanitize_remote_name handle in
11911390 (* Point to their src/ checkout for this repo *)
11921391 let verse_src = Fpath.(parent verse_mono_path / "src" / repo_name) in
11931392 if Sys.file_exists (Fpath.to_string verse_src) then begin
11941393 let url = Fpath.to_string verse_src in
11951195- match Git.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir with
11961196- | Ok () -> Log.debug (fun m -> m "Ensured verse remote %s -> %s" remote_name url)
11971197- | Error e -> Log.warn (fun m -> m "Failed to add verse remote %s: %a" remote_name Git.pp_error e)
13941394+ match
13951395+ Git.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir
13961396+ with
13971397+ | Ok () ->
13981398+ Log.debug (fun m ->
13991399+ m "Ensured verse remote %s -> %s" remote_name url)
14001400+ | Error e ->
14011401+ Log.warn (fun m ->
14021402+ m "Failed to add verse remote %s: %a" remote_name Git.pp_error
14031403+ e)
11981404 end)
11991405 members_with_repo;
1200140612011407 (* Remove outdated verse remotes *)
12021202- List.iter (fun remote_name ->
14081408+ List.iter
14091409+ (fun remote_name ->
12031410 if not (List.mem remote_name expected_remotes) then begin
12041411 Log.debug (fun m -> m "Removing outdated verse remote %s" remote_name);
12051412 match Git.remove_remote ~proc ~fs ~name:remote_name checkout_dir with
12061413 | Ok () -> ()
12071207- | Error e -> Log.warn (fun m -> m "Failed to remove verse remote %s: %a" remote_name Git.pp_error e)
14141414+ | Error e ->
14151415+ Log.warn (fun m ->
14161416+ m "Failed to remove verse remote %s: %a" remote_name
14171417+ Git.pp_error e)
12081418 end)
12091419 verse_remotes
12101420 end
···12121422(* Sync verse remotes for all repos *)
12131423let sync_verse_remotes ~proc ~fs ~config ~verse_config repos =
12141424 Log.app (fun m -> m " Updating verse remotes...");
12151215- let verse_subtrees = Verse.get_verse_subtrees ~proc ~fs ~config:verse_config () in
12161216- List.iter (fun pkg ->
14251425+ let verse_subtrees =
14261426+ Verse.get_verse_subtrees ~proc ~fs ~config:verse_config ()
14271427+ in
14281428+ List.iter
14291429+ (fun pkg ->
12171430 ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg)
12181431 repos
12191432···12211434let fetch_verse_remotes ~proc ~fs ~config pkg =
12221435 let checkouts_root = Config.Paths.checkouts config in
12231436 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
12241224- (* Skip packages without checkouts *)
12251225- if not (Git.is_repo ~proc ~fs checkout_dir) then
12261226- Log.info (fun m -> m "Skipping verse fetch for %s (no checkout)" (Package.repo_name pkg))
12271227- else begin
12281228- let remotes = Git.list_remotes ~proc ~fs checkout_dir in
12291229- let verse_remotes = List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes in
12301230- List.iter (fun remote ->
12311231- Log.debug (fun m -> m "Fetching from verse remote %s" remote);
12321232- match Git.fetch ~proc ~fs ~remote checkout_dir with
12331233- | Ok () -> ()
12341234- | Error e -> Log.debug (fun m -> m "Failed to fetch from %s: %a" remote Git.pp_error e))
12351235- verse_remotes
12361236- end
14371437+ let remotes = Git.list_remotes ~proc ~fs checkout_dir in
14381438+ let verse_remotes =
14391439+ List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes
14401440+ in
14411441+ List.iter
14421442+ (fun remote ->
14431443+ Log.debug (fun m -> m "Fetching from verse remote %s" remote);
14441444+ match Git.fetch ~proc ~fs ~remote checkout_dir with
14451445+ | Ok () -> ()
14461446+ | Error e ->
14471447+ Log.debug (fun m ->
14481448+ m "Failed to fetch from %s: %a" remote Git.pp_error e))
14491449+ verse_remotes
1237145012381238-let sync ~proc ~fs ~config ?package ?(remote = false) ?(skip_push = false) ?(skip_pull = false) () =
14511451+(* Helper to read file contents, returning None if file doesn't exist *)
14521452+let read_file_opt path = try Some (Eio.Path.load path) with Eio.Io _ -> None
14531453+14541454+(* Regenerate opam-repo entries from monorepo dune-project files.
14551455+ This ensures URLs in opam-repo match the monorepo before sync. *)
14561456+let regenerate_opam_repo ~fs ~config () =
14571457+ let monorepo = Config.Paths.monorepo config in
14581458+ let sources_path = Fpath.(monorepo / "sources.toml") in
14591459+ let sources =
14601460+ match Sources_registry.load ~fs sources_path with
14611461+ | Ok s -> s
14621462+ | Error _ -> Sources_registry.empty
14631463+ in
14641464+ match discover_packages_from_monorepo ~fs ~config ~sources () with
14651465+ | Error _ -> () (* Skip on error *)
14661466+ | Ok pkgs ->
14671467+ let opam_repo = Config.Paths.opam_repo config in
14681468+ let updated = ref 0 in
14691469+ List.iter
14701470+ (fun pkg ->
14711471+ let pkg_dir =
14721472+ Fpath.(opam_repo / "packages" / pkg.pkg_name / (pkg.pkg_name ^ ".dev"))
14731473+ in
14741474+ let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in
14751475+ let dst_content = read_file_opt dst_path in
14761476+ if Some pkg.opam_content <> dst_content then begin
14771477+ let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in
14781478+ (try Eio.Path.mkdirs ~perm:0o755 pkg_dir_eio with Eio.Io _ -> ());
14791479+ Eio.Path.save ~create:(`Or_truncate 0o644) dst_path pkg.opam_content;
14801480+ incr updated
14811481+ end)
14821482+ pkgs;
14831483+ if !updated > 0 then
14841484+ Log.info (fun m -> m "Regenerated %d opam-repo entries from monorepo" !updated)
14851485+14861486+(** Clone monorepo and opam-repo from verse registry if they don't exist locally.
14871487+ This enables `monopam sync` to work in a fresh devcontainer. *)
14881488+let clone_from_verse_if_needed ~proc ~fs ~config () =
14891489+ let monorepo = Config.Paths.monorepo config in
14901490+ let opam_repo = Config.Paths.opam_repo config in
14911491+ let monorepo_exists = Git.is_repo ~proc ~fs monorepo in
14921492+ let opam_repo_exists = Git.is_repo ~proc ~fs opam_repo in
14931493+14941494+ (* If both exist, nothing to do *)
14951495+ if monorepo_exists && opam_repo_exists then Ok ()
14961496+ else
14971497+ (* Try to load verse config to get handle *)
14981498+ match Verse_config.load ~fs () with
14991499+ | Error _ ->
15001500+ (* No verse config - can't clone from registry *)
15011501+ Log.debug (fun m -> m "No verse config found, will initialize fresh repos");
15021502+ Ok ()
15031503+ | Ok verse_config ->
15041504+ let handle = Verse_config.handle verse_config in
15051505+ Log.info (fun m -> m "Found verse config for handle: %s" handle);
15061506+ (* Load registry to look up URLs *)
15071507+ match Verse_registry.clone_or_pull ~proc ~fs ~config:verse_config () with
15081508+ | Error msg ->
15091509+ Log.warn (fun m -> m "Could not load verse registry: %s" msg);
15101510+ Ok () (* Continue without cloning - will init fresh *)
15111511+ | Ok registry ->
15121512+ match Verse_registry.find_member registry ~handle with
15131513+ | None ->
15141514+ Log.warn (fun m -> m "Handle %s not found in registry" handle);
15151515+ Ok ()
15161516+ | Some member ->
15171517+ (* Clone monorepo if needed *)
15181518+ let result =
15191519+ if monorepo_exists then Ok ()
15201520+ else begin
15211521+ Log.app (fun m -> m "Cloning monorepo from %s..." member.monorepo);
15221522+ let url = Uri.of_string member.monorepo in
15231523+ let branch = Option.value ~default:"main" member.monorepo_branch in
15241524+ match Git.clone ~proc ~fs ~url ~branch monorepo with
15251525+ | Ok () ->
15261526+ Log.app (fun m -> m "Monorepo cloned successfully");
15271527+ Ok ()
15281528+ | Error e ->
15291529+ Log.err (fun m -> m "Failed to clone monorepo: %a" Git.pp_error e);
15301530+ Error (Git_error e)
15311531+ end
15321532+ in
15331533+ match result with
15341534+ | Error e -> Error e
15351535+ | Ok () ->
15361536+ (* Clone opam-repo if needed *)
15371537+ if opam_repo_exists then Ok ()
15381538+ else begin
15391539+ Log.app (fun m -> m "Cloning opam-repo from %s..." member.opamrepo);
15401540+ let url = Uri.of_string member.opamrepo in
15411541+ let branch = Option.value ~default:"main" member.opamrepo_branch in
15421542+ match Git.clone ~proc ~fs ~url ~branch opam_repo with
15431543+ | Ok () ->
15441544+ Log.app (fun m -> m "Opam-repo cloned successfully");
15451545+ Ok ()
15461546+ | Error e ->
15471547+ Log.err (fun m -> m "Failed to clone opam-repo: %a" Git.pp_error e);
15481548+ Error (Git_error e)
15491549+ end
15501550+15511551+let sync ~proc ~fs ~config ?package ?(remote = false) ?(skip_push = false)
15521552+ ?(skip_pull = false) () =
12391553 let fs_t = fs_typed fs in
15541554+15551555+ (* Step 0: Sync verse members if verse config exists and not skipping pull *)
15561556+ (if not skip_pull then
15571557+ match Verse_config.load ~fs:fs_t () with
15581558+ | Error _ -> () (* No verse config = skip *)
15591559+ | Ok verse_config ->
15601560+ Log.app (fun m -> m "Syncing verse members...");
15611561+ match Verse.pull ~proc ~fs:fs_t ~config:verse_config () with
15621562+ | Ok () -> ()
15631563+ | Error e -> Log.warn (fun m -> m "Verse sync: %a" Verse.pp_error e));
15641564+15651565+ (* Clone from verse registry if repos don't exist *)
15661566+ match clone_from_verse_if_needed ~proc ~fs:fs_t ~config () with
15671567+ | Error e -> Error e
15681568+ | Ok () ->
15691569+12401570 (* Update the opam repo first - clone if needed *)
12411571 let opam_repo = Config.Paths.opam_repo config in
12421572 if (not skip_pull) && Git.is_repo ~proc ~fs:fs_t opam_repo then begin
···12561586 match ensure_monorepo_initialized ~proc ~fs:fs_t ~config with
12571587 | Error e -> Error e
12581588 | Ok () -> (
15891589+ (* Check for uncommitted changes in monorepo *)
15901590+ let monorepo = Config.Paths.monorepo config in
15911591+ if Git.is_dirty ~proc ~fs:fs_t monorepo then begin
15921592+ Log.err (fun m -> m "Monorepo has uncommitted changes");
15931593+ Error Monorepo_dirty
15941594+ end
15951595+ else begin
15961596+ (* Regenerate opam-repo from monorepo to ensure URLs are up to date *)
15971597+ regenerate_opam_repo ~fs:(fs_t :> _ Eio.Path.t) ~config ();
12591598 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
12601599 | Error e -> Error e
12611600 | Ok all_pkgs ->
···12811620 let total = List.length repos in
12821621 Log.app (fun m -> m "Syncing %d repositories..." total);
1283162216231623+ (* Build status lookup for optimization *)
16241624+ let status_by_name =
16251625+ List.map (fun s -> (Package.name s.Status.package, s)) statuses
16261626+ in
16271627+ let sync_needs_push = function
16281628+ | Status.Subtree_ahead _ | Status.Trees_differ -> true
16291629+ | Status.In_sync | Status.Subtree_behind _ | Status.Unknown ->
16301630+ false
16311631+ in
16321632+ let needs_push pkg =
16331633+ List.assoc_opt (Package.name pkg) status_by_name
16341634+ |> Option.fold ~none:true ~some:(fun s ->
16351635+ sync_needs_push s.Status.subtree_sync)
16361636+ in
16371637+ let sync_needs_pull = function
16381638+ | Status.Subtree_behind _ | Status.Trees_differ -> true
16391639+ | Status.In_sync | Status.Subtree_ahead _ | Status.Unknown ->
16401640+ false
16411641+ in
16421642+ let needs_pull pkg =
16431643+ List.assoc_opt (Package.name pkg) status_by_name
16441644+ |> Option.fold ~none:true ~some:(fun s ->
16451645+ sync_needs_pull s.Status.subtree_sync)
16461646+ in
16471647+12841648 (* Step 2: Push phase - export monorepo changes to checkouts (PARALLEL) *)
12851649 (* git subtree push is read-only on the monorepo, so safe to parallelize *)
16501650+ (* OPTIMIZATION: skip packages already in sync *)
12861651 let push_results =
12871652 if skip_push then begin
12881288- Log.app (fun m -> m " Skipping push to checkouts (--skip-push)");
16531653+ Log.app (fun m ->
16541654+ m " Skipping push to checkouts (--skip-push)");
12891655 List.map (fun pkg -> Ok (Package.repo_name pkg)) repos
12901656 end
12911657 else begin
12921292- Log.app (fun m -> m " Pushing monorepo changes to checkouts (parallel)...");
12931293- Eio.Fiber.List.map ~max_fibers:12 (fun pkg ->
12941294- let repo_name = Package.repo_name pkg in
12951295- Log.info (fun m -> m "Push to checkout: %s" repo_name);
12961296- match push_one ~proc ~fs ~config pkg with
12971297- | Ok () -> Ok repo_name
12981298- | Error (Git_error e) ->
12991299- Error { repo_name; phase = `Push_checkout; error = e }
13001300- | Error _ -> Ok repo_name)
13011301- repos
16581658+ let to_push, to_skip = List.partition needs_push repos in
16591659+ Log.app (fun m ->
16601660+ m " Pushing monorepo changes to checkouts (parallel)...");
16611661+ if to_skip <> [] then
16621662+ Log.app (fun m ->
16631663+ m " Skipping %d already-synced packages"
16641664+ (List.length to_skip));
16651665+ (* Local git subtree push - no parallelism limit needed *)
16661666+ let pushed =
16671667+ Eio.Fiber.List.map
16681668+ (fun pkg ->
16691669+ let repo_name = Package.repo_name pkg in
16701670+ Log.info (fun m -> m "Push to checkout: %s" repo_name);
16711671+ match push_one ~proc ~fs ~config pkg with
16721672+ | Ok () -> Ok repo_name
16731673+ | Error (Git_error e) ->
16741674+ Error
16751675+ { repo_name; phase = `Push_checkout; error = e }
16761676+ | Error _ -> Ok repo_name)
16771677+ to_push
16781678+ in
16791679+ let skipped_ok =
16801680+ List.map (fun pkg -> Ok (Package.repo_name pkg)) to_skip
16811681+ in
16821682+ pushed @ skipped_ok
13021683 end
13031684 in
13041685 let push_errors =
13051305- List.filter_map (function Error e -> Some e | Ok _ -> None) push_results
16861686+ List.filter_map
16871687+ (function Error e -> Some e | Ok _ -> None)
16881688+ push_results
13061689 in
1307169013081691 (* Steps 3-5: Pull phases (fetch, merge, subtree) - skip if --skip-pull *)
13091309- let fetch_errors, unchanged_count, total_commits_pulled, merge_errors, subtree_errors =
16921692+ let ( fetch_errors,
16931693+ unchanged_count,
16941694+ total_commits_pulled,
16951695+ merge_errors,
16961696+ subtree_errors,
16971697+ successfully_fetched_repos ) =
13101698 if skip_pull then begin
13111311- Log.app (fun m -> m " Skipping pull from remotes (--skip-pull)");
13121312- ([], List.length repos, 0, ref [], ref [])
16991699+ Log.app (fun m ->
17001700+ m " Skipping pull from remotes (--skip-pull)");
17011701+ ([], List.length repos, 0, ref [], ref [], repos)
13131702 end
13141703 else begin
13151704 (* Step 3: Fetch phase - clone/fetch from remotes (PARALLEL) *)
13161705 Log.app (fun m -> m " Fetching from remotes (parallel)...");
13171317- let fetch_results = Eio.Fiber.List.map ~max_fibers:3 (fun pkg ->
13181318- let repo_name = Package.repo_name pkg in
13191319- (* First ensure checkout exists *)
13201320- match ensure_checkout_safe ~proc ~fs:fs_t ~config pkg with
13211321- | Error e -> Error { repo_name; phase = `Fetch; error = e }
13221322- | Ok (was_cloned, _) ->
13231323- if was_cloned then Ok (repo_name, true, 0)
13241324- else
13251325- match fetch_checkout_safe ~proc ~fs:fs_t ~config pkg with
13261326- | Error e -> Error { repo_name; phase = `Fetch; error = e }
13271327- | Ok commits -> Ok (repo_name, false, commits))
13281328- repos
17061706+ let fetch_results =
17071707+ Eio.Fiber.List.map ~max_fibers:4
17081708+ (fun pkg ->
17091709+ let repo_name = Package.repo_name pkg in
17101710+ (* First ensure checkout exists *)
17111711+ match
17121712+ ensure_checkout_safe ~proc ~fs:fs_t ~config pkg
17131713+ with
17141714+ | Error e ->
17151715+ Error { repo_name; phase = `Fetch; error = e }
17161716+ | Ok (was_cloned, _) -> (
17171717+ if was_cloned then Ok (repo_name, true, 0)
17181718+ else
17191719+ match
17201720+ fetch_checkout_safe ~proc ~fs:fs_t ~config pkg
17211721+ with
17221722+ | Error e ->
17231723+ Error { repo_name; phase = `Fetch; error = e }
17241724+ | Ok commits -> Ok (repo_name, false, commits)))
17251725+ repos
13291726 in
13301727 let fetch_errs, fetch_successes =
13311331- List.partition_map (function
13321332- | Error e -> Left e
13331333- | Ok r -> Right r)
17281728+ List.partition_map
17291729+ (function Error e -> Left e | Ok r -> Right r)
13341730 fetch_results
13351731 in
13361336- let cloned = List.filter (fun (_, c, _) -> c) fetch_successes in
13371337- let updated = List.filter (fun (_, c, commits) -> not c && commits > 0) fetch_successes in
13381338- let unchanged = List.length fetch_successes - List.length cloned - List.length updated in
13391339- let commits_pulled = List.fold_left (fun acc (_, _, c) -> acc + c) 0 fetch_successes in
13401340- Log.app (fun m -> m " Pulled: %d cloned, %d updated, %d unchanged"
13411341- (List.length cloned) (List.length updated) unchanged);
17321732+ let cloned =
17331733+ List.filter (fun (_, c, _) -> c) fetch_successes
17341734+ in
17351735+ let updated =
17361736+ List.filter
17371737+ (fun (_, c, commits) -> (not c) && commits > 0)
17381738+ fetch_successes
17391739+ in
17401740+ let unchanged =
17411741+ List.length fetch_successes
17421742+ - List.length cloned - List.length updated
17431743+ in
17441744+ let commits_pulled =
17451745+ List.fold_left
17461746+ (fun acc (_, _, c) -> acc + c)
17471747+ 0 fetch_successes
17481748+ in
17491749+ Log.app (fun m ->
17501750+ m " Pulled: %d cloned, %d updated, %d unchanged"
17511751+ (List.length cloned) (List.length updated) unchanged);
17521752+17531753+ (* Filter repos to only those that were successfully fetched *)
17541754+ let success_names =
17551755+ List.map (fun (name, _, _) -> name) fetch_successes
17561756+ in
17571757+ let successfully_fetched =
17581758+ List.filter
17591759+ (fun pkg -> List.mem (Package.repo_name pkg) success_names)
17601760+ repos
17611761+ in
1342176213431763 (* Step 4: Merge phase - fast-forward merge checkouts (SEQUENTIAL) *)
13441764 Log.app (fun m -> m " Merging checkouts...");
13451765 let merge_errs = ref [] in
13461346- let checkouts_root = Config.Paths.checkouts config in
13471347- List.iter (fun pkg ->
13481348- let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
13491349- (* Skip packages without checkouts *)
13501350- if not (Git.is_repo ~proc ~fs:fs_t checkout_dir) then
13511351- Log.info (fun m -> m "Skipping %s (no checkout)" (Package.repo_name pkg))
13521352- else
13531353- match merge_checkout_safe ~proc ~fs:fs_t ~config pkg with
13541354- | Ok () -> ()
13551355- | Error e ->
13561356- merge_errs := { repo_name = Package.repo_name pkg;
13571357- phase = `Merge; error = e } :: !merge_errs)
13581358- repos;
17661766+ List.iter
17671767+ (fun pkg ->
17681768+ match merge_checkout_safe ~proc ~fs:fs_t ~config pkg with
17691769+ | Ok () -> ()
17701770+ | Error e ->
17711771+ merge_errs :=
17721772+ {
17731773+ repo_name = Package.repo_name pkg;
17741774+ phase = `Merge;
17751775+ error = e;
17761776+ }
17771777+ :: !merge_errs)
17781778+ successfully_fetched;
1359177913601780 (* Step 5: Subtree phase - pull subtrees into monorepo (SEQUENTIAL) *)
13611781 (* Check if monorepo has local modifications first *)
···13641784 let subtree_errs = ref [] in
13651785 if monorepo_dirty then begin
13661786 Log.warn (fun m ->
13671367- m "Monorepo has uncommitted changes, skipping subtree pulls");
13681368- Log.app (fun m -> m " Skipping subtree updates (local modifications)...")
17871787+ m
17881788+ "Monorepo has uncommitted changes, skipping subtree \
17891789+ pulls");
17901790+ Log.app (fun m ->
17911791+ m " Skipping subtree updates (local modifications)...")
13691792 end
13701793 else begin
17941794+ (* OPTIMIZATION: skip packages already in sync *)
17951795+ (* But always pull repos that received commits from fetch *)
17961796+ let repos_updated_by_fetch =
17971797+ List.filter_map
17981798+ (fun (name, was_cloned, commits) ->
17991799+ if was_cloned || commits > 0 then Some name else None)
18001800+ fetch_successes
18011801+ in
18021802+ let needs_pull_after_fetch pkg =
18031803+ needs_pull pkg
18041804+ || List.mem (Package.repo_name pkg) repos_updated_by_fetch
18051805+ in
18061806+ let to_pull, to_skip =
18071807+ List.partition needs_pull_after_fetch successfully_fetched
18081808+ in
13711809 Log.app (fun m -> m " Updating subtrees...");
13721372- List.iteri (fun i pkg ->
18101810+ if to_skip <> [] then
18111811+ Log.app (fun m ->
18121812+ m " Skipping %d already-synced subtrees"
18131813+ (List.length to_skip));
18141814+ let pull_count = List.length to_pull in
18151815+ List.iteri
18161816+ (fun i pkg ->
13731817 Log.info (fun m ->
13741374- m "[%d/%d] Subtree %s" (i + 1) total
18181818+ m "[%d/%d] Subtree %s" (i + 1) pull_count
13751819 (Package.subtree_prefix pkg));
13761820 match pull_subtree ~proc ~fs ~config pkg with
13771821 | Ok _ -> ()
13781822 | Error (Git_error e) ->
13791379- subtree_errs := { repo_name = Package.repo_name pkg;
13801380- phase = `Subtree; error = e } :: !subtree_errs
18231823+ subtree_errs :=
18241824+ {
18251825+ repo_name = Package.repo_name pkg;
18261826+ phase = `Subtree;
18271827+ error = e;
18281828+ }
18291829+ :: !subtree_errs
13811830 | Error _ -> ())
13821382- repos
18311831+ to_pull
13831832 end;
13841384- (fetch_errs, unchanged, commits_pulled, merge_errs, subtree_errs)
18331833+ ( fetch_errs,
18341834+ unchanged,
18351835+ commits_pulled,
18361836+ merge_errs,
18371837+ subtree_errs,
18381838+ successfully_fetched )
13851839 end
13861840 in
1387184113881842 (* Step 5.5: Verse remotes - update and fetch from verse members *)
18431843+ (* Only operate on successfully fetched repos to avoid missing directory errors *)
13891844 (match Verse_config.load ~fs:(fs_t :> _ Eio.Path.t) () with
13901390- | Error _ -> () (* No verse config, skip verse remotes *)
13911391- | Ok verse_config ->
13921392- sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config repos;
13931393- (* Fetch from verse remotes in parallel *)
13941394- Log.app (fun m -> m " Fetching from verse remotes...");
13951395- Eio.Fiber.List.iter ~max_fibers:4 (fun pkg ->
13961396- fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
13971397- repos);
18451845+ | Error _ -> () (* No verse config, skip verse remotes *)
18461846+ | Ok verse_config ->
18471847+ sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config successfully_fetched_repos;
18481848+ (* Fetch from verse remotes in parallel *)
18491849+ Log.app (fun m -> m " Fetching from verse remotes...");
18501850+ Eio.Fiber.List.iter ~max_fibers:4
18511851+ (fun pkg -> fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
18521852+ successfully_fetched_repos);
1398185313991854 (* Step 6: Finalize - write README.md, CLAUDE.md, and dune-project (SEQUENTIAL) *)
14001400- Log.app (fun m -> m " Writing README.md, CLAUDE.md, and dune-project...");
18551855+ Log.app (fun m ->
18561856+ m " Writing README.md, CLAUDE.md, and dune-project...");
14011857 write_readme ~proc ~fs:fs_t ~config all_pkgs;
14021858 write_claude_md ~proc ~fs:fs_t ~config;
14031859 write_dune_project ~proc ~fs:fs_t ~config all_pkgs;
1404186014051861 (* Step 7: Remote phase - push to upstream remotes if --remote (LIMITED PARALLEL) *)
18621862+ (* Only push repos that were successfully fetched *)
14061863 let remote_errors =
14071864 if remote then begin
14081865 Log.app (fun m -> m " Pushing to upstream remotes...");
14091866 (* Limit to 2 concurrent pushes to avoid overwhelming remotes *)
14101410- let push_results = Eio.Fiber.List.map ~max_fibers:2 (fun pkg ->
14111411- let repo_name = Package.repo_name pkg in
14121412- match push_remote_safe ~proc ~fs:fs_t ~config pkg with
14131413- | Error e -> Error { repo_name; phase = `Push_remote; error = e }
14141414- | Ok () ->
14151415- Log.app (fun m -> m " Pushed %s" repo_name);
14161416- Ok repo_name)
14171417- repos
18671867+ let push_results =
18681868+ Eio.Fiber.List.map ~max_fibers:2
18691869+ (fun pkg ->
18701870+ let repo_name = Package.repo_name pkg in
18711871+ match push_remote_safe ~proc ~fs:fs_t ~config pkg with
18721872+ | Error e ->
18731873+ Error { repo_name; phase = `Push_remote; error = e }
18741874+ | Ok () ->
18751875+ Log.app (fun m -> m " Pushed %s" repo_name);
18761876+ Ok repo_name)
18771877+ successfully_fetched_repos
14181878 in
14191879 let errors, successes =
14201420- List.partition_map (function
14211421- | Error e -> Left e
14221422- | Ok r -> Right r)
18801880+ List.partition_map
18811881+ (function Error e -> Left e | Ok r -> Right r)
14231882 push_results
14241883 in
14251425- Log.app (fun m -> m " Pushed: %d repos to upstream" (List.length successes));
18841884+ Log.app (fun m ->
18851885+ m " Pushed: %d repos to upstream" (List.length successes));
14261886 errors
14271887 end
14281888 else []
···1430189014311891 (* Collect all errors *)
14321892 let all_errors =
14331433- push_errors @ fetch_errors @ !merge_errors @ !subtree_errors @ remote_errors
18931893+ push_errors @ fetch_errors @ !merge_errors @ !subtree_errors
18941894+ @ remote_errors
14341895 in
14351435- let summary = {
14361436- repos_synced = List.length repos - List.length all_errors;
14371437- repos_unchanged = unchanged_count;
14381438- commits_pulled = total_commits_pulled;
14391439- commits_pushed = 0; (* TODO: track this *)
14401440- errors = all_errors;
14411441- } in
18961896+ let summary =
18971897+ {
18981898+ repos_synced = List.length repos - List.length all_errors;
18991899+ repos_unchanged = unchanged_count;
19001900+ commits_pulled = total_commits_pulled;
19011901+ commits_pushed = 0;
19021902+ (* TODO: track this *)
19031903+ errors = all_errors;
19041904+ }
19051905+ in
1442190614431907 (* Print summary *)
14441444- Log.app (fun m -> m "@.Summary: %d synced, %d errors"
14451445- summary.repos_synced (List.length summary.errors));
19081908+ Log.app (fun m ->
19091909+ m "@.Summary: %d synced, %d errors" summary.repos_synced
19101910+ (List.length summary.errors));
14461911 if summary.errors <> [] then
14471447- List.iter (fun e ->
14481448- Log.warn (fun m -> m " %a" pp_sync_failure e))
19121912+ List.iter
19131913+ (fun e -> Log.warn (fun m -> m " %a" pp_sync_failure e))
14491914 summary.errors;
1450191514511916 Ok summary
14521917 end
14531453- end)
19181918+ end
19191919+ end)
1454192014551921(* Opam metadata sync: copy .opam files from monorepo subtrees to opam-repo *)
1456192214571923type opam_sync_result = {
14581458- synced : string list; (* packages that were updated *)
14591459- unchanged : string list; (* packages that were already in sync *)
14601460- missing : string list; (* packages where monorepo has no .opam file *)
14611461- orphaned : string list; (* packages in opam-repo but subtree missing from monorepo *)
19241924+ synced : string list; (* packages that were updated *)
19251925+ unchanged : string list; (* packages that were already in sync *)
19261926+ missing : string list; (* packages where monorepo has no .opam file *)
19271927+ orphaned : string list;
19281928+ (* packages in opam-repo but subtree missing from monorepo *)
14621929}
1463193014641931let pp_opam_sync_result ppf r =
···14661933 (List.length r.synced) (List.length r.unchanged) (List.length r.missing)
14671934 (List.length r.orphaned)
1468193514691469-(* Read file contents safely, returning None if file doesn't exist *)
14701470-let read_file_opt path =
14711471- try Some (Eio.Path.load path)
14721472- with Eio.Io _ -> None
14731473-14741474-(* Sync a single package's opam file from monorepo to opam-repo *)
14751475-let sync_opam_file ~proc ~fs ~config pkg =
14761476- let monorepo = Config.Paths.monorepo config in
19361936+(* List all package directories in opam-repo/packages/ *)
19371937+let list_opam_repo_packages ~fs ~config =
14771938 let opam_repo = Config.Paths.opam_repo config in
14781478- let name = Package.name pkg in
14791479- let subtree_prefix = Package.subtree_prefix pkg in
14801480- let version = Package.version pkg in
19391939+ let packages_dir = Eio.Path.(fs / Fpath.to_string opam_repo / "packages") in
19401940+ try
19411941+ Eio.Path.read_dir packages_dir
19421942+ |> List.filter (fun name ->
19431943+ let child = Eio.Path.(packages_dir / name) in
19441944+ match Eio.Path.kind ~follow:false child with
19451945+ | `Directory -> true
19461946+ | _ -> false)
19471947+ with Eio.Io _ -> []
1481194814821482- (* Source: monorepo/<subtree>/<name>.opam *)
14831483- let src_path = Eio.Path.(fs / Fpath.to_string monorepo / subtree_prefix / (name ^ ".opam")) in
14841484-14851485- (* Destination: opam-repo/packages/<name>/<name>.<version>/opam *)
14861486- let pkg_dir = Fpath.(opam_repo / "packages" / name / (name ^ "." ^ version)) in
14871487- let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in
14881488-14891489- match read_file_opt src_path with
14901490- | None ->
14911491- (* No opam file in monorepo subtree *)
14921492- `Missing name
14931493- | Some src_content ->
14941494- let dst_content = read_file_opt dst_path in
14951495- if Some src_content = dst_content then
14961496- `Unchanged name
14971497- else begin
14981498- (* Create destination directory if needed *)
14991499- let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in
15001500- (try mkdirs pkg_dir_eio with _ -> ());
15011501- (* Write the opam file *)
15021502- Log.info (fun m -> m "Syncing %s.opam to opam-repo" name);
15031503- Eio.Path.save ~create:(`Or_truncate 0o644) dst_path src_content;
15041504- (* Stage the change *)
15051505- let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
15061506- let rel_path = Printf.sprintf "packages/%s/%s.%s/opam" name name version in
15071507- Eio.Switch.run (fun sw ->
15081508- let child =
15091509- Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
15101510- [ "git"; "add"; rel_path ]
15111511- in
15121512- ignore (Eio.Process.await child));
15131513- `Synced name
15141514- end
19491949+(* Delete a package directory from opam-repo *)
19501950+let delete_opam_repo_package ~proc ~fs ~config name =
19511951+ let opam_repo = Config.Paths.opam_repo config in
19521952+ let pkg_dir = Eio.Path.(fs / Fpath.to_string opam_repo / "packages" / name) in
19531953+ try
19541954+ Eio.Path.rmtree pkg_dir;
19551955+ (* Stage the deletion *)
19561956+ let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
19571957+ let rel_path = Printf.sprintf "packages/%s" name in
19581958+ Eio.Switch.run (fun sw ->
19591959+ let child =
19601960+ Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
19611961+ [ "git"; "add"; "-A"; rel_path ]
19621962+ in
19631963+ ignore (Eio.Process.await child));
19641964+ Log.info (fun m -> m "Deleted orphaned package %s from opam-repo" name);
19651965+ true
19661966+ with Eio.Io _ ->
19671967+ Log.warn (fun m -> m "Failed to delete package %s" name);
19681968+ false
1515196915161516-(* Sync opam files for all packages *)
19701970+(* Sync opam files for all packages - generation-based approach *)
15171971let sync_opam_files ~proc ~fs ~config ?package () =
15181972 let fs = fs_typed fs in
15191519- match discover_packages ~fs:(fs :> _ Eio.Path.t) ~config () with
19731973+19741974+ (* Load sources.toml for URL overrides *)
19751975+ let monorepo = Config.Paths.monorepo config in
19761976+ let sources_path = Fpath.(monorepo / "sources.toml") in
19771977+ let sources =
19781978+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
19791979+ | Ok s ->
19801980+ let count = List.length (Sources_registry.to_list s) in
19811981+ if count > 0 then
19821982+ Log.info (fun m -> m "Loaded %d source overrides from sources.toml" count);
19831983+ s
19841984+ | Error msg ->
19851985+ Log.warn (fun m -> m "Failed to load sources.toml: %s" msg);
19861986+ Sources_registry.empty
19871987+ in
19881988+19891989+ (* Discover packages from monorepo *)
19901990+ match discover_packages_from_monorepo ~fs:(fs :> _ Eio.Path.t) ~config ~sources () with
15201991 | Error e -> Error e
15211992 | Ok all_pkgs ->
19931993+ (* Filter to specific package/subtree if requested *)
15221994 let pkgs =
15231995 match package with
15241996 | None -> all_pkgs
15251525- | Some name -> List.filter (fun p -> Package.name p = name) all_pkgs
19971997+ | Some name ->
19981998+ List.filter
19991999+ (fun p -> p.pkg_name = name || p.subtree = name)
20002000+ all_pkgs
15262001 in
15271527- if pkgs = [] && package <> None then
15281528- Error (Package_not_found (Option.get package))
15291529- else begin
15301530- Log.app (fun m -> m "Syncing opam files for %d packages..." (List.length pkgs));
15311531- let synced = ref [] in
15321532- let unchanged = ref [] in
15331533- let missing = ref [] in
15341534- let orphaned = ref [] in
1535200215361536- (* Check each package *)
15371537- List.iter (fun pkg ->
15381538- (* Check if the subtree exists in monorepo *)
15391539- let monorepo = Config.Paths.monorepo config in
15401540- let subtree_prefix = Package.subtree_prefix pkg in
15411541- let subtree_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subtree_prefix in
20032003+ Log.app (fun m ->
20042004+ m "Generating opam-repo entries for %d packages..." (List.length pkgs));
1542200515431543- if not subtree_exists then
15441544- (* Subtree doesn't exist - package is orphaned in opam-repo *)
15451545- orphaned := Package.name pkg :: !orphaned
15461546- else
15471547- match sync_opam_file ~proc ~fs ~config pkg with
15481548- | `Synced name -> synced := name :: !synced
15491549- | `Unchanged name -> unchanged := name :: !unchanged
15501550- | `Missing name -> missing := name :: !missing)
15511551- pkgs;
20062006+ let opam_repo = Config.Paths.opam_repo config in
20072007+ let synced = ref [] in
20082008+ let unchanged = ref [] in
20092009+20102010+ (* Generate each package *)
20112011+ List.iter
20122012+ (fun pkg ->
20132013+ (* Destination: opam-repo/packages/<name>/<name>.dev/opam *)
20142014+ let pkg_dir =
20152015+ Fpath.(opam_repo / "packages" / pkg.pkg_name / (pkg.pkg_name ^ ".dev"))
20162016+ in
20172017+ let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in
1552201815531553- let result = {
20192019+ let dst_content = read_file_opt dst_path in
20202020+ if Some pkg.opam_content = dst_content then
20212021+ unchanged := pkg.pkg_name :: !unchanged
20222022+ else begin
20232023+ (* Create destination directory if needed *)
20242024+ let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in
20252025+ (try Eio.Path.mkdirs ~perm:0o755 pkg_dir_eio with Eio.Io _ -> ());
20262026+ (* Write the opam file *)
20272027+ Log.info (fun m -> m "Generating %s.opam in opam-repo" pkg.pkg_name);
20282028+ Eio.Path.save ~create:(`Or_truncate 0o644) dst_path pkg.opam_content;
20292029+ (* Stage the change *)
20302030+ let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
20312031+ let rel_path =
20322032+ Printf.sprintf "packages/%s/%s.dev/opam" pkg.pkg_name pkg.pkg_name
20332033+ in
20342034+ Eio.Switch.run (fun sw ->
20352035+ let child =
20362036+ Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
20372037+ [ "git"; "add"; rel_path ]
20382038+ in
20392039+ ignore (Eio.Process.await child));
20402040+ synced := pkg.pkg_name :: !synced
20412041+ end)
20422042+ pkgs;
20432043+20442044+ (* Find and delete orphaned packages *)
20452045+ let generated_names =
20462046+ List.map (fun p -> p.pkg_name) pkgs
20472047+ |> List.sort_uniq String.compare
20482048+ in
20492049+ let existing_packages = list_opam_repo_packages ~fs ~config in
20502050+ let orphaned =
20512051+ List.filter
20522052+ (fun name -> not (List.mem name generated_names))
20532053+ existing_packages
20542054+ in
20552055+20562056+ (* Delete orphans only if we're doing a full sync (no package filter) *)
20572057+ let deleted =
20582058+ if package = None then begin
20592059+ List.iter
20602060+ (fun name ->
20612061+ Log.info (fun m -> m "Removing orphaned package: %s" name);
20622062+ ignore (delete_opam_repo_package ~proc ~fs ~config name))
20632063+ orphaned;
20642064+ orphaned
20652065+ end
20662066+ else []
20672067+ in
20682068+20692069+ let result =
20702070+ {
15542071 synced = List.rev !synced;
15552072 unchanged = List.rev !unchanged;
15561556- missing = List.rev !missing;
15571557- orphaned = List.rev !orphaned;
15581558- } in
20732073+ missing = []; (* No longer used in generation-based approach *)
20742074+ orphaned = deleted;
20752075+ }
20762076+ in
1559207715601560- (* Commit if there were changes *)
15611561- if result.synced <> [] then begin
15621562- let opam_repo = Config.Paths.opam_repo config in
15631563- let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
15641564- let msg = Printf.sprintf "Sync opam files from monorepo (%d packages)"
15651565- (List.length result.synced) in
15661566- Eio.Switch.run (fun sw ->
15671567- let child =
15681568- Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
15691569- [ "git"; "commit"; "-m"; msg ]
15701570- in
15711571- ignore (Eio.Process.await child));
15721572- Log.app (fun m -> m "Committed opam sync: %s" msg)
15731573- end;
15741574-15751575- (* Report orphaned packages *)
15761576- if result.orphaned <> [] then begin
15771577- Log.warn (fun m -> m "Found %d orphaned packages in opam-repo (subtree missing from monorepo):"
15781578- (List.length result.orphaned));
15791579- List.iter (fun name ->
15801580- Log.warn (fun m -> m " %s" name))
15811581- result.orphaned;
15821582- Log.warn (fun m -> m "To remove, delete from opam-repo/packages/ and commit.")
15831583- end;
20782078+ (* Commit if there were changes *)
20792079+ if result.synced <> [] || result.orphaned <> [] then begin
20802080+ let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
20812081+ let msg =
20822082+ let parts = [] in
20832083+ let parts =
20842084+ if result.synced <> [] then
20852085+ Printf.sprintf "updated %d" (List.length result.synced) :: parts
20862086+ else parts
20872087+ in
20882088+ let parts =
20892089+ if result.orphaned <> [] then
20902090+ Printf.sprintf "removed %d" (List.length result.orphaned) :: parts
20912091+ else parts
20922092+ in
20932093+ Printf.sprintf "Sync opam files from monorepo (%s packages)"
20942094+ (String.concat ", " parts)
20952095+ in
20962096+ Eio.Switch.run (fun sw ->
20972097+ let child =
20982098+ Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
20992099+ [ "git"; "commit"; "-m"; msg ]
21002100+ in
21012101+ ignore (Eio.Process.await child));
21022102+ Log.app (fun m -> m "Committed opam sync: %s" msg)
21032103+ end;
1584210415851585- Log.app (fun m -> m "%a" pp_opam_sync_result result);
15861586- Ok result
15871587- end
21052105+ Log.app (fun m -> m "%a" pp_opam_sync_result result);
21062106+ Ok result
1588210715892108let add ~proc ~fs ~config ~package () =
15902109 let fs_t = fs_typed fs in
···1616213516172136(* Changes command - generate weekly changelogs using Claude *)
1618213716191619-let changes ~proc ~fs ~config ~clock ?package ?(weeks = 1) ?(history = 12) ?(dry_run = false) () =
21382138+let changes ~proc ~fs ~config ~clock ?package ?(weeks = 1) ?(history = 12)
21392139+ ?(dry_run = false) () =
16202140 let fs_t = fs_typed fs in
16212141 let monorepo = Config.Paths.monorepo config in
1622214216232143 (* Get current time and calculate week boundaries *)
16242144 let now = Eio.Time.now clock in
16251625- let now_ptime = match Ptime.of_float_s now with
16261626- | Some t -> t
16271627- | None -> Ptime.v (0, 0L) (* fallback to epoch *)
21452145+ let now_ptime =
21462146+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
21472147+ (* fallback to epoch *)
16282148 in
1629214916302150 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
16312151 | Error e -> Error e
16322152 | Ok all_pkgs ->
16332153 let repos = unique_repos all_pkgs in
16341634- let repos = match package with
21542154+ let repos =
21552155+ match package with
16352156 | None -> repos
16362157 | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
16372158 in
16382159 if repos = [] && package <> None then
16392160 Error (Package_not_found (Option.get package))
16402161 else begin
16411641- Log.info (fun m -> m "Processing changelogs for %d repositories" (List.length repos));
21622162+ Log.info (fun m ->
21632163+ m "Processing changelogs for %d repositories" (List.length repos));
1642216416432165 (* Process each repository *)
16442166 let all_changes_files = ref [] in
16452167 let rec process_repos = function
16462168 | [] -> Ok ()
16471647- | pkg :: rest ->
21692169+ | pkg :: rest -> (
16482170 let repo_name = Package.repo_name pkg in
1649217116502172 Log.info (fun m -> m "Processing %s" repo_name);
···16522174 (* Load existing changes from .changes/<repo>.json *)
16532175 match Changes.load ~fs:fs_t ~monorepo repo_name with
16542176 | Error e -> Error (Claude_error e)
16551655- | Ok changes_file ->
21772177+ | Ok changes_file -> (
16562178 (* Process each week *)
16572179 let rec process_weeks week_offset updated_cf =
16582180 if week_offset >= weeks then Ok updated_cf
16592181 else begin
16602182 (* Calculate week boundaries *)
16611661- let offset_seconds = float_of_int (week_offset * 7 * 24 * 60 * 60) in
16621662- let week_time = match Ptime.of_float_s (now -. offset_seconds) with
21832183+ let offset_seconds =
21842184+ float_of_int (week_offset * 7 * 24 * 60 * 60)
21852185+ in
21862186+ let week_time =
21872187+ match Ptime.of_float_s (now -. offset_seconds) with
16632188 | Some t -> t
16642189 | None -> now_ptime
16652190 in
16661666- let week_start, week_end = Changes.week_of_ptime week_time in
21912191+ let week_start, week_end =
21922192+ Changes.week_of_ptime week_time
21932193+ in
1667219416682195 (* Skip if week already has an entry *)
16692196 if Changes.has_week updated_cf ~week_start then begin
16701670- Log.info (fun m -> m " Week %s already has entry, skipping" week_start);
21972197+ Log.info (fun m ->
21982198+ m " Week %s already has entry, skipping" week_start);
16712199 process_weeks (week_offset + 1) updated_cf
16722200 end
16732201 else begin
16742202 (* Get commits for this week *)
16752203 let since = week_start ^ " 00:00:00" in
16762204 let until = week_end ^ " 23:59:59" in
16771677- match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
22052205+ match
22062206+ Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name
22072207+ monorepo
22082208+ with
16782209 | Error e -> Error (Git_error e)
16792210 | Ok commits ->
16802211 if commits = [] then begin
16811681- Log.info (fun m -> m " No commits for week %s" week_start);
22122212+ Log.info (fun m ->
22132213+ m " No commits for week %s" week_start);
16822214 process_weeks (week_offset + 1) updated_cf
16832215 end
16842216 else begin
16851685- Log.info (fun m -> m " Found %d commits for week %s" (List.length commits) week_start);
22172217+ Log.info (fun m ->
22182218+ m " Found %d commits for week %s"
22192219+ (List.length commits) week_start);
1686222016872221 if dry_run then begin
16881688- Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s week %s"
16891689- (List.length commits) repo_name week_start);
22222222+ Log.app (fun m ->
22232223+ m
22242224+ " [DRY RUN] Would analyze %d commits \
22252225+ for %s week %s"
22262226+ (List.length commits) repo_name week_start);
16902227 process_weeks (week_offset + 1) updated_cf
16912228 end
16922229 else begin
16932230 (* Analyze commits with Claude *)
16942231 Eio.Switch.run @@ fun sw ->
16951695- match Changes.analyze_commits ~sw ~process_mgr:proc ~clock
16961696- ~repository:repo_name ~week_start ~week_end commits with
22322232+ match
22332233+ Changes.analyze_commits ~sw ~process_mgr:proc
22342234+ ~clock ~repository:repo_name ~week_start
22352235+ ~week_end commits
22362236+ with
16972237 | Error e -> Error (Claude_error e)
16982238 | Ok None ->
16991699- Log.info (fun m -> m " No user-facing changes for week %s" week_start);
22392239+ Log.info (fun m ->
22402240+ m " No user-facing changes for week %s"
22412241+ week_start);
17002242 process_weeks (week_offset + 1) updated_cf
17012243 | Ok (Some response) ->
17021702- Log.app (fun m -> m " Generated changelog for %s week %s" repo_name week_start);
22442244+ Log.app (fun m ->
22452245+ m " Generated changelog for %s week %s"
22462246+ repo_name week_start);
17032247 (* Create new entry *)
17041704- let first_hash = (List.hd commits).Git.hash in
17051705- let last_hash = (List.hd (List.rev commits)).Git.hash in
17061706- let entry : Changes.weekly_entry = {
17071707- week_start;
17081708- week_end;
17091709- summary = response.Changes.summary;
17101710- changes = response.Changes.changes;
17111711- commit_range = {
17121712- from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
17131713- to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
17141714- count = List.length commits;
17151715- };
17161716- } in
22482248+ let first_hash =
22492249+ (List.hd commits).Git.hash
22502250+ in
22512251+ let last_hash =
22522252+ (List.hd (List.rev commits)).Git.hash
22532253+ in
22542254+ let entry : Changes.weekly_entry =
22552255+ {
22562256+ week_start;
22572257+ week_end;
22582258+ summary = response.Changes.summary;
22592259+ changes = response.Changes.changes;
22602260+ commit_range =
22612261+ {
22622262+ from_hash =
22632263+ String.sub first_hash 0
22642264+ (min 7
22652265+ (String.length first_hash));
22662266+ to_hash =
22672267+ String.sub last_hash 0
22682268+ (min 7 (String.length last_hash));
22692269+ count = List.length commits;
22702270+ };
22712271+ }
22722272+ in
17172273 (* Add entry (sorted by date descending) *)
17182274 let new_entries =
17192275 entry :: updated_cf.Changes.entries
17202276 |> List.sort (fun e1 e2 ->
17211721- String.compare e2.Changes.week_start e1.Changes.week_start)
22772277+ String.compare e2.Changes.week_start
22782278+ e1.Changes.week_start)
17222279 in
17232280 process_weeks (week_offset + 1)
17242281 { updated_cf with entries = new_entries }
···17292286 in
17302287 match process_weeks 0 changes_file with
17312288 | Error e -> Error e
17321732- | Ok updated_cf ->
22892289+ | Ok updated_cf -> (
17332290 (* Save if changed and not dry run *)
17342291 let save_result =
17351735- if not dry_run && updated_cf.entries <> changes_file.entries then
22922292+ if
22932293+ (not dry_run)
22942294+ && updated_cf.entries <> changes_file.entries
22952295+ then (
17362296 match Changes.save ~fs:fs_t ~monorepo updated_cf with
17372297 | Error e -> Error (Claude_error e)
17382298 | Ok () ->
17391739- Log.app (fun m -> m "Saved .changes/%s.json" repo_name);
17401740- Ok ()
22992299+ Log.app (fun m ->
23002300+ m "Saved .changes/%s.json" repo_name);
23012301+ Ok ())
17412302 else Ok ()
17422303 in
17432304 match save_result with
17442305 | Error e -> Error e
17452306 | Ok () ->
17462307 all_changes_files := updated_cf :: !all_changes_files;
17471747- process_repos rest
23082308+ process_repos rest)))
17482309 in
17492310 match process_repos repos with
17502311 | Error e -> Error e
17512312 | Ok () ->
17522313 (* Generate aggregated CHANGES.md *)
17531753- if not dry_run && !all_changes_files <> [] then begin
23142314+ if (not dry_run) && !all_changes_files <> [] then begin
17542315 let markdown = Changes.aggregate ~history !all_changes_files in
17551755- let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "CHANGES.md") in
17561756- Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
23162316+ let changes_md_path =
23172317+ Eio.Path.(fs_t / Fpath.to_string monorepo / "CHANGES.md")
23182318+ in
23192319+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path
23202320+ markdown;
17572321 Log.app (fun m -> m "Generated CHANGES.md at monorepo root")
17582322 end;
17592323 Ok ()
···1761232517622326(* Daily changes command - generate daily changelogs using Claude *)
1763232717641764-let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30) ?(dry_run = false) ?(aggregate = false) () =
23282328+let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30)
23292329+ ?(dry_run = false) ?(aggregate = false) () =
17652330 let fs_t = fs_typed fs in
17662331 let monorepo = Config.Paths.monorepo config in
1767233217682333 (* Get current time *)
17692334 let now = Eio.Time.now clock in
17701770- let now_ptime = match Ptime.of_float_s now with
17711771- | Some t -> t
17721772- | None -> Ptime.v (0, 0L) (* fallback to epoch *)
23352335+ let now_ptime =
23362336+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
23372337+ (* fallback to epoch *)
17732338 in
1774233917752340 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
17762341 | Error e -> Error e
17772342 | Ok all_pkgs ->
17782343 let repos = unique_repos all_pkgs in
17791779- let repos = match package with
23442344+ let repos =
23452345+ match package with
17802346 | None -> repos
17812347 | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
17822348 in
17832349 if repos = [] && package <> None then
17842350 Error (Package_not_found (Option.get package))
17852351 else begin
17861786- Log.info (fun m -> m "Processing daily changelogs for %d repositories" (List.length repos));
23522352+ Log.info (fun m ->
23532353+ m "Processing daily changelogs for %d repositories"
23542354+ (List.length repos));
1787235517882356 (* Process each repository *)
17892357 let all_changes_files = ref [] in
17902358 let rec process_repos = function
17912359 | [] -> Ok ()
17921792- | pkg :: rest ->
23602360+ | pkg :: rest -> (
17932361 let repo_name = Package.repo_name pkg in
1794236217952363 Log.info (fun m -> m "Processing %s" repo_name);
···17992367 if day_offset >= days then Ok ()
18002368 else begin
18012369 (* Calculate day boundaries *)
18021802- let offset_seconds = float_of_int (day_offset * 24 * 60 * 60) in
18031803- let day_time = match Ptime.of_float_s (now -. offset_seconds) with
23702370+ let offset_seconds =
23712371+ float_of_int (day_offset * 24 * 60 * 60)
23722372+ in
23732373+ let day_time =
23742374+ match Ptime.of_float_s (now -. offset_seconds) with
18042375 | Some t -> t
18052376 | None -> now_ptime
18062377 in
···18112382 (* For today, skip only if file has entries (may need to catch new commits) *)
18122383 let should_skip =
18132384 if is_today then
18141814- Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name &&
18151815- (match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
18161816- | Ok cf -> Changes.has_day cf ~date
18171817- | Error _ -> false)
18181818- else
18192385 Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name
23862386+ &&
23872387+ match
23882388+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
23892389+ with
23902390+ | Ok cf -> Changes.has_day cf ~date
23912391+ | Error _ -> false
23922392+ else Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name
18202393 in
18212394 if should_skip then begin
18221822- Log.info (fun m -> m " Day %s already processed, skipping" date);
18231823- (match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
18241824- | Ok cf -> all_changes_files := cf :: !all_changes_files
18251825- | Error _ -> ());
23952395+ Log.info (fun m ->
23962396+ m " Day %s already processed, skipping" date);
23972397+ (match
23982398+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
23992399+ with
24002400+ | Ok cf -> all_changes_files := cf :: !all_changes_files
24012401+ | Error _ -> ());
18262402 process_days (day_offset + 1)
18272403 end
18282404 else
18292405 (* Load existing daily changes from .changes/<repo>-<date>.json *)
18301830- match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
24062406+ match
24072407+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
24082408+ with
18312409 | Error e -> Error (Claude_error e)
18321832- | Ok changes_file ->
24102410+ | Ok changes_file -> (
18332411 (* Get commits for this day *)
18342412 let since = date ^ " 00:00:00" in
18352413 let until = date ^ " 23:59:59" in
18361836- match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
24142414+ match
24152415+ Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name
24162416+ monorepo
24172417+ with
18372418 | Error e -> Error (Git_error e)
18382419 | Ok commits ->
18392420 if commits = [] then begin
18401840- Log.info (fun m -> m " No commits for day %s" date);
24212421+ Log.info (fun m ->
24222422+ m " No commits for day %s" date);
18412423 process_days (day_offset + 1)
18422424 end
18432425 else begin
18441844- Log.info (fun m -> m " Found %d commits for day %s" (List.length commits) date);
24262426+ Log.info (fun m ->
24272427+ m " Found %d commits for day %s"
24282428+ (List.length commits) date);
1845242918462430 if dry_run then begin
18471847- Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s on %s"
18481848- (List.length commits) repo_name date);
24312431+ Log.app (fun m ->
24322432+ m
24332433+ " [DRY RUN] Would analyze %d commits \
24342434+ for %s on %s"
24352435+ (List.length commits) repo_name date);
18492436 process_days (day_offset + 1)
18502437 end
18512438 else begin
18522439 (* Analyze commits with Claude *)
18532440 Eio.Switch.run @@ fun sw ->
18541854- match Changes.analyze_commits_daily ~sw ~process_mgr:proc ~clock
18551855- ~repository:repo_name ~date commits with
24412441+ match
24422442+ Changes.analyze_commits_daily ~sw
24432443+ ~process_mgr:proc ~clock
24442444+ ~repository:repo_name ~date commits
24452445+ with
18562446 | Error e -> Error (Claude_error e)
18572447 | Ok None ->
18581858- Log.info (fun m -> m " No user-facing changes for day %s" date);
24482448+ Log.info (fun m ->
24492449+ m " No user-facing changes for day %s"
24502450+ date);
18592451 process_days (day_offset + 1)
18601860- | Ok (Some response) ->
18611861- Log.app (fun m -> m " Generated changelog for %s on %s" repo_name date);
24522452+ | Ok (Some response) -> (
24532453+ Log.app (fun m ->
24542454+ m " Generated changelog for %s on %s"
24552455+ repo_name date);
18622456 (* Extract unique contributors from commits *)
18632457 let contributors =
18642458 commits
18651865- |> List.map (fun (c : Git.log_entry) -> c.author)
24592459+ |> List.map (fun (c : Git.log_entry) ->
24602460+ c.author)
18662461 |> List.sort_uniq String.compare
18672462 in
18682463 (* Get repo URL from package dev_repo *)
···18702465 let uri = Package.dev_repo pkg in
18712466 let url = Uri.to_string uri in
18722467 (* Strip git+ prefix if present for display *)
18731873- if String.starts_with ~prefix:"git+" url then
18741874- Some (String.sub url 4 (String.length url - 4))
18751875- else
18761876- Some url
24682468+ if String.starts_with ~prefix:"git+" url
24692469+ then
24702470+ Some
24712471+ (String.sub url 4
24722472+ (String.length url - 4))
24732473+ else Some url
18772474 in
18782475 (* Create new entry with hour and timestamp *)
18791879- let first_hash = (List.hd commits).Git.hash in
18801880- let last_hash = (List.hd (List.rev commits)).Git.hash in
18811881- let (_, ((hour, _, _), _)) = Ptime.to_date_time now_ptime in
18821882- let entry : Changes.daily_entry = {
18831883- date;
18841884- hour;
18851885- timestamp = now_ptime;
18861886- summary = response.Changes.summary;
18871887- changes = response.Changes.changes;
18881888- commit_range = {
18891889- from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
18901890- to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
18911891- count = List.length commits;
18921892- };
18931893- contributors;
18941894- repo_url;
18951895- } in
24762476+ let first_hash =
24772477+ (List.hd commits).Git.hash
24782478+ in
24792479+ let last_hash =
24802480+ (List.hd (List.rev commits)).Git.hash
24812481+ in
24822482+ let _, ((hour, _, _), _) =
24832483+ Ptime.to_date_time now_ptime
24842484+ in
24852485+ let entry : Changes.daily_entry =
24862486+ {
24872487+ date;
24882488+ hour;
24892489+ timestamp = now_ptime;
24902490+ summary = response.Changes.summary;
24912491+ changes = response.Changes.changes;
24922492+ commit_range =
24932493+ {
24942494+ from_hash =
24952495+ String.sub first_hash 0
24962496+ (min 7
24972497+ (String.length first_hash));
24982498+ to_hash =
24992499+ String.sub last_hash 0
25002500+ (min 7 (String.length last_hash));
25012501+ count = List.length commits;
25022502+ };
25032503+ contributors;
25042504+ repo_url;
25052505+ }
25062506+ in
18962507 (* Add entry (sorted by timestamp descending) *)
18972508 let new_entries =
18982509 entry :: changes_file.Changes.entries
18992510 |> List.sort (fun e1 e2 ->
19001900- Ptime.compare e2.Changes.timestamp e1.Changes.timestamp)
25112511+ Ptime.compare e2.Changes.timestamp
25122512+ e1.Changes.timestamp)
25132513+ in
25142514+ let updated_cf =
25152515+ {
25162516+ changes_file with
25172517+ Changes.entries = new_entries;
25182518+ }
19012519 in
19021902- let updated_cf = { changes_file with Changes.entries = new_entries } in
19032520 (* Save the per-day file *)
19041904- match Changes.save_daily ~fs:fs_t ~monorepo ~date updated_cf with
25212521+ match
25222522+ Changes.save_daily ~fs:fs_t ~monorepo
25232523+ ~date updated_cf
25242524+ with
19052525 | Error e -> Error (Claude_error e)
19062526 | Ok () ->
19071907- Log.app (fun m -> m "Saved .changes/%s-%s.json" repo_name date);
19081908- all_changes_files := updated_cf :: !all_changes_files;
19091909- process_days (day_offset + 1)
25272527+ Log.app (fun m ->
25282528+ m "Saved .changes/%s-%s.json"
25292529+ repo_name date);
25302530+ all_changes_files :=
25312531+ updated_cf :: !all_changes_files;
25322532+ process_days (day_offset + 1))
19102533 end
19111911- end
25342534+ end)
19122535 end
19132536 in
19142537 match process_days 0 with
19152538 | Error e -> Error e
19161916- | Ok () -> process_repos rest
25392539+ | Ok () -> process_repos rest)
19172540 in
19182541 match process_repos repos with
19192542 | Error e -> Error e
19202543 | Ok () ->
19212544 (* Generate aggregated DAILY-CHANGES.md *)
19221922- if not dry_run && !all_changes_files <> [] then begin
19231923- let raw_markdown = Changes.aggregate_daily ~history !all_changes_files in
25452545+ if (not dry_run) && !all_changes_files <> [] then begin
25462546+ let raw_markdown =
25472547+ Changes.aggregate_daily ~history !all_changes_files
25482548+ in
19242549 (* Refine the markdown through Claude for better narrative *)
19252550 Log.info (fun m -> m "Refining daily changelog with Claude...");
19261926- let markdown = Eio.Switch.run @@ fun sw ->
19271927- match Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock raw_markdown with
25512551+ let markdown =
25522552+ Eio.Switch.run @@ fun sw ->
25532553+ match
25542554+ Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock
25552555+ raw_markdown
25562556+ with
19282557 | Ok refined ->
19291929- Log.app (fun m -> m "Refined daily changelog for readability");
25582558+ Log.app (fun m ->
25592559+ m "Refined daily changelog for readability");
19302560 refined
19312561 | Error e ->
19321932- Log.warn (fun m -> m "Failed to refine changelog: %s (using raw version)" e);
25622562+ Log.warn (fun m ->
25632563+ m "Failed to refine changelog: %s (using raw version)" e);
19332564 raw_markdown
19342565 in
19351935- let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md") in
19361936- Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
25662566+ let changes_md_path =
25672567+ Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md")
25682568+ in
25692569+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path
25702570+ markdown;
19372571 Log.app (fun m -> m "Generated DAILY-CHANGES.md at monorepo root")
19382572 end;
19392573 (* Generate aggregated JSON file if requested *)
19401940- if not dry_run && aggregate then begin
25742574+ if (not dry_run) && aggregate then begin
19412575 let today = Changes.date_of_ptime now_ptime in
19422576 let git_head =
19432577 match Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD" monorepo with
19442578 | Ok hash -> String.sub hash 0 (min 7 (String.length hash))
19452579 | Error _ -> "unknown"
19462580 in
19471947- match Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today ~git_head ~now:now_ptime with
19481948- | Ok () -> Log.app (fun m -> m "Generated aggregated file .changes/%s.json"
19491949- (String.concat "" (String.split_on_char '-' today)))
19501950- | Error e -> Log.warn (fun m -> m "Failed to generate aggregated file: %s" e)
25812581+ match
25822582+ Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today
25832583+ ~git_head ~now:now_ptime
25842584+ with
25852585+ | Ok () ->
25862586+ Log.app (fun m ->
25872587+ m "Generated aggregated file .changes/%s.json"
25882588+ (String.concat "" (String.split_on_char '-' today)))
25892589+ | Error e ->
25902590+ Log.warn (fun m ->
25912591+ m "Failed to generate aggregated file: %s" e)
19512592 end;
19522593 Ok ()
19532594 end
25952595+25962596+(* ==================== Diff ==================== *)
25972597+25982598+type diff_entry = {
25992599+ repo_name : string;
26002600+ handle : string;
26012601+ relationship : Forks.relationship;
26022602+ commits : Git.log_entry list;
26032603+ patches : (string * string) list; (* hash -> patch content *)
26042604+}
26052605+26062606+type diff_result = {
26072607+ entries : diff_entry list;
26082608+ forks : Forks.t;
26092609+}
26102610+26112611+let pp_diff_entry ~show_patch ppf entry =
26122612+ let n_commits = List.length entry.commits in
26132613+ Fmt.pf ppf "@[<v 2>%a %s (%a, %d commit%s):@,"
26142614+ Fmt.(styled `Bold string) entry.repo_name
26152615+ entry.handle
26162616+ Forks.pp_relationship entry.relationship
26172617+ n_commits (if n_commits = 1 then "" else "s");
26182618+ List.iter (fun (c : Git.log_entry) ->
26192619+ let short_hash = String.sub c.hash 0 (min 7 (String.length c.hash)) in
26202620+ Fmt.pf ppf " %a %s %a@,"
26212621+ Fmt.(styled `Yellow string) short_hash
26222622+ c.subject
26232623+ Fmt.(styled `Faint string) c.author;
26242624+ if show_patch then
26252625+ match List.assoc_opt c.hash entry.patches with
26262626+ | Some patch -> Fmt.pf ppf "@,%s@," patch
26272627+ | None -> ())
26282628+ entry.commits;
26292629+ Fmt.pf ppf "@]"
26302630+26312631+let pp_diff_result ~show_patch ppf result =
26322632+ (* First show the summary *)
26332633+ Fmt.pf ppf "%a@." (Forks.pp_summary' ~show_all:false) result.forks;
26342634+ (* Then show diffs for each entry *)
26352635+ if result.entries <> [] then begin
26362636+ Fmt.pf ppf "@[<v>%a@]@."
26372637+ Fmt.(list ~sep:(any "@,@,") (pp_diff_entry ~show_patch)) result.entries
26382638+ end
26392639+26402640+(** Check if a string looks like a git commit hash (7+ hex chars) *)
26412641+let is_commit_sha s =
26422642+ String.length s >= 7 &&
26432643+ String.for_all (function '0'..'9' | 'a'..'f' | 'A'..'F' -> true | _ -> false) s
26442644+26452645+let diff ~proc ~fs ~config ~verse_config ?repo ?(refresh=false) ?(patch=false) () =
26462646+ let checkouts_path = Config.Paths.checkouts config in
26472647+26482648+ (* Compute fork analysis *)
26492649+ let forks = Forks.compute ~proc ~fs ~verse_config ~monopam_config:config ~refresh () in
26502650+26512651+ (* Filter repos if specific one requested *)
26522652+ let repos_to_check = match repo with
26532653+ | None -> forks.repos
26542654+ | Some name -> List.filter (fun r -> r.Forks.repo_name = name) forks.repos
26552655+ in
26562656+26572657+ (* For each repo with actionable status, get commits *)
26582658+ let entries =
26592659+ List.filter_map (fun (r : Forks.repo_analysis) ->
26602660+ (* Find actionable verse sources *)
26612661+ let actionable = List.filter (fun (_, _, rel) ->
26622662+ match rel with
26632663+ | Forks.I_am_behind _ -> true
26642664+ | Forks.Diverged _ -> true
26652665+ | _ -> false)
26662666+ r.verse_sources
26672667+ in
26682668+ match actionable with
26692669+ | [] -> None
26702670+ | sources ->
26712671+ (* Get commits for each actionable source *)
26722672+ let entries = List.filter_map (fun (handle, _src, rel) ->
26732673+ let checkout_path = Fpath.(checkouts_path / r.repo_name) in
26742674+ if not (Git.is_repo ~proc ~fs checkout_path) then None
26752675+ else begin
26762676+ let remote_name = "verse/" ^ handle in
26772677+ let my_ref = "origin/main" in
26782678+ let their_ref = remote_name ^ "/main" in
26792679+ (* Get commits they have that I don't *)
26802680+ match Git.log_range ~proc ~fs ~base:my_ref ~tip:their_ref ~max_count:20 checkout_path with
26812681+ | Error _ -> None
26822682+ | Ok commits when commits = [] -> None
26832683+ | Ok commits ->
26842684+ (* Fetch patches if requested *)
26852685+ let patches =
26862686+ if patch then
26872687+ List.filter_map (fun (c : Git.log_entry) ->
26882688+ match Git.show_patch ~proc ~fs ~commit:c.hash checkout_path with
26892689+ | Ok p -> Some (c.hash, p)
26902690+ | Error _ -> None)
26912691+ commits
26922692+ else []
26932693+ in
26942694+ Some { repo_name = r.repo_name; handle; relationship = rel; commits; patches }
26952695+ end)
26962696+ sources
26972697+ in
26982698+ match entries with
26992699+ | [] -> None
27002700+ | _ -> Some entries)
27012701+ repos_to_check
27022702+ |> List.flatten
27032703+ in
27042704+ { entries; forks }
27052705+27062706+(** Result of looking up a specific commit *)
27072707+type commit_info = {
27082708+ commit_repo : string;
27092709+ commit_handle : string;
27102710+ commit_hash : string;
27112711+ commit_subject : string;
27122712+ commit_author : string;
27132713+ commit_patch : string;
27142714+}
27152715+27162716+(** Show patch for a specific commit SHA from diff output *)
27172717+let diff_show_commit ~proc ~fs ~config ~verse_config ~sha ?(refresh=false) () =
27182718+ let checkouts_path = Config.Paths.checkouts config in
27192719+27202720+ (* Compute fork analysis to find which repo has this commit *)
27212721+ let forks = Forks.compute ~proc ~fs ~verse_config ~monopam_config:config ~refresh () in
27222722+27232723+ (* Search through repos for this commit *)
27242724+ let result = List.find_map (fun (r : Forks.repo_analysis) ->
27252725+ let checkout_path = Fpath.(checkouts_path / r.repo_name) in
27262726+ if not (Git.is_repo ~proc ~fs checkout_path) then None
27272727+ else
27282728+ (* Check each verse source *)
27292729+ List.find_map (fun (handle, _src, rel) ->
27302730+ match rel with
27312731+ | Forks.I_am_behind _ | Forks.Diverged _ ->
27322732+ let remote_name = "verse/" ^ handle in
27332733+ let my_ref = "origin/main" in
27342734+ let their_ref = remote_name ^ "/main" in
27352735+ (* Get commits they have that I don't *)
27362736+ (match Git.log_range ~proc ~fs ~base:my_ref ~tip:their_ref ~max_count:50 checkout_path with
27372737+ | Error _ -> None
27382738+ | Ok commits ->
27392739+ (* Check if our sha matches any commit *)
27402740+ let matching = List.find_opt (fun (c : Git.log_entry) ->
27412741+ String.starts_with ~prefix:sha c.hash ||
27422742+ String.starts_with ~prefix:(String.lowercase_ascii sha) (String.lowercase_ascii c.hash))
27432743+ commits
27442744+ in
27452745+ match matching with
27462746+ | None -> None
27472747+ | Some c ->
27482748+ match Git.show_patch ~proc ~fs ~commit:c.hash checkout_path with
27492749+ | Ok patch -> Some {
27502750+ commit_repo = r.repo_name;
27512751+ commit_handle = handle;
27522752+ commit_hash = c.hash;
27532753+ commit_subject = c.subject;
27542754+ commit_author = c.author;
27552755+ commit_patch = patch;
27562756+ }
27572757+ | Error _ -> None)
27582758+ | _ -> None)
27592759+ r.verse_sources)
27602760+ forks.repos
27612761+ in
27622762+ result
27632763+27642764+(* ==================== Pull from Handle ==================== *)
27652765+27662766+type handle_pull_result = {
27672767+ repos_pulled : (string * int) list;
27682768+ repos_skipped : string list;
27692769+ repos_failed : (string * string) list;
27702770+}
27712771+27722772+let pp_handle_pull_result ppf result =
27732773+ if result.repos_pulled <> [] then begin
27742774+ Fmt.pf ppf "@[<v>%a@," Fmt.(styled `Bold string) "Pulled:";
27752775+ List.iter (fun (repo, count) ->
27762776+ Fmt.pf ppf " %s: %d commits@," repo count)
27772777+ result.repos_pulled;
27782778+ Fmt.pf ppf "@]"
27792779+ end;
27802780+ if result.repos_skipped <> [] then
27812781+ Fmt.pf ppf "%a %s@,"
27822782+ Fmt.(styled `Faint string) "Skipped:"
27832783+ (String.concat ", " result.repos_skipped);
27842784+ if result.repos_failed <> [] then begin
27852785+ Fmt.pf ppf "@[<v>%a@," Fmt.(styled `Red string) "Failed:";
27862786+ List.iter (fun (repo, err) ->
27872787+ Fmt.pf ppf " %s: %s@," repo err)
27882788+ result.repos_failed;
27892789+ Fmt.pf ppf "@]"
27902790+ end
27912791+27922792+let pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ?(refresh=false) () =
27932793+ let checkouts_path = Config.Paths.checkouts config in
27942794+27952795+ (* Compute fork analysis *)
27962796+ let forks = Forks.compute ~proc ~fs ~verse_config ~monopam_config:config ~refresh () in
27972797+27982798+ (* Filter repos if specific one requested *)
27992799+ let repos_to_check = match repo with
28002800+ | None -> forks.repos
28012801+ | Some name -> List.filter (fun r -> r.Forks.repo_name = name) forks.repos
28022802+ in
28032803+28042804+ (* Find repos where this handle has commits we don't have *)
28052805+ let repos_pulled = ref [] in
28062806+ let repos_skipped = ref [] in
28072807+ let repos_failed = ref [] in
28082808+28092809+ List.iter (fun (r : Forks.repo_analysis) ->
28102810+ (* Check if this handle has commits for this repo *)
28112811+ let handle_source = List.find_opt (fun (h, _, _) -> h = handle) r.verse_sources in
28122812+ match handle_source with
28132813+ | None ->
28142814+ (* Handle doesn't have this repo *)
28152815+ ()
28162816+ | Some (_, _, rel) ->
28172817+ let checkout_path = Fpath.(checkouts_path / r.repo_name) in
28182818+ if not (Git.is_repo ~proc ~fs checkout_path) then
28192819+ repos_skipped := r.repo_name :: !repos_skipped
28202820+ else begin
28212821+ match rel with
28222822+ | Forks.Same_url | Forks.Same_commit | Forks.I_am_ahead _ ->
28232823+ repos_skipped := r.repo_name :: !repos_skipped
28242824+ | Forks.Not_fetched | Forks.Unrelated ->
28252825+ repos_skipped := r.repo_name :: !repos_skipped
28262826+ | Forks.I_am_behind count ->
28272827+ (* Merge their changes *)
28282828+ let remote_ref = "verse/" ^ handle ^ "/main" in
28292829+ (match Git.merge ~proc ~fs ~ref_name:remote_ref ~ff_only:true checkout_path with
28302830+ | Ok () ->
28312831+ repos_pulled := (r.repo_name, count) :: !repos_pulled
28322832+ | Error e ->
28332833+ repos_failed := (r.repo_name, Fmt.str "%a" Git.pp_error e) :: !repos_failed)
28342834+ | Forks.Diverged { their_ahead; _ } ->
28352835+ (* Merge their changes (may create a merge commit) *)
28362836+ let remote_ref = "verse/" ^ handle ^ "/main" in
28372837+ (match Git.merge ~proc ~fs ~ref_name:remote_ref checkout_path with
28382838+ | Ok () ->
28392839+ repos_pulled := (r.repo_name, their_ahead) :: !repos_pulled
28402840+ | Error e ->
28412841+ repos_failed := (r.repo_name, Fmt.str "%a" Git.pp_error e) :: !repos_failed)
28422842+ end)
28432843+ repos_to_check;
28442844+28452845+ Ok {
28462846+ repos_pulled = List.rev !repos_pulled;
28472847+ repos_skipped = List.rev !repos_skipped;
28482848+ repos_failed = List.rev !repos_failed;
28492849+ }
28502850+28512851+(* ==================== Cherry-pick ==================== *)
28522852+28532853+type cherrypick_result = {
28542854+ repo_name : string;
28552855+ commit_hash : string;
28562856+ commit_subject : string;
28572857+}
28582858+28592859+let pp_cherrypick_result ppf result =
28602860+ let short_hash = String.sub result.commit_hash 0 (min 7 (String.length result.commit_hash)) in
28612861+ Fmt.pf ppf "Cherry-picked %a %s into %s@."
28622862+ Fmt.(styled `Yellow string) short_hash
28632863+ result.commit_subject
28642864+ result.repo_name
28652865+28662866+let cherrypick ~proc ~fs ~config ~verse_config ~sha ?(refresh=false) () =
28672867+ let checkouts_path = Config.Paths.checkouts config in
28682868+28692869+ (* First, find the commit *)
28702870+ match diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh () with
28712871+ | None ->
28722872+ Error (Config_error (Printf.sprintf "Commit %s not found in any verse diff" sha))
28732873+ | Some info ->
28742874+ let checkout_path = Fpath.(checkouts_path / info.commit_repo) in
28752875+ if not (Git.is_repo ~proc ~fs checkout_path) then
28762876+ Error (Config_error (Printf.sprintf "No checkout for repository %s" info.commit_repo))
28772877+ else begin
28782878+ match Git.cherry_pick ~proc ~fs ~commit:info.commit_hash checkout_path with
28792879+ | Ok () ->
28802880+ Ok {
28812881+ repo_name = info.commit_repo;
28822882+ commit_hash = info.commit_hash;
28832883+ commit_subject = info.commit_subject;
28842884+ }
28852885+ | Error e ->
28862886+ Error (Git_error e)
28872887+ end
+229-43
lib/monopam.mli
···3535module Forks = Forks
3636module Doctor = Doctor
3737module Feature = Feature
3838+module Dune_project = Dune_project
3939+module Opam_transform = Opam_transform
4040+module Sources_registry = Sources_registry
4141+module Fork_join = Fork_join
4242+module Site = Site
38433944(** {1 High-Level Operations} *)
4045···4550 | Git_error of Git.error (** Git operation error *)
4651 | Dirty_state of Package.t list
4752 (** Operation blocked due to dirty packages *)
5353+ | Monorepo_dirty (** Monorepo has uncommitted changes *)
4854 | Package_not_found of string (** Named package not found in opam repo *)
4955 | Claude_error of string (** Claude API or response parsing error *)
5056···5258(** [pp_error] formats errors. *)
53595460val pp_error_with_hint : error Fmt.t
5555-(** [pp_error_with_hint] formats errors with a helpful hint for resolving them. *)
6161+(** [pp_error_with_hint] formats errors with a helpful hint for resolving them.
6262+*)
56635764val error_hint : error -> string option
5865(** [error_hint e] returns a hint string for the given error, if available. *)
···8289 ?opam_repo_url:string ->
8390 unit ->
8491 (unit, error) result
8585-(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from remotes.
9292+(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from
9393+ remotes.
86948795 For each package (or the specified package): 1. Clones or fetches the
8896 individual checkout 2. Adds or pulls the subtree in the monorepo
···96104 @param fs Eio filesystem
97105 @param config Monopam configuration
98106 @param package Optional specific package to pull
9999- @param opam_repo_url Optional URL to clone opam-repo from if it doesn't exist *)
107107+ @param opam_repo_url
108108+ Optional URL to clone opam-repo from if it doesn't exist *)
100109101110(** {2 Push} *)
102111···128137129138(** {2 Sync} *)
130139131131-(** Phase where a sync failure occurred. *)
132140type sync_phase = [ `Push_checkout | `Fetch | `Merge | `Subtree | `Push_remote ]
141141+(** Phase where a sync failure occurred. *)
133142134134-(** A failure during sync for a specific repository. *)
135143type sync_failure = {
136144 repo_name : string;
137145 phase : sync_phase;
138146 error : Git.error;
139147}
148148+(** A failure during sync for a specific repository. *)
140149141141-(** Summary of a sync operation. *)
142150type sync_summary = {
143151 repos_synced : int;
144152 repos_unchanged : int;
···146154 commits_pushed : int;
147155 errors : sync_failure list;
148156}
157157+(** Summary of a sync operation. *)
149158150159val pp_sync_phase : sync_phase Fmt.t
151160(** [pp_sync_phase] formats a sync phase. *)
···169178(** [sync ~proc ~fs ~config ?package ?remote ?skip_push ?skip_pull ()]
170179 synchronizes the monorepo with upstream repositories.
171180172172- This is the primary command for all sync operations. It performs both
173173- push and pull operations in the correct order:
174174- 1. Validate: check for dirty state (abort if dirty)
175175- 2. Push phase: export monorepo changes to checkouts (parallel)
176176- 3. Fetch phase: clone/fetch from remotes (parallel)
177177- 4. Merge phase: fast-forward merge checkouts (sequential)
178178- 5. Subtree phase: pull subtrees into monorepo (sequential)
179179- 6. Finalize: write README.md and dune-project (sequential)
180180- 7. Remote phase: push to upstream remotes if [~remote:true] (parallel)
181181+ This is the primary command for all sync operations. It performs both push
182182+ and pull operations in the correct order: 1. Validate: check for dirty state
183183+ (abort if dirty) 2. Push phase: export monorepo changes to checkouts
184184+ (parallel) 3. Fetch phase: clone/fetch from remotes (parallel) 4. Merge
185185+ phase: fast-forward merge checkouts (sequential) 5. Subtree phase: pull
186186+ subtrees into monorepo (sequential) 6. Finalize: write README.md and
187187+ dune-project (sequential) 7. Remote phase: push to upstream remotes if
188188+ [~remote:true] (parallel)
181189182190 The fetch and remote push phases run concurrently for improved performance.
183191···191199192200(** {2 Opam Metadata Sync} *)
193201194194-(** Result of syncing opam files from monorepo to opam-repo. *)
195202type opam_sync_result = {
196203 synced : string list; (** Packages that were updated *)
197204 unchanged : string list; (** Packages that were already in sync *)
198205 missing : string list; (** Packages where monorepo has no .opam file *)
199199- orphaned : string list; (** Packages in opam-repo but subtree missing from monorepo *)
206206+ orphaned : string list;
207207+ (** Packages in opam-repo but subtree missing from monorepo *)
200208}
209209+(** Result of syncing opam files from monorepo to opam-repo. *)
201210202211val pp_opam_sync_result : opam_sync_result Fmt.t
203212(** [pp_opam_sync_result] formats an opam sync result. *)
···209218 ?package:string ->
210219 unit ->
211220 (opam_sync_result, error) result
212212-(** [sync_opam_files ~proc ~fs ~config ?package ()] synchronizes .opam files
213213- from monorepo subtrees to the opam-repo overlay.
221221+(** [sync_opam_files ~proc ~fs ~config ?package ()] generates opam-repo entries
222222+ from monorepo dune-project files.
214223215215- For each package (or the specified package):
216216- 1. Checks if the subtree exists in the monorepo
217217- 2. If subtree missing, reports as orphaned (needs manual removal)
218218- 3. Reads the .opam file from the monorepo subtree
219219- 4. Compares with the opam-repo version
220220- 5. If different, copies monorepo → opam-repo (local always wins)
221221- 6. Stages and commits changes in opam-repo
224224+ For each subtree directory in the monorepo:
225225+ 1. Parses the dune-project to extract source/homepage URL
226226+ 2. For each .opam file in the subtree:
227227+ - Transforms it by removing dune-generated comment
228228+ - Adds dev-repo and url fields derived from dune-project
229229+ - Writes to opam-repo/packages/<name>/<name>.dev/opam
230230+ 3. Deletes any orphaned packages in opam-repo not found in monorepo
231231+ 4. Stages and commits changes in opam-repo
222232223223- Orphaned packages (in opam-repo but subtree missing from monorepo) are
224224- reported with a warning suggesting manual removal.
233233+ This is a generation-based approach - opam-repo is derived entirely from
234234+ monorepo dune-project and .opam files.
225235226236 @param proc Eio process manager
227237 @param fs Eio filesystem
228238 @param config Monopam configuration
229229- @param package Optional specific package to sync *)
239239+ @param package Optional specific subtree to sync *)
230240231241(** {2 Package Management} *)
232242···302312 @param config Monopam configuration
303313 @param pkgs List of packages discovered from the opam overlay *)
304314315315+(** Information about a package discovered from the monorepo. *)
316316+type monorepo_package = {
317317+ pkg_name : string; (** Package name (from .opam filename) *)
318318+ subtree : string; (** Subtree directory name *)
319319+ dev_repo : string; (** dev-repo URL derived from dune-project *)
320320+ url_src : string; (** url src with branch (e.g., "git+https://...#main") *)
321321+ opam_content : string; (** Transformed opam file content ready to write *)
322322+}
323323+324324+val discover_packages_from_monorepo :
325325+ fs:Eio.Fs.dir_ty Eio.Path.t ->
326326+ config:Config.t ->
327327+ ?sources:Sources_registry.t ->
328328+ unit ->
329329+ (monorepo_package list, error) result
330330+(** [discover_packages_from_monorepo ~fs ~config ?sources ()] scans monorepo
331331+ subtrees and discovers packages from dune-project files.
332332+333333+ For each subdirectory of the monorepo with a dune-project file:
334334+ 1. Checks sources.toml for URL override
335335+ 2. Falls back to dune-project source/homepage URL
336336+ 3. For each .opam file in that directory, transforms it with dev-repo and url
337337+338338+ @param fs Eio filesystem
339339+ @param config Monopam configuration
340340+ @param sources Optional sources registry for URL overrides *)
341341+305342(** {1 Changelog Generation} *)
306343307344val changes :
···318355(** [changes ~proc ~fs ~config ~clock ?package ?weeks ?history ?dry_run ()]
319356 generates weekly changelog entries using Claude AI.
320357321321- For each repository (or the specified package's repository):
322322- 1. Loads or creates .changes/<repo>.json
323323- 2. For each week that doesn't have an entry, retrieves git commits
324324- 3. Sends commits to Claude for analysis
325325- 4. Saves changelog entries back to .changes/<repo>.json
358358+ For each repository (or the specified package's repository): 1. Loads or
359359+ creates .changes/<repo>.json 2. For each week that doesn't have an entry,
360360+ retrieves git commits 3. Sends commits to Claude for analysis 4. Saves
361361+ changelog entries back to .changes/<repo>.json
326362327363 Also generates an aggregated CHANGES.md at the monorepo root.
328364···347383 ?aggregate:bool ->
348384 unit ->
349385 (unit, error) result
350350-(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run ?aggregate ()]
351351- generates daily changelog entries using Claude AI.
386386+(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run
387387+ ?aggregate ()] generates daily changelog entries using Claude AI.
352388353353- For each repository (or the specified package's repository):
354354- 1. Loads or creates .changes/<repo>-daily.json
355355- 2. For each day that doesn't have an entry, retrieves git commits
356356- 3. Sends commits to Claude for analysis
357357- 4. Saves changelog entries back to .changes/<repo>-daily.json
389389+ For each repository (or the specified package's repository): 1. Loads or
390390+ creates .changes/<repo>-daily.json 2. For each day that doesn't have an
391391+ entry, retrieves git commits 3. Sends commits to Claude for analysis 4.
392392+ Saves changelog entries back to .changes/<repo>-daily.json
358393359394 Also generates an aggregated DAILY-CHANGES.md at the monorepo root.
360395 Repositories with no user-facing changes will have blank entries.
···368403 @param clock Eio clock for time operations
369404 @param package Optional specific repository to process
370405 @param days Number of past days to analyze (default: 1)
371371- @param history Number of recent days to include in DAILY-CHANGES.md (default: 30)
406406+ @param history
407407+ Number of recent days to include in DAILY-CHANGES.md (default: 30)
372408 @param dry_run If true, preview changes without writing files
373373- @param aggregate If true, also generate .changes/YYYYMMDD.json aggregated file *)
409409+ @param aggregate
410410+ If true, also generate .changes/YYYYMMDD.json aggregated file *)
411411+412412+(** {1 Diff} *)
413413+414414+(** A diff entry for a single repository showing commits from a verse member. *)
415415+type diff_entry = {
416416+ repo_name : string;
417417+ handle : string;
418418+ relationship : Forks.relationship;
419419+ commits : Git.log_entry list;
420420+ patches : (string * string) list; (** hash -> patch content *)
421421+}
422422+423423+(** Result of computing diffs for repos needing attention. *)
424424+type diff_result = {
425425+ entries : diff_entry list;
426426+ forks : Forks.t;
427427+}
428428+429429+val pp_diff_entry : show_patch:bool -> diff_entry Fmt.t
430430+(** [pp_diff_entry ~show_patch] formats a single diff entry.
431431+ If [show_patch] is true, includes the patch content for each commit. *)
432432+433433+val pp_diff_result : show_patch:bool -> diff_result Fmt.t
434434+(** [pp_diff_result ~show_patch] formats the full diff result. *)
435435+436436+val is_commit_sha : string -> bool
437437+(** [is_commit_sha s] returns true if [s] looks like a git commit hash
438438+ (7+ hexadecimal characters). *)
439439+440440+val diff :
441441+ proc:_ Eio.Process.mgr ->
442442+ fs:Eio.Fs.dir_ty Eio.Path.t ->
443443+ config:Config.t ->
444444+ verse_config:Verse_config.t ->
445445+ ?repo:string ->
446446+ ?refresh:bool ->
447447+ ?patch:bool ->
448448+ unit ->
449449+ diff_result
450450+(** [diff ~proc ~fs ~config ~verse_config ?repo ?refresh ?patch ()] computes and displays diffs
451451+ for repositories that need attention from verse members.
452452+453453+ For each repository where a verse member is ahead (I_am_behind or Diverged),
454454+ retrieves the commit log showing what commits they have that you don't.
455455+456456+ Remote fetches are cached for 1 hour. Use [~refresh:true] to force fresh
457457+ fetches from all remotes.
458458+459459+ @param proc Eio process manager
460460+ @param fs Eio filesystem
461461+ @param config Monopam configuration
462462+ @param verse_config Verse configuration
463463+ @param repo Optional specific repository to show diff for
464464+ @param refresh If true, force fresh fetches ignoring cache (default: false)
465465+ @param patch If true, fetch and include patch content for each commit (default: false) *)
466466+467467+(** Result of looking up a specific commit *)
468468+type commit_info = {
469469+ commit_repo : string;
470470+ commit_handle : string;
471471+ commit_hash : string;
472472+ commit_subject : string;
473473+ commit_author : string;
474474+ commit_patch : string;
475475+}
476476+477477+val diff_show_commit :
478478+ proc:_ Eio.Process.mgr ->
479479+ fs:Eio.Fs.dir_ty Eio.Path.t ->
480480+ config:Config.t ->
481481+ verse_config:Verse_config.t ->
482482+ sha:string ->
483483+ ?refresh:bool ->
484484+ unit ->
485485+ commit_info option
486486+(** [diff_show_commit ~proc ~fs ~config ~verse_config ~sha ?refresh ()] finds and shows
487487+ the patch for a specific commit SHA from the diff output.
488488+489489+ Searches through all repos with actionable verse sources to find a commit
490490+ matching the given SHA prefix. Returns [Some commit_info] if found, [None] otherwise.
491491+492492+ @param sha Commit SHA prefix (7+ characters) to look up *)
493493+494494+(** {1 Pull from Verse Members} *)
495495+496496+(** Result of pulling from a handle. *)
497497+type handle_pull_result = {
498498+ repos_pulled : (string * int) list; (** (repo_name, commit_count) for each repo pulled *)
499499+ repos_skipped : string list; (** Repos skipped (already in sync or no checkout) *)
500500+ repos_failed : (string * string) list; (** (repo_name, error_message) for failures *)
501501+}
502502+503503+val pp_handle_pull_result : handle_pull_result Fmt.t
504504+(** [pp_handle_pull_result] formats a pull result. *)
505505+506506+val pull_from_handle :
507507+ proc:_ Eio.Process.mgr ->
508508+ fs:Eio.Fs.dir_ty Eio.Path.t ->
509509+ config:Config.t ->
510510+ verse_config:Verse_config.t ->
511511+ handle:string ->
512512+ ?repo:string ->
513513+ ?refresh:bool ->
514514+ unit ->
515515+ (handle_pull_result, error) result
516516+(** [pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ?refresh ()]
517517+ pulls commits from a verse member's forks into your local checkouts.
518518+519519+ For each repository where the handle has commits you don't have:
520520+ 1. Merges their commits into your checkout's main branch
521521+ 2. The changes are then ready to be synced to the monorepo via [sync]
522522+523523+ If [repo] is specified, only pulls from that repository.
524524+ Otherwise, pulls from all repositories where the handle is ahead.
525525+526526+ @param handle The verse member handle (e.g., "avsm.bsky.social")
527527+ @param repo Optional specific repository to pull from
528528+ @param refresh If true, force fresh fetches ignoring cache (default: false) *)
529529+530530+(** {1 Cherry-pick} *)
531531+532532+(** Result of cherry-picking a commit. *)
533533+type cherrypick_result = {
534534+ repo_name : string;
535535+ commit_hash : string;
536536+ commit_subject : string;
537537+}
538538+539539+val pp_cherrypick_result : cherrypick_result Fmt.t
540540+(** [pp_cherrypick_result] formats a cherry-pick result. *)
541541+542542+val cherrypick :
543543+ proc:_ Eio.Process.mgr ->
544544+ fs:Eio.Fs.dir_ty Eio.Path.t ->
545545+ config:Config.t ->
546546+ verse_config:Verse_config.t ->
547547+ sha:string ->
548548+ ?refresh:bool ->
549549+ unit ->
550550+ (cherrypick_result, error) result
551551+(** [cherrypick ~proc ~fs ~config ~verse_config ~sha ?refresh ()]
552552+ applies a specific commit from a verse member's fork to your local checkout.
553553+554554+ Finds the commit in the verse diff output and cherry-picks it into the
555555+ appropriate local checkout. The changes are then ready to be synced to
556556+ the monorepo via [sync].
557557+558558+ @param sha Commit SHA prefix (7+ characters) to cherry-pick
559559+ @param refresh If true, force fresh fetches ignoring cache (default: false) *)
+11-42
lib/opam_repo.ml
···3131 | true -> String.sub url 4 (String.length url - 4)
3232 | false -> url
3333 in
3434- let uri = Uri.of_string url in
3535- (* Strip fragment from dev-repo URL - branch comes from url field *)
3636- Uri.with_fragment uri None
3737-3838-(** Extract branch from a URL string with optional #branch fragment *)
3939-let extract_branch_from_url url =
4040- let url =
4141- match String.starts_with ~prefix:"git+" url with
4242- | true -> String.sub url 4 (String.length url - 4)
4343- | false -> url
4444- in
4545- Uri.fragment (Uri.of_string url)
3434+ Uri.of_string url
46354736module OP = OpamParserTypes.FullPos
4837···5847 | _ -> None)
5948 items
60496161-(** Find the 'src' field inside a 'url' section *)
6262-let find_url_src (items : OP.opamfile_item list) : string option =
6363- List.find_map
6464- (fun (item : OP.opamfile_item) ->
6565- match item.pelem with
6666- | OP.Section sec when sec.section_kind.pelem = "url" ->
6767- (* Look for src field inside the section *)
6868- List.find_map
6969- (fun (inner : OP.opamfile_item) ->
7070- match inner.pelem with
7171- | OP.Variable (name, value) when name.pelem = "src" ->
7272- extract_string_value value
7373- | _ -> None)
7474- sec.section_items.pelem
7575- | _ -> None)
7676- items
7777-7850(** Extract package name from a dependency formula value.
7951 Handles cases like:
8052 - "pkgname"
···8759 | OP.Option (inner, _) -> extract_dep_name inner
8860 | _ -> None
89619090-(** Extract all dependency package names from a depends value.
9191- The depends field is a list of package formulas. *)
6262+(** Extract all dependency package names from a depends value. The depends field
6363+ is a list of package formulas. *)
9264let extract_depends_list (v : OP.value) : string list =
9365 match v.pelem with
9494- | OP.List { pelem = items; _ } ->
9595- List.filter_map extract_dep_name items
9696- | _ -> (
9797- match extract_dep_name v with Some s -> [ s ] | None -> [])
6666+ | OP.List { pelem = items; _ } -> List.filter_map extract_dep_name items
6767+ | _ -> ( match extract_dep_name v with Some s -> [ s ] | None -> [])
98689969let find_depends (items : OP.opamfile_item list) : string list =
10070 List.find_map
···144114 if not (is_git_url url) then Error (Not_git_remote (name, url))
145115 else
146116 let dev_repo = normalize_git_url url in
147147- (* Extract branch from url field's src, not from dev-repo *)
148148- let branch = Option.bind (find_url_src opamfile.file_contents) extract_branch_from_url in
149117 let depends = find_depends opamfile.file_contents in
150118 let synopsis = find_synopsis opamfile.file_contents in
151151- Ok (Package.create ~name ~version ~dev_repo ?branch ~depends ?synopsis ())
119119+ Ok (Package.create ~name ~version ~dev_repo ~depends ?synopsis ())
152120 with
153121 | Eio.Io _ as e -> Error (Io_error (Printexc.to_string e))
154122 | exn -> Error (Parse_error (path_str, Printexc.to_string exn)))
···193161 let _, errors = scan_all ~fs repo_path in
194162 errors
195163196196-(** Scan a directory for .opam files and extract all dependencies.
197197- This is used to find dependencies from monorepo subtree directories,
198198- where multiple .opam files may exist that aren't in the opam overlay. *)
164164+(** Scan a directory for .opam files and extract all dependencies. This is used
165165+ to find dependencies from monorepo subtree directories, where multiple .opam
166166+ files may exist that aren't in the opam overlay. *)
199167let scan_opam_files_for_deps ~fs dir_path =
200168 let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in
201169 try
···209177 try
210178 let content = Eio.Path.load opam_path in
211179 let opamfile =
212212- OpamParser.FullPos.string content (Fpath.to_string dir_path ^ "/" ^ opam_file)
180180+ OpamParser.FullPos.string content
181181+ (Fpath.to_string dir_path ^ "/" ^ opam_file)
213182 in
214183 find_depends opamfile.file_contents
215184 with _ -> [])
+4-9
lib/opam_repo.mli
···71717272val normalize_git_url : string -> Uri.t
7373(** [normalize_git_url url] normalizes a git URL by removing the "git+" prefix
7474- and any fragment (branch) if present.
7474+ if present.
75757676- For example, "git+https://example.com/repo.git#main" becomes
7676+ For example, "git+https://example.com/repo.git" becomes
7777 "https://example.com/repo.git". *)
78787979-val extract_branch_from_url : string -> string option
8080-(** [extract_branch_from_url url] extracts the branch from a URL fragment.
8181-8282- For example, "git+https://example.com/repo.git#main" returns [Some "main"]. *)
8383-8479val scan_opam_files_for_deps : fs:_ Eio.Path.t -> Fpath.t -> string list
8580(** [scan_opam_files_for_deps ~fs dir_path] scans a directory for .opam files
8681 and extracts all dependencies from them.
87828888- This is used to find dependencies from monorepo subtree directories,
8989- where multiple .opam files may exist that aren't in the opam overlay.
8383+ This is used to find dependencies from monorepo subtree directories, where
8484+ multiple .opam files may exist that aren't in the opam overlay.
90859186 @param fs Eio filesystem capability
9287 @param dir_path Path to the directory to scan
+78
lib/opam_transform.ml
···11+(** Transform dune-generated opam files for opam-repo overlay. *)
22+33+(** Remove the "generated by dune" comment from the first line *)
44+let strip_dune_comment content =
55+ let lines = String.split_on_char '\n' content in
66+ match lines with
77+ | first :: rest
88+ when String.starts_with ~prefix:"# This file is generated by dune"
99+ (String.trim first) ->
1010+ String.concat "\n" rest
1111+ | _ -> content
1212+1313+(** Remove existing dev-repo line if present *)
1414+let remove_dev_repo_line content =
1515+ let lines = String.split_on_char '\n' content in
1616+ let lines =
1717+ List.filter
1818+ (fun line ->
1919+ let trimmed = String.trim line in
2020+ not (String.starts_with ~prefix:"dev-repo:" trimmed))
2121+ lines
2222+ in
2323+ String.concat "\n" lines
2424+2525+(** Remove existing url { ... } section if present *)
2626+let remove_url_section content =
2727+ let lines = String.split_on_char '\n' content in
2828+ let rec process lines in_url_block acc =
2929+ match lines with
3030+ | [] -> List.rev acc
3131+ | line :: rest ->
3232+ let trimmed = String.trim line in
3333+ if in_url_block then
3434+ (* Inside url { ... }, skip until we see } *)
3535+ if String.starts_with ~prefix:"}" trimmed then
3636+ process rest false acc
3737+ else process rest true acc
3838+ else if trimmed = "url {" || String.starts_with ~prefix:"url {" trimmed
3939+ then
4040+ (* Start of url block *)
4141+ if String.ends_with ~suffix:"}" trimmed then
4242+ (* Single-line url block, skip it *)
4343+ process rest false acc
4444+ else process rest true acc
4545+ else process rest false (line :: acc)
4646+ in
4747+ String.concat "\n" (process lines false [])
4848+4949+(** Trim trailing blank lines and ensure single trailing newline *)
5050+let normalize_ending content =
5151+ let lines = String.split_on_char '\n' content in
5252+ let rec trim_trailing = function
5353+ | [] -> []
5454+ | [ "" ] -> []
5555+ | "" :: rest -> (
5656+ match trim_trailing rest with [] -> [] | trimmed -> "" :: trimmed)
5757+ | x :: rest -> x :: trim_trailing rest
5858+ in
5959+ let lines = List.rev (trim_trailing (List.rev lines)) in
6060+ String.concat "\n" lines
6161+6262+let transform ~content ~dev_repo ~url_src =
6363+ (* Step 1: Strip the dune comment *)
6464+ let content = strip_dune_comment content in
6565+6666+ (* Step 2: Remove any existing dev-repo and url sections *)
6767+ let content = remove_dev_repo_line content in
6868+ let content = remove_url_section content in
6969+7070+ (* Step 3: Normalize ending *)
7171+ let content = normalize_ending content in
7272+7373+ (* Step 4: Append dev-repo and url section *)
7474+ let dev_repo_line = Printf.sprintf {|dev-repo: "%s"|} dev_repo in
7575+ let url_section =
7676+ Printf.sprintf "url {\n src: \"%s\"\n}" url_src
7777+ in
7878+ content ^ "\n" ^ dev_repo_line ^ "\n" ^ url_section ^ "\n"
+18
lib/opam_transform.mli
···11+(** Transform dune-generated opam files for opam-repo overlay.
22+33+ Dune generates .opam files from dune-project, but these need to be
44+ transformed before being placed in the opam-repo overlay:
55+ - Remove the "generated by dune" comment
66+ - Add dev-repo field with the git repository URL
77+ - Add url section with source URL and branch *)
88+99+val transform : content:string -> dev_repo:string -> url_src:string -> string
1010+(** [transform ~content ~dev_repo ~url_src] transforms a dune-generated opam file.
1111+1212+ - Removes the "# This file is generated by dune" comment if present
1313+ - Adds or replaces the [dev-repo] field with [dev_repo]
1414+ - Adds or replaces the [url { src: "..." }] section with [url_src]
1515+1616+ @param content The original opam file content
1717+ @param dev_repo The dev-repo URL (e.g., "git+https://github.com/user/repo.git")
1818+ @param url_src The url src URL with branch (e.g., "git+https://...#main") *)
+4-2
lib/package.mli
···2020 ?synopsis:string ->
2121 unit ->
2222 t
2323-(** [create ~name ~version ~dev_repo ?branch ?depends ?synopsis ()] creates a new package.
2323+(** [create ~name ~version ~dev_repo ?branch ?depends ?synopsis ()] creates a
2424+ new package.
24252526 @param name The opam package name
2627 @param version The package version (e.g., "dev")
···4445(** [branch t] returns the branch to track, if explicitly set. *)
45464647val depends : t -> string list
4747-(** [depends t] returns the list of opam package names this package depends on. *)
4848+(** [depends t] returns the list of opam package names this package depends on.
4949+*)
48504951val synopsis : t -> string option
5052(** [synopsis t] returns the short description of the package, if any. *)
+535
lib/site.ml
···11+(** Generate a static HTML site representing the monoverse map. *)
22+33+(** Information about a package in the verse *)
44+type pkg_info = {
55+ name : string;
66+ synopsis : string option;
77+ repo_name : string;
88+ dev_repo : string; (** Upstream git URL *)
99+ owners : string list; (** List of handles that have this package *)
1010+ depends : string list; (** Package dependencies *)
1111+}
1212+1313+(** Information about a repository (group of packages) *)
1414+type repo_info = {
1515+ ri_name : string;
1616+ ri_dev_repo : string;
1717+ ri_packages : pkg_info list;
1818+ ri_owners : string list; (** All handles that have any package from this repo *)
1919+ ri_fork_status : (string * Forks.relationship) list; (** (handle, relationship) *)
2020+ ri_dep_count : int; (** Number of dependencies (for sorting) *)
2121+}
2222+2323+(** Information about a verse member *)
2424+type member_info = {
2525+ handle : string;
2626+ display_name : string; (** Name to display (from registry or handle) *)
2727+ monorepo_url : string;
2828+ opam_url : string;
2929+ package_count : int;
3030+ unique_packages : string list; (** Packages unique to this member *)
3131+}
3232+3333+(** Aggregated site data *)
3434+type site_data = {
3535+ local_handle : string;
3636+ registry_name : string;
3737+ registry_description : string option;
3838+ members : member_info list;
3939+ common_repos : repo_info list; (** Repos that exist in multiple members *)
4040+ unique_repos : repo_info list; (** Repos unique to one member *)
4141+ all_packages : pkg_info list; (** All packages *)
4242+}
4343+4444+(** Scan a member's opam repo and return package info *)
4545+let scan_member_packages ~fs opam_repo_path =
4646+ let pkgs, _errors = Opam_repo.scan_all ~fs opam_repo_path in
4747+ List.map (fun pkg ->
4848+ {
4949+ name = Package.name pkg;
5050+ synopsis = Package.synopsis pkg;
5151+ repo_name = Package.repo_name pkg;
5252+ dev_repo = Uri.to_string (Package.dev_repo pkg);
5353+ owners = [];
5454+ depends = Package.depends pkg;
5555+ }
5656+ ) pkgs
5757+5858+(** Check if a directory exists *)
5959+let dir_exists ~fs path =
6060+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
6161+ match Eio.Path.kind ~follow:true eio_path with
6262+ | `Directory -> true
6363+ | _ -> false
6464+ | exception _ -> false
6565+6666+(** Collect site data from the workspace *)
6767+let collect_data ~fs ~config ?forks ~registry () =
6868+ let local_handle = Verse_config.handle config in
6969+ let local_opam_repo = Verse_config.opam_repo_path config in
7070+ let verse_path = Verse_config.verse_path config in
7171+7272+ (* Scan local packages *)
7373+ let local_pkgs =
7474+ if dir_exists ~fs local_opam_repo then
7575+ scan_member_packages ~fs local_opam_repo
7676+ else []
7777+ in
7878+7979+ (* Build a map: package name -> list of (handle, pkg_info) *)
8080+ let pkg_map : (string, (string * pkg_info) list) Hashtbl.t = Hashtbl.create 256 in
8181+8282+ (* Add local packages *)
8383+ List.iter (fun pkg ->
8484+ let existing = try Hashtbl.find pkg_map pkg.name with Not_found -> [] in
8585+ Hashtbl.replace pkg_map pkg.name ((local_handle, pkg) :: existing)
8686+ ) local_pkgs;
8787+8888+ let registry_name = registry.Verse_registry.name in
8989+ let registry_description = registry.Verse_registry.description in
9090+9191+ (* Build handle -> display name lookup *)
9292+ let handle_to_name = Hashtbl.create 16 in
9393+ List.iter (fun (m : Verse_registry.member) ->
9494+ let display = match m.name with Some n -> n | None -> m.handle in
9595+ Hashtbl.replace handle_to_name m.handle display
9696+ ) registry.Verse_registry.members;
9797+9898+ (* Get tracked handles from verse directory, excluding local handle *)
9999+ let tracked_handles =
100100+ if dir_exists ~fs verse_path then
101101+ let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in
102102+ try
103103+ Eio.Path.read_dir eio_path
104104+ |> List.filter (fun name ->
105105+ not (String.ends_with ~suffix:"-opam" name) &&
106106+ name <> local_handle &&
107107+ dir_exists ~fs Fpath.(verse_path / name))
108108+ with Eio.Io _ -> []
109109+ else []
110110+ in
111111+112112+ (* Scan each tracked member's opam repo *)
113113+ let member_infos =
114114+ List.filter_map (fun handle ->
115115+ let opam_path = Fpath.(verse_path / (handle ^ "-opam")) in
116116+ if dir_exists ~fs opam_path then begin
117117+ let pkgs = scan_member_packages ~fs opam_path in
118118+ (* Add to package map *)
119119+ List.iter (fun pkg ->
120120+ let existing = try Hashtbl.find pkg_map pkg.name with Not_found -> [] in
121121+ Hashtbl.replace pkg_map pkg.name ((handle, pkg) :: existing)
122122+ ) pkgs;
123123+ (* Look up member in registry for URLs *)
124124+ let member = Verse_registry.find_member registry ~handle in
125125+ let display_name =
126126+ try Hashtbl.find handle_to_name handle
127127+ with Not_found -> handle
128128+ in
129129+ Some {
130130+ handle;
131131+ display_name;
132132+ monorepo_url = (match member with Some m -> m.monorepo | None -> "");
133133+ opam_url = (match member with Some m -> m.opamrepo | None -> "");
134134+ package_count = List.length pkgs;
135135+ unique_packages = []; (* Will be filled in later *)
136136+ }
137137+ end else None
138138+ ) tracked_handles
139139+ in
140140+141141+ (* Add local member info *)
142142+ let local_member =
143143+ let member = Verse_registry.find_member registry ~handle:local_handle in
144144+ let display_name =
145145+ try Hashtbl.find handle_to_name local_handle
146146+ with Not_found -> local_handle
147147+ in
148148+ {
149149+ handle = local_handle;
150150+ display_name;
151151+ monorepo_url = (match member with Some m -> m.monorepo | None -> "");
152152+ opam_url = (match member with Some m -> m.opamrepo | None -> "");
153153+ package_count = List.length local_pkgs;
154154+ unique_packages = [];
155155+ }
156156+ in
157157+158158+ (* Build final package list with owners *)
159159+ let all_packages =
160160+ Hashtbl.fold (fun _name entries acc ->
161161+ match entries with
162162+ | [] -> acc
163163+ | (_, pkg) :: _ as all ->
164164+ let owners = List.map fst all in
165165+ (* Pick the best synopsis (first non-None) *)
166166+ let synopsis =
167167+ List.find_map (fun (_, p) -> p.synopsis) all
168168+ in
169169+ (* Merge depends from all sources *)
170170+ let depends =
171171+ List.concat_map (fun (_, p) -> p.depends) all
172172+ |> List.sort_uniq String.compare
173173+ in
174174+ { pkg with owners; synopsis; depends } :: acc
175175+ ) pkg_map []
176176+ |> List.sort (fun a b -> String.compare a.name b.name)
177177+ in
178178+179179+ (* Build set of all package names for dependency counting *)
180180+ let all_pkg_names =
181181+ List.fold_left (fun s p -> Hashtbl.replace s p.name (); s)
182182+ (Hashtbl.create 256) all_packages
183183+ in
184184+185185+ (* Group packages by repo *)
186186+ let repos_map : (string, pkg_info list) Hashtbl.t = Hashtbl.create 64 in
187187+ List.iter (fun (pkg : pkg_info) ->
188188+ let existing = try Hashtbl.find repos_map pkg.repo_name with Not_found -> [] in
189189+ Hashtbl.replace repos_map pkg.repo_name (pkg :: existing)
190190+ ) all_packages;
191191+192192+ (* Build forks status lookup from forks data if provided *)
193193+ let forks_by_repo : (string, (string * Forks.relationship) list) Hashtbl.t = Hashtbl.create 64 in
194194+ (match forks with
195195+ | Some f ->
196196+ List.iter (fun (ra : Forks.repo_analysis) ->
197197+ let statuses = List.map (fun (h, _src, rel) -> (h, rel)) ra.verse_sources in
198198+ Hashtbl.replace forks_by_repo ra.repo_name statuses
199199+ ) f.Forks.repos
200200+ | None -> ());
201201+202202+ (* Build repo_info list with dependency counts *)
203203+ let all_repos =
204204+ Hashtbl.fold (fun repo_name pkgs acc ->
205205+ let dev_repo = (List.hd pkgs).dev_repo in
206206+ let owners =
207207+ List.sort_uniq String.compare (List.concat_map (fun (p : pkg_info) -> p.owners) pkgs)
208208+ in
209209+ let fork_status =
210210+ try Hashtbl.find forks_by_repo repo_name with Not_found -> []
211211+ in
212212+ (* Count dependencies that are in our package set *)
213213+ let dep_count =
214214+ List.concat_map (fun (p : pkg_info) -> p.depends) pkgs
215215+ |> List.filter (fun d -> Hashtbl.mem all_pkg_names d)
216216+ |> List.sort_uniq String.compare
217217+ |> List.length
218218+ in
219219+ { ri_name = repo_name;
220220+ ri_dev_repo = dev_repo;
221221+ ri_packages = List.sort (fun a b -> String.compare a.name b.name) pkgs;
222222+ ri_owners = owners;
223223+ ri_fork_status = fork_status;
224224+ ri_dep_count = dep_count } :: acc
225225+ ) repos_map []
226226+ (* Sort by dependency count descending (apps with most deps first), then by name *)
227227+ |> List.sort (fun a b ->
228228+ let cmp = compare b.ri_dep_count a.ri_dep_count in
229229+ if cmp <> 0 then cmp else String.compare a.ri_name b.ri_name)
230230+ in
231231+232232+ (* Separate common and unique repos *)
233233+ let common_repos = List.filter (fun r -> List.length r.ri_owners > 1) all_repos in
234234+ let unique_repos = List.filter (fun r -> List.length r.ri_owners = 1) all_repos in
235235+236236+ (* Compute unique packages per member *)
237237+ let unique_by_handle = Hashtbl.create 32 in
238238+ List.iter (fun (pkg : pkg_info) ->
239239+ if List.length pkg.owners = 1 then begin
240240+ let handle = List.hd pkg.owners in
241241+ let existing = try Hashtbl.find unique_by_handle handle with Not_found -> [] in
242242+ Hashtbl.replace unique_by_handle handle (pkg.name :: existing)
243243+ end
244244+ ) all_packages;
245245+246246+ (* Update member infos with unique packages *)
247247+ let update_member m =
248248+ let unique = try Hashtbl.find unique_by_handle m.handle with Not_found -> [] in
249249+ { m with unique_packages = List.sort String.compare unique }
250250+ in
251251+252252+ let all_members = local_member :: member_infos in
253253+ let members = List.map update_member all_members in
254254+255255+ { local_handle; registry_name; registry_description; members; common_repos; unique_repos; all_packages }
256256+257257+(** Escape HTML special characters *)
258258+let html_escape s =
259259+ let buf = Buffer.create (String.length s) in
260260+ String.iter (function
261261+ | '<' -> Buffer.add_string buf "<"
262262+ | '>' -> Buffer.add_string buf ">"
263263+ | '&' -> Buffer.add_string buf "&"
264264+ | '"' -> Buffer.add_string buf """
265265+ | c -> Buffer.add_char buf c
266266+ ) s;
267267+ Buffer.contents buf
268268+269269+(** External link SVG icon *)
270270+let external_link_icon =
271271+ {|<svg class="ext-icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3.5 3H9V8.5M9 3L3 9"/></svg>|}
272272+273273+(** Format fork relationship as short string *)
274274+let format_relationship = function
275275+ | Forks.Same_url -> "="
276276+ | Forks.Same_commit -> "sync"
277277+ | Forks.I_am_ahead n -> Printf.sprintf "+%d" n
278278+ | Forks.I_am_behind n -> Printf.sprintf "-%d" n
279279+ | Forks.Diverged { my_ahead; their_ahead; _ } -> Printf.sprintf "+%d/-%d" my_ahead their_ahead
280280+ | Forks.Unrelated -> "unrel"
281281+ | Forks.Not_fetched -> "?"
282282+283283+(** Generate HTML from site data *)
284284+let generate_html data =
285285+ let buf = Buffer.create 16384 in
286286+ let add = Buffer.add_string buf in
287287+288288+ (* Build member lookups *)
289289+ let member_urls = Hashtbl.create 16 in
290290+ let member_names = Hashtbl.create 16 in
291291+ List.iter (fun m ->
292292+ Hashtbl.replace member_urls m.handle (m.monorepo_url, m.opam_url);
293293+ Hashtbl.replace member_names m.handle m.display_name
294294+ ) data.members;
295295+296296+ (* Helper to get display name for handle *)
297297+ let get_name handle =
298298+ try Hashtbl.find member_names handle with Not_found -> handle
299299+ in
300300+301301+ add {|<!DOCTYPE html>
302302+<html lang="en">
303303+<head>
304304+<meta charset="UTF-8">
305305+<meta name="viewport" content="width=device-width, initial-scale=1.0">
306306+<title>|};
307307+ add (html_escape data.registry_name);
308308+ add {|</title>
309309+<style>
310310+* { margin: 0; padding: 0; box-sizing: border-box; }
311311+body { font: 10pt/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #333; max-width: 900px; margin: 0 auto; padding: 12px; }
312312+h1 { font-size: 14pt; font-weight: 600; margin-bottom: 4px; }
313313+.subtitle { font-size: 9pt; color: #666; margin-bottom: 12px; border-bottom: 1px solid #ddd; padding-bottom: 8px; }
314314+h2 { font-size: 11pt; font-weight: 600; margin: 16px 0 8px; color: #444; }
315315+h3 { font-size: 10pt; font-weight: 600; margin: 12px 0 6px; color: #555; }
316316+a { color: #0066cc; text-decoration: none; }
317317+a:hover { text-decoration: underline; }
318318+a.ext { color: #0088aa; }
319319+a.ext:hover { color: #006688; }
320320+.ext-icon { width: 10px; height: 10px; margin-left: 2px; vertical-align: baseline; position: relative; top: 1px; }
321321+.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin-bottom: 16px; }
322322+.member { background: #f8f8f8; padding: 8px; border-radius: 4px; border: 1px solid #e0e0e0; }
323323+.member-name { font-weight: 600; margin-bottom: 2px; }
324324+.member-handle { font-size: 8pt; color: #888; margin-bottom: 4px; }
325325+.member-stats { font-size: 9pt; color: #666; }
326326+.member-links { font-size: 9pt; margin-top: 4px; }
327327+.member-links a { margin-right: 8px; }
328328+.section { margin-bottom: 20px; }
329329+.summary { background: #fafafa; border: 1px solid #e8e8e8; border-radius: 4px; padding: 12px; margin-bottom: 16px; }
330330+.summary-title { font-weight: 600; margin-bottom: 8px; }
331331+.summary-list { font-size: 9pt; color: #555; line-height: 1.6; }
332332+.summary-item { display: inline-block; background: #fff; border: 1px solid #ddd; padding: 1px 6px; border-radius: 3px; margin: 2px 2px; }
333333+.summary-item a { color: #333; }
334334+.repo { margin-bottom: 12px; padding: 8px; background: #fafafa; border-radius: 4px; }
335335+.repo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
336336+.repo-name { font-weight: 600; }
337337+.repo-name a { color: #333; }
338338+.repo-packages { font-size: 9pt; color: #666; margin-bottom: 4px; }
339339+.pkg-list { list-style: none; margin: 4px 0 0 0; padding: 0; }
340340+.pkg-list li { padding: 1px 0; color: #555; font-size: 8pt; }
341341+.pkg-list li::before { content: "-"; color: #999; margin-right: 6px; }
342342+.pkg-list b { font-weight: 500; color: #444; }
343343+.repo-forks { margin-top: 6px; }
344344+.repo-forks summary { font-size: 9pt; color: #666; cursor: pointer; }
345345+.repo-forks summary:hover { color: #444; }
346346+.fork-list { margin-top: 4px; font-size: 9pt; display: flex; flex-wrap: wrap; gap: 4px 12px; }
347347+.fork-item { color: #555; }
348348+.fork-item a { margin-left: 4px; }
349349+.fork-status { font-family: monospace; font-size: 8pt; padding: 1px 4px; border-radius: 2px; margin-left: 4px; }
350350+.fork-status.ahead { background: #e6f4ea; color: #137333; }
351351+.fork-status.behind { background: #fce8e6; color: #c5221f; }
352352+.fork-status.diverged { background: #fef7e0; color: #b06000; }
353353+.fork-status.sync { background: #e8f0fe; color: #1a73e8; }
354354+.unique-section { margin-top: 12px; }
355355+.unique-member { margin-bottom: 8px; }
356356+.unique-member-name { font-weight: 500; font-size: 9pt; color: #555; }
357357+.unique-list { font-size: 9pt; color: #666; margin-top: 2px; }
358358+.intro { background: #f0f7ff; border: 1px solid #d0e3f5; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 9pt; line-height: 1.5; color: #444; }
359359+footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 9pt; color: #888; }
360360+</style>
361361+</head>
362362+<body>
363363+|};
364364+365365+ (* Title and description *)
366366+ add (Printf.sprintf "<h1>%s</h1>\n" (html_escape data.registry_name));
367367+ (match data.registry_description with
368368+ | Some desc -> add (Printf.sprintf "<div class=\"subtitle\">%s</div>\n" (html_escape desc))
369369+ | None -> add "<div class=\"subtitle\"></div>\n");
370370+371371+ (* Intro section *)
372372+ add {|<div class="intro">
373373+This is an experiment in large-scale agentic coding using OCaml and OxCaml, where we're building environments to exchange vibe code at scale.
374374+Managed by <a class="ext" href="https://tangled.org/anil.recoil.org/monopam">monopam|}; add external_link_icon; add {|</a>,
375375+with the central registry at <a class="ext" href="https://tangled.org/eeg.cl.cam.ac.uk/opamverse">opamverse|}; add external_link_icon; add {|</a>.
376376+</div>
377377+|};
378378+379379+ (* Members section *)
380380+ add "<div class=\"section\">\n<h2>Members</h2>\n<div class=\"members\">\n";
381381+ List.iter (fun m ->
382382+ add "<div class=\"member\">\n";
383383+ add (Printf.sprintf "<div class=\"member-name\"><a href=\"https://%s\">%s</a></div>\n"
384384+ (html_escape m.handle) (html_escape m.display_name));
385385+ if m.display_name <> m.handle then
386386+ add (Printf.sprintf "<div class=\"member-handle\">%s</div>\n" (html_escape m.handle));
387387+ add (Printf.sprintf "<div class=\"member-stats\">%d packages" m.package_count);
388388+ if m.unique_packages <> [] then
389389+ add (Printf.sprintf ", %d unique" (List.length m.unique_packages));
390390+ add "</div>\n";
391391+ if m.monorepo_url <> "" || m.opam_url <> "" then begin
392392+ add "<div class=\"member-links\">";
393393+ if m.monorepo_url <> "" then
394394+ add (Printf.sprintf "<a class=\"ext\" href=\"%s\">mono%s</a>" (html_escape m.monorepo_url) external_link_icon);
395395+ if m.opam_url <> "" then
396396+ add (Printf.sprintf "<a class=\"ext\" href=\"%s\">opam%s</a>" (html_escape m.opam_url) external_link_icon);
397397+ add "</div>\n"
398398+ end;
399399+ add "</div>\n"
400400+ ) data.members;
401401+ add "</div>\n</div>\n";
402402+403403+ (* Summary section *)
404404+ add "<div class=\"section\">\n";
405405+ add "<div class=\"summary\">\n";
406406+ add (Printf.sprintf "<div class=\"summary-title\">Common Libraries (%d repos, %d packages)</div>\n"
407407+ (List.length data.common_repos)
408408+ (List.fold_left (fun acc r -> acc + List.length r.ri_packages) 0 data.common_repos));
409409+ add "<div class=\"summary-list\">\n";
410410+ List.iter (fun r ->
411411+ add (Printf.sprintf "<span class=\"summary-item\"><a href=\"#%s\">%s</a> <span style=\"color:#888\">(%d)</span></span>\n"
412412+ (html_escape r.ri_name) (html_escape r.ri_name) (List.length r.ri_packages))
413413+ ) data.common_repos;
414414+ add "</div>\n</div>\n";
415415+416416+ (* Member-specific summary *)
417417+ let members_with_unique = List.filter (fun m -> m.unique_packages <> []) data.members in
418418+ if members_with_unique <> [] then begin
419419+ add "<div class=\"summary\">\n";
420420+ add "<div class=\"summary-title\">Member-Specific Packages</div>\n";
421421+ add "<div class=\"unique-section\">\n";
422422+ List.iter (fun m ->
423423+ add "<div class=\"unique-member\">\n";
424424+ add (Printf.sprintf "<span class=\"unique-member-name\"><a href=\"https://%s\">%s</a>:</span> "
425425+ (html_escape m.handle) (html_escape m.display_name));
426426+ add "<span class=\"unique-list\">";
427427+ add (String.concat ", " (List.map html_escape m.unique_packages));
428428+ add "</span>\n";
429429+ add "</div>\n"
430430+ ) members_with_unique;
431431+ add "</div>\n</div>\n"
432432+ end;
433433+ add "</div>\n";
434434+435435+ (* Detailed repos section *)
436436+ if data.common_repos <> [] then begin
437437+ add "<div class=\"section\">\n<h2>Repository Details</h2>\n";
438438+439439+ List.iter (fun r ->
440440+ add (Printf.sprintf "<div class=\"repo\" id=\"%s\">\n" (html_escape r.ri_name));
441441+ add "<div class=\"repo-header\">";
442442+ add (Printf.sprintf "<span class=\"repo-name\"><a class=\"ext\" href=\"%s\">%s%s</a></span>"
443443+ (html_escape r.ri_dev_repo) (html_escape r.ri_name) external_link_icon);
444444+ add "</div>\n";
445445+446446+ (* Packages list - compact with names *)
447447+ add "<div class=\"repo-packages\">";
448448+ let pkg_names = List.map (fun (p : pkg_info) -> p.name) r.ri_packages in
449449+ add (String.concat ", " (List.map html_escape pkg_names));
450450+ add "</div>\n";
451451+452452+ (* Package descriptions as bullet list *)
453453+ let pkg_descs = List.filter_map (fun (p : pkg_info) ->
454454+ match p.synopsis with
455455+ | Some s -> Some (p.name, s)
456456+ | None -> None
457457+ ) r.ri_packages in
458458+ if pkg_descs <> [] then begin
459459+ add "<ul class=\"pkg-list\">\n";
460460+ List.iter (fun (name, desc) ->
461461+ add (Printf.sprintf "<li><b>%s</b>: %s</li>\n" (html_escape name) (html_escape desc))
462462+ ) pkg_descs;
463463+ add "</ul>\n"
464464+ end;
465465+466466+ (* Forks - at repo level with names *)
467467+ if List.length r.ri_owners > 1 then begin
468468+ let owner_links = List.map (fun h ->
469469+ Printf.sprintf "<a href=\"https://%s\">%s</a>" (html_escape h) (html_escape (get_name h))
470470+ ) (List.sort String.compare r.ri_owners) in
471471+ add "<details class=\"repo-forks\">\n";
472472+ add (Printf.sprintf "<summary>%d members (%s)</summary>\n"
473473+ (List.length r.ri_owners)
474474+ (String.concat ", " owner_links));
475475+ add "<div class=\"fork-list\">\n";
476476+ List.iter (fun handle ->
477477+ let mono_url, _opam_url =
478478+ try Hashtbl.find member_urls handle
479479+ with Not_found -> ("", "")
480480+ in
481481+ add "<span class=\"fork-item\">";
482482+ add (Printf.sprintf "<a href=\"https://%s\">%s</a>" (html_escape handle) (html_escape (get_name handle)));
483483+ (* Add status if available *)
484484+ (match List.assoc_opt handle r.ri_fork_status with
485485+ | Some rel ->
486486+ let status_str = format_relationship rel in
487487+ let status_class =
488488+ match rel with
489489+ | Forks.Same_url | Forks.Same_commit -> "sync"
490490+ | Forks.I_am_ahead _ -> "ahead"
491491+ | Forks.I_am_behind _ -> "behind"
492492+ | Forks.Diverged _ -> "diverged"
493493+ | _ -> ""
494494+ in
495495+ if status_class <> "" then
496496+ add (Printf.sprintf "<span class=\"fork-status %s\">%s</span>" status_class status_str)
497497+ else
498498+ add (Printf.sprintf "<span class=\"fork-status\">%s</span>" status_str)
499499+ | None -> ());
500500+ if mono_url <> "" then
501501+ add (Printf.sprintf "<a class=\"ext\" href=\"%s/%s\">mono%s</a>"
502502+ (html_escape mono_url) (html_escape r.ri_name) external_link_icon);
503503+ add "</span>\n"
504504+ ) (List.sort String.compare r.ri_owners);
505505+ add "</div>\n</details>\n"
506506+ end;
507507+508508+ add "</div>\n"
509509+ ) data.common_repos;
510510+511511+ add "</div>\n"
512512+ end;
513513+514514+ (* Footer with generation date *)
515515+ let now = Unix.gettimeofday () in
516516+ let tm = Unix.gmtime now in
517517+ let date_str = Printf.sprintf "%04d-%02d-%02d"
518518+ (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday in
519519+ add (Printf.sprintf "<footer>Generated by monopam on %s | %d members | %d repos | %d packages</footer>\n"
520520+ date_str (List.length data.members) (List.length data.common_repos + List.length data.unique_repos) (List.length data.all_packages));
521521+522522+ add "</body>\n</html>\n";
523523+ Buffer.contents buf
524524+525525+(** Generate the site and return the HTML content *)
526526+let generate ~fs ~config ?forks ~registry () =
527527+ let data = collect_data ~fs ~config ?forks ~registry () in
528528+ generate_html data
529529+530530+(** Write the site to a file *)
531531+let write ~fs ~config ?forks ~registry ~output_path () =
532532+ let html = generate ~fs ~config ?forks ~registry () in
533533+ let eio_path = Eio.Path.(fs / Fpath.to_string output_path) in
534534+ Eio.Path.save ~create:(`Or_truncate 0o644) eio_path html;
535535+ Ok ()
+82
lib/site.mli
···11+(** Generate a static HTML site representing the monoverse map.
22+33+ The site command generates an index.html that shows:
44+ - All verse members with links to their repos
55+ - Summary of common libraries and member-specific packages
66+ - Detailed repository information with fork status *)
77+88+(** {1 Types} *)
99+1010+(** Information about a package in the verse *)
1111+type pkg_info = {
1212+ name : string;
1313+ synopsis : string option;
1414+ repo_name : string;
1515+ dev_repo : string; (** Upstream git URL *)
1616+ owners : string list; (** List of handles that have this package *)
1717+ depends : string list; (** Package dependencies *)
1818+}
1919+2020+(** Information about a repository (group of packages) *)
2121+type repo_info = {
2222+ ri_name : string;
2323+ ri_dev_repo : string;
2424+ ri_packages : pkg_info list;
2525+ ri_owners : string list; (** All handles that have any package from this repo *)
2626+ ri_fork_status : (string * Forks.relationship) list; (** (handle, relationship) *)
2727+ ri_dep_count : int; (** Number of dependencies (for sorting) *)
2828+}
2929+3030+(** Information about a verse member *)
3131+type member_info = {
3232+ handle : string;
3333+ display_name : string; (** Name to display (from registry or handle) *)
3434+ monorepo_url : string;
3535+ opam_url : string;
3636+ package_count : int;
3737+ unique_packages : string list; (** Packages unique to this member *)
3838+}
3939+4040+(** Aggregated site data *)
4141+type site_data = {
4242+ local_handle : string;
4343+ registry_name : string;
4444+ registry_description : string option;
4545+ members : member_info list;
4646+ common_repos : repo_info list; (** Repos that exist in multiple members *)
4747+ unique_repos : repo_info list; (** Repos unique to one member *)
4848+ all_packages : pkg_info list; (** All packages *)
4949+}
5050+5151+(** {1 Generation} *)
5252+5353+val collect_data :
5454+ fs:Eio.Fs.dir_ty Eio.Path.t ->
5555+ config:Verse_config.t ->
5656+ ?forks:Forks.t ->
5757+ registry:Verse_registry.t ->
5858+ unit ->
5959+ site_data
6060+(** [collect_data ~fs ~config ?forks ~registry ()] scans the workspace and verse members
6161+ to collect package information for the site. If [forks] is provided,
6262+ includes fork status information for each repository. *)
6363+6464+val generate :
6565+ fs:Eio.Fs.dir_ty Eio.Path.t ->
6666+ config:Verse_config.t ->
6767+ ?forks:Forks.t ->
6868+ registry:Verse_registry.t ->
6969+ unit ->
7070+ string
7171+(** [generate ~fs ~config ?forks ~registry ()] generates the HTML content for the site. *)
7272+7373+val write :
7474+ fs:Eio.Fs.dir_ty Eio.Path.t ->
7575+ config:Verse_config.t ->
7676+ ?forks:Forks.t ->
7777+ registry:Verse_registry.t ->
7878+ output_path:Fpath.t ->
7979+ unit ->
8080+ (unit, string) result
8181+(** [write ~fs ~config ?forks ~registry ~output_path ()] generates and writes the site
8282+ to the specified output path. *)
+136
lib/sources_registry.ml
···11+(** Sources registry for tracking forked/vendored package URLs. *)
22+33+type origin = Fork | Join
44+55+type entry = {
66+ url : string;
77+ upstream : string option;
88+ branch : string option;
99+ reason : string option;
1010+ origin : origin option;
1111+}
1212+1313+type t = {
1414+ default_url_base : string option;
1515+ entries : (string * entry) list;
1616+}
1717+1818+let empty = { default_url_base = None; entries = [] }
1919+2020+let default_url_base t = t.default_url_base
2121+2222+let with_default_url_base t base =
2323+ { t with default_url_base = Some base }
2424+2525+let find t ~subtree = List.assoc_opt subtree t.entries
2626+2727+let derive_url t ~subtree =
2828+ match find t ~subtree with
2929+ | Some entry -> Some entry.url
3030+ | None ->
3131+ (* Use default_url_base to construct URL from subtree name *)
3232+ Option.map (fun base ->
3333+ let base =
3434+ if String.ends_with ~suffix:"/" base then
3535+ String.sub base 0 (String.length base - 1)
3636+ else base
3737+ in
3838+ base ^ "/" ^ subtree
3939+ ) t.default_url_base
4040+4141+let add t ~subtree entry =
4242+ { t with entries = (subtree, entry) :: List.remove_assoc subtree t.entries }
4343+4444+let remove t ~subtree =
4545+ { t with entries = List.remove_assoc subtree t.entries }
4646+4747+let to_list t = t.entries
4848+4949+let of_list entries = { default_url_base = None; entries }
5050+5151+(* TOML structure:
5252+ default_url_base = "git+https://tangled.org/anil.recoil.org"
5353+5454+ [braid]
5555+ url = "git+https://github.com/avsm/braid"
5656+ upstream = "git+https://github.com/mtelvers/braid"
5757+ reason = "Maintenance fork"
5858+5959+ [eio]
6060+ url = "git+https://github.com/myorg/eio"
6161+ branch = "backport-5.1"
6262+*)
6363+6464+let origin_codec : origin Tomlt.t =
6565+ Tomlt.map
6666+ ~dec:(function
6767+ | "fork" -> Fork
6868+ | "join" -> Join
6969+ | s -> failwith (Printf.sprintf "Invalid origin: %s (expected 'fork' or 'join')" s))
7070+ ~enc:(function Fork -> "fork" | Join -> "join")
7171+ Tomlt.string
7272+7373+let entry_codec : entry Tomlt.t =
7474+ Tomlt.(
7575+ Table.(
7676+ obj (fun url upstream branch reason origin -> { url; upstream; branch; reason; origin })
7777+ |> mem "url" string ~enc:(fun e -> e.url)
7878+ |> opt_mem "upstream" string ~enc:(fun e -> e.upstream)
7979+ |> opt_mem "branch" string ~enc:(fun e -> e.branch)
8080+ |> opt_mem "reason" string ~enc:(fun e -> e.reason)
8181+ |> opt_mem "origin" origin_codec ~enc:(fun e -> e.origin)
8282+ |> finish))
8383+8484+let codec : t Tomlt.t =
8585+ Tomlt.(
8686+ Table.(
8787+ obj (fun default_url_base entries ->
8888+ { default_url_base; entries })
8989+ |> opt_mem "default_url_base" string ~enc:(fun t -> t.default_url_base)
9090+ |> keep_unknown ~enc:(fun t -> t.entries) (Mems.assoc entry_codec)
9191+ |> finish))
9292+9393+let load ~fs path =
9494+ let path_str = Fpath.to_string path in
9595+ let eio_path = Eio.Path.(fs / path_str) in
9696+ (* Check if file exists *)
9797+ match Eio.Path.kind ~follow:true eio_path with
9898+ | `Regular_file -> (
9999+ try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str) with
100100+ | Failure msg -> Error (Printf.sprintf "Invalid sources.toml: %s" msg)
101101+ | exn -> Error (Printf.sprintf "Error loading sources.toml: %s" (Printexc.to_string exn)))
102102+ | _ -> Ok empty (* File doesn't exist, return empty registry *)
103103+ | exception _ -> Ok empty
104104+105105+let save ~fs path t =
106106+ let path_str = Fpath.to_string path in
107107+ try
108108+ Tomlt_eio.encode_path codec t ~fs path_str;
109109+ Ok ()
110110+ with exn -> Error (Printexc.to_string exn)
111111+112112+let pp_origin ppf = function
113113+ | Fork -> Fmt.string ppf "fork"
114114+ | Join -> Fmt.string ppf "join"
115115+116116+let pp_entry ppf e =
117117+ Fmt.pf ppf "@[<hov 2>url: %s" e.url;
118118+ Option.iter (fun u -> Fmt.pf ppf "@ upstream: %s" u) e.upstream;
119119+ Option.iter (fun b -> Fmt.pf ppf "@ branch: %s" b) e.branch;
120120+ Option.iter (fun r -> Fmt.pf ppf "@ reason: %s" r) e.reason;
121121+ Option.iter (fun o -> Fmt.pf ppf "@ origin: %a" pp_origin o) e.origin;
122122+ Fmt.pf ppf "@]"
123123+124124+let pp ppf t =
125125+ (match t.default_url_base with
126126+ | Some base -> Fmt.pf ppf "default_url_base: %s@," base
127127+ | None -> ());
128128+ if t.entries = [] then Fmt.pf ppf "(no source overrides)"
129129+ else begin
130130+ Fmt.pf ppf "@[<v>";
131131+ List.iter
132132+ (fun (subtree, entry) ->
133133+ Fmt.pf ppf "@[<v 2>[%s]@,%a@]@," subtree pp_entry entry)
134134+ t.entries;
135135+ Fmt.pf ppf "@]"
136136+ end
+74
lib/sources_registry.mli
···11+(** Sources registry for tracking forked/vendored package URLs.
22+33+ The sources.toml file in the monorepo root tracks packages where
44+ the dev-repo URL differs from what's declared in dune-project.
55+ This is typically used for:
66+ - Forked packages (our fork URL vs upstream)
77+ - Vendored packages (local copy, custom URL)
88+ - Packages without source in dune-project
99+1010+ The registry also supports a [default_url_base] field that is used
1111+ to derive URLs for subtrees without explicit entries:
1212+ {v
1313+ default_url_base = "git+https://tangled.org/anil.recoil.org"
1414+ v}
1515+ For a subtree named "ocaml-foo", this would produce:
1616+ [git+https://tangled.org/anil.recoil.org/ocaml-foo] *)
1717+1818+(** How a source entry was created. *)
1919+type origin =
2020+ | Fork (** Created via [monopam fork] - subtree split from monorepo *)
2121+ | Join (** Created via [monopam join] - external repo brought into monorepo *)
2222+2323+(** A source entry for a subtree. *)
2424+type entry = {
2525+ url : string; (** Our dev-repo URL (e.g., "git+https://github.com/avsm/braid") *)
2626+ upstream : string option; (** Original upstream URL if this is a fork *)
2727+ branch : string option; (** Override branch (default: main) *)
2828+ reason : string option; (** Why we have a custom source *)
2929+ origin : origin option; (** How this entry was created *)
3030+}
3131+3232+(** The sources registry - maps subtree names to source entries. *)
3333+type t
3434+3535+val empty : t
3636+(** Empty registry. *)
3737+3838+val default_url_base : t -> string option
3939+(** [default_url_base t] returns the default URL base for deriving URLs. *)
4040+4141+val with_default_url_base : t -> string -> t
4242+(** [with_default_url_base t base] sets the default URL base. *)
4343+4444+val find : t -> subtree:string -> entry option
4545+(** [find t ~subtree] looks up the source entry for a subtree. *)
4646+4747+val derive_url : t -> subtree:string -> string option
4848+(** [derive_url t ~subtree] derives a URL for a subtree.
4949+ First checks for an explicit entry, then uses default_url_base if set. *)
5050+5151+val add : t -> subtree:string -> entry -> t
5252+(** [add t ~subtree entry] adds or replaces an entry. *)
5353+5454+val remove : t -> subtree:string -> t
5555+(** [remove t ~subtree] removes an entry. *)
5656+5757+val to_list : t -> (string * entry) list
5858+(** [to_list t] returns all entries as an association list. *)
5959+6060+val of_list : (string * entry) list -> t
6161+(** [of_list entries] creates a registry from an association list. *)
6262+6363+val load : fs:_ Eio.Path.t -> Fpath.t -> (t, string) result
6464+(** [load ~fs path] loads a sources.toml file. Returns empty registry
6565+ if file doesn't exist. *)
6666+6767+val save : fs:_ Eio.Path.t -> Fpath.t -> t -> (unit, string) result
6868+(** [save ~fs path t] writes the registry to a TOML file. *)
6969+7070+val pp_entry : entry Fmt.t
7171+(** Pretty-print a single entry. *)
7272+7373+val pp : t Fmt.t
7474+(** Pretty-print the registry. *)
+151-60
lib/status.ml
···8899(** Sync state between monorepo subtree and local checkout *)
1010type subtree_sync =
1111- | In_sync (** Subtree matches checkout HEAD *)
1212- | Subtree_behind of int (** Subtree needs pull from checkout (checkout has new commits) *)
1313- | Subtree_ahead of int (** Subtree has commits not in checkout (need push to checkout) *)
1414- | Trees_differ (** Trees differ but can't determine direction/count *)
1515- | Unknown (** Can't determine (subtree not added or checkout missing) *)
1111+ | In_sync (** Subtree matches checkout HEAD *)
1212+ | Subtree_behind of int
1313+ (** Subtree needs pull from checkout (checkout has new commits) *)
1414+ | Subtree_ahead of int
1515+ (** Subtree has commits not in checkout (need push to checkout) *)
1616+ | Trees_differ (** Trees differ but can't determine direction/count *)
1717+ | Unknown (** Can't determine (subtree not added or checkout missing) *)
16181719type t = {
1820 package : Package.t;
···5860 match (checkout, subtree) with
5961 | (Missing | Not_a_repo | Dirty), _ -> Unknown
6062 | _, Not_added -> Unknown
6161- | Clean _, Present ->
6363+ | Clean _, Present -> (
6264 (* Get tree hash of subtree directory in monorepo *)
6363- let subtree_tree = Git.rev_parse ~proc ~fs:fs_t ~rev:("HEAD:" ^ prefix) monorepo in
6565+ let subtree_tree =
6666+ Git.rev_parse ~proc ~fs:fs_t ~rev:("HEAD:" ^ prefix) monorepo
6767+ in
6468 (* Get tree hash of checkout root *)
6565- let checkout_tree = Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD^{tree}" checkout_dir in
6969+ let checkout_tree =
7070+ Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD^{tree}" checkout_dir
7171+ in
6672 match (subtree_tree, checkout_tree) with
6773 | Ok st, Ok ct when st = ct -> In_sync
6868- | Ok _, Ok _ ->
7474+ | Ok _, Ok _ -> (
6975 (* Trees differ - check commit ancestry to determine direction *)
7076 let subtree_commit =
7171- Git.subtree_last_upstream_commit ~proc ~fs:fs_t ~repo:monorepo ~prefix ()
7777+ Git.subtree_last_upstream_commit ~proc ~fs:fs_t ~repo:monorepo
7878+ ~prefix ()
7279 in
7380 let checkout_head = Git.head_commit ~proc ~fs:fs_t checkout_dir in
7474- (match (subtree_commit, checkout_head) with
8181+ match (subtree_commit, checkout_head) with
7582 | Some subtree_sha, Ok checkout_sha ->
7676- if Git.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir
7777- ~commit1:subtree_sha ~commit2:checkout_sha () then
8383+ if
8484+ Git.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir
8585+ ~commit1:subtree_sha ~commit2:checkout_sha ()
8686+ then
7887 (* Checkout has commits not in subtree - need subtree pull *)
7979- let count = Git.count_commits_between ~proc ~fs:fs_t ~repo:checkout_dir
8080- ~base:subtree_sha ~head:checkout_sha () in
8181- if count > 0 then Subtree_behind count
8282- else Trees_differ (* Same commit but trees differ - monorepo has changes *)
8383- else if Git.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir
8484- ~commit1:checkout_sha ~commit2:subtree_sha () then
8888+ let count =
8989+ Git.count_commits_between ~proc ~fs:fs_t ~repo:checkout_dir
9090+ ~base:subtree_sha ~head:checkout_sha ()
9191+ in
9292+ if count > 0 then Subtree_behind count else Trees_differ
9393+ (* Same commit but trees differ - monorepo has changes *)
9494+ else if
9595+ Git.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir
9696+ ~commit1:checkout_sha ~commit2:subtree_sha ()
9797+ then
8598 (* Subtree has content not in checkout - need push *)
8686- let count = Git.count_commits_between ~proc ~fs:fs_t ~repo:checkout_dir
8787- ~base:checkout_sha ~head:subtree_sha () in
8888- if count > 0 then Subtree_ahead count
8989- else Trees_differ
9090- else
9191- Trees_differ (* Diverged *)
9292- | _ -> Trees_differ) (* Trees differ but can't determine ancestry *)
9393- | _ -> Unknown
9999+ let count =
100100+ Git.count_commits_between ~proc ~fs:fs_t ~repo:checkout_dir
101101+ ~base:checkout_sha ~head:subtree_sha ()
102102+ in
103103+ if count > 0 then Subtree_ahead count else Trees_differ
104104+ else Trees_differ (* Diverged *)
105105+ | _ -> Trees_differ
106106+ (* Trees differ but can't determine ancestry *))
107107+ | _ -> Unknown)
94108 in
95109 { package = pkg; checkout; subtree; subtree_sync }
96110···113127114128(** Needs remote action: checkout ahead/behind of upstream *)
115129let needs_remote_action t =
116116- match t.checkout with
117117- | Clean ab -> ab.ahead > 0 || ab.behind > 0
118118- | _ -> false
130130+ match t.checkout with Clean ab -> ab.ahead > 0 || ab.behind > 0 | _ -> false
119131120132let is_fully_synced t =
121133 match (t.checkout, t.subtree, t.subtree_sync) with
···128140 match t.checkout with
129141 | Missing | Not_a_repo | Dirty -> true
130142 | Clean ab ->
131131- ab.ahead > 0 || ab.behind > 0 ||
132132- t.subtree = Not_added ||
133133- needs_local_sync t)
143143+ ab.ahead > 0 || ab.behind > 0 || t.subtree = Not_added
144144+ || needs_local_sync t)
134145 statuses
135146136147let pp_checkout_status ppf = function
···149160 Fmt.pf ppf "@[<h>%-20s checkout: %a subtree: %a@]" (Package.name t.package)
150161 pp_checkout_status t.checkout pp_subtree_status t.subtree
151162163163+(** Extract handle from a tangled.org URL like "git+https://tangled.org/handle/repo" *)
164164+let extract_handle_from_url url =
165165+ let url = if String.starts_with ~prefix:"git+" url then
166166+ String.sub url 4 (String.length url - 4)
167167+ else url in
168168+ let uri = Uri.of_string url in
169169+ match Uri.host uri with
170170+ | Some "tangled.org" ->
171171+ let path = Uri.path uri in
172172+ (* Path is like "/handle/repo" - extract first component *)
173173+ let path = if String.length path > 0 && path.[0] = '/' then
174174+ String.sub path 1 (String.length path - 1)
175175+ else path in
176176+ (match String.index_opt path '/' with
177177+ | Some i -> Some (String.sub path 0 i)
178178+ | None -> Some path)
179179+ | _ -> None
180180+181181+(** Format origin indicator from sources registry entry *)
182182+let pp_origin_indicator ppf entry =
183183+ match entry with
184184+ | None -> ()
185185+ | Some Sources_registry.{ origin = Some Sources_registry.Fork; _ } ->
186186+ Fmt.pf ppf " %a" Fmt.(styled `Magenta string) "^"
187187+ | Some Sources_registry.{ origin = Some Sources_registry.Join; upstream = Some url; _ } ->
188188+ (match extract_handle_from_url url with
189189+ | Some handle ->
190190+ (* Abbreviate handle - take first part before dot, max 8 chars *)
191191+ let abbrev = match String.index_opt handle '.' with
192192+ | Some i -> String.sub handle 0 i
193193+ | None -> handle
194194+ in
195195+ let abbrev = if String.length abbrev > 8 then String.sub abbrev 0 8 else abbrev in
196196+ Fmt.pf ppf " %a" Fmt.(styled `Cyan (fun ppf s -> pf ppf "v:%s" s)) abbrev
197197+ | None -> Fmt.pf ppf " %a" Fmt.(styled `Cyan string) "v:")
198198+ | Some Sources_registry.{ origin = Some Sources_registry.Join; _ } ->
199199+ Fmt.pf ppf " %a" Fmt.(styled `Cyan string) "v:"
200200+ | Some _ -> ()
201201+152202(** Compact status for actionable items with colors *)
153153-let pp_compact ppf t =
203203+let pp_compact ?sources ppf t =
154204 let name = Package.name t.package in
205205+ let subtree = Package.subtree_prefix t.package in
206206+ let entry = match sources with
207207+ | Some s -> Sources_registry.find s ~subtree
208208+ | None -> None
209209+ in
155210 (* Helper to print remote sync info *)
156211 let pp_remote ab =
157212 if ab.Git.ahead > 0 && ab.behind > 0 then
158158- Fmt.pf ppf " %a" Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b)) (ab.ahead, ab.behind)
213213+ Fmt.pf ppf " %a"
214214+ Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b))
215215+ (ab.ahead, ab.behind)
159216 else if ab.ahead > 0 then
160160- Fmt.pf ppf " %a" Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n)) ab.ahead
217217+ Fmt.pf ppf " %a"
218218+ Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n))
219219+ ab.ahead
161220 else if ab.behind > 0 then
162162- Fmt.pf ppf " %a" Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n)) ab.behind
221221+ Fmt.pf ppf " %a"
222222+ Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n))
223223+ ab.behind
163224 in
164225 match (t.checkout, t.subtree, t.subtree_sync) with
165226 (* Local sync issues with count *)
166227 | Clean ab, Present, Subtree_behind n ->
167167- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Blue (fun ppf n -> pf ppf "local:-%d" n)) n;
168168- pp_remote ab
228228+ Fmt.pf ppf "%-22s %a" name
229229+ Fmt.(styled `Blue (fun ppf n -> pf ppf "local:-%d" n))
230230+ n;
231231+ pp_remote ab;
232232+ pp_origin_indicator ppf entry
169233 | Clean ab, Present, Subtree_ahead n ->
170170- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Blue (fun ppf n -> pf ppf "local:+%d" n)) n;
171171- pp_remote ab
234234+ Fmt.pf ppf "%-22s %a" name
235235+ Fmt.(styled `Blue (fun ppf n -> pf ppf "local:+%d" n))
236236+ n;
237237+ pp_remote ab;
238238+ pp_origin_indicator ppf entry
172239 (* Trees differ but can't determine count *)
173240 | Clean ab, Present, Trees_differ ->
174241 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Blue string) "local:sync";
175175- pp_remote ab
242242+ pp_remote ab;
243243+ pp_origin_indicator ppf entry
176244 (* Remote sync issues only *)
177245 | Clean ab, Present, (In_sync | Unknown) when ab.ahead > 0 && ab.behind > 0 ->
178178- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Yellow (fun ppf (a,b) -> pf ppf "remote:+%d/-%d" a b)) (ab.ahead, ab.behind)
246246+ Fmt.pf ppf "%-22s %a" name
247247+ Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b))
248248+ (ab.ahead, ab.behind);
249249+ pp_origin_indicator ppf entry
179250 | Clean ab, Present, (In_sync | Unknown) when ab.ahead > 0 ->
180180- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n)) ab.ahead
251251+ Fmt.pf ppf "%-22s %a" name
252252+ Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n))
253253+ ab.ahead;
254254+ pp_origin_indicator ppf entry
181255 | Clean ab, Present, (In_sync | Unknown) when ab.behind > 0 ->
182182- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n)) ab.behind
256256+ Fmt.pf ppf "%-22s %a" name
257257+ Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n))
258258+ ab.behind;
259259+ pp_origin_indicator ppf entry
183260 (* Other issues *)
184261 | Clean _, Not_added, _ ->
185185- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Magenta string) "(no subtree)"
262262+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Magenta string) "(no subtree)";
263263+ pp_origin_indicator ppf entry
186264 | Missing, _, _ ->
187187- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(no checkout)"
265265+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(no checkout)";
266266+ pp_origin_indicator ppf entry
188267 | Not_a_repo, _, _ ->
189189- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(not a repo)"
268268+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(not a repo)";
269269+ pp_origin_indicator ppf entry
190270 | Dirty, _, _ ->
191191- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Yellow string) "(dirty)"
271271+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Yellow string) "(dirty)";
272272+ pp_origin_indicator ppf entry
192273 | Clean _, Present, (In_sync | Unknown) ->
193193- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Green string) "ok"
274274+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Green string) "ok";
275275+ pp_origin_indicator ppf entry
194276195195-let pp_summary ppf statuses =
277277+let pp_summary ?sources ppf statuses =
196278 let total = List.length statuses in
197279 let actionable = filter_actionable statuses in
198280 let synced = List.filter is_fully_synced statuses |> List.length in
199281 let dirty = List.filter has_local_changes statuses |> List.length in
200200- let local_sync_needed = List.filter needs_local_sync statuses |> List.length in
282282+ let local_sync_needed =
283283+ List.filter needs_local_sync statuses |> List.length
284284+ in
201285 let remote_needed = List.filter needs_remote_action statuses |> List.length in
202286 let action_count = List.length actionable in
203287 (* Header line with colors *)
204288 if dirty > 0 then
205289 Fmt.pf ppf "%a %d total, %a synced, %a dirty\n"
206206- Fmt.(styled `Bold string) "Packages:" total
207207- Fmt.(styled `Green int) synced
208208- Fmt.(styled `Yellow int) dirty
290290+ Fmt.(styled `Bold string)
291291+ "Packages:" total
292292+ Fmt.(styled `Green int)
293293+ synced
294294+ Fmt.(styled `Yellow int)
295295+ dirty
209296 else if action_count > 0 then begin
210297 Fmt.pf ppf "%a %d total, %a synced"
211211- Fmt.(styled `Bold string) "Packages:" total
212212- Fmt.(styled `Green int) synced;
298298+ Fmt.(styled `Bold string)
299299+ "Packages:" total
300300+ Fmt.(styled `Green int)
301301+ synced;
213302 if local_sync_needed > 0 then
214303 Fmt.pf ppf ", %a local sync" Fmt.(styled `Blue int) local_sync_needed;
215304 if remote_needed > 0 then
···218307 end
219308 else
220309 Fmt.pf ppf "%a %d total, %a\n"
221221- Fmt.(styled `Bold string) "Packages:" total
222222- Fmt.(styled `Green string) "all synced";
310310+ Fmt.(styled `Bold string)
311311+ "Packages:" total
312312+ Fmt.(styled `Green string)
313313+ "all synced";
223314 (* Only show actionable items *)
224315 if actionable <> [] then
225225- List.iter (fun t -> Fmt.pf ppf " %a\n" pp_compact t) actionable
316316+ List.iter (fun t -> Fmt.pf ppf " %a\n" (pp_compact ?sources) t) actionable
+9-5
lib/status.mli
···1818 | Not_added (** Subtree has not been added to monorepo *)
1919 | Present (** Subtree exists in monorepo *)
20202121-(** Sync state between monorepo subtree and local checkout.
2222- This distinguishes issues fixable with [monopam sync] from those
2323- requiring network access. *)
2121+(** Sync state between monorepo subtree and local checkout. This distinguishes
2222+ issues fixable with [monopam sync] from those requiring network access. *)
2423type subtree_sync =
2524 | In_sync (** Subtree matches checkout HEAD *)
2625 | Subtree_behind of int
···113112val pp : t Fmt.t
114113(** [pp] formats a single package status. *)
115114116116-val pp_summary : t list Fmt.t
117117-(** [pp_summary] formats a summary of all package statuses. *)
115115+val pp_compact : ?sources:Sources_registry.t -> t Fmt.t
116116+(** [pp_compact ?sources] formats a single package status in compact form with colors.
117117+ If [sources] is provided, displays origin indicators (^ for fork, v:handle for join). *)
118118+119119+val pp_summary : ?sources:Sources_registry.t -> t list Fmt.t
120120+(** [pp_summary ?sources] formats a summary of all package statuses.
121121+ If [sources] is provided, displays origin indicators for each package. *)
+125-70
lib/verse.ml
···25252626let error_hint = function
2727 | Config_error _ ->
2828- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace."
2828+ Some
2929+ "Run 'monopam init --handle <your-handle>' to create a workspace."
2930 | Git_error (Git.Dirty_worktree _) ->
3031 Some "Commit or stash your changes first: git status"
3131- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git clone" cmd ->
3232+ | Git_error (Git.Command_failed (cmd, _))
3333+ when String.starts_with ~prefix:"git clone" cmd ->
3234 Some "Check the URL is correct and you have network access."
3333- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git pull" cmd ->
3535+ | Git_error (Git.Command_failed (cmd, _))
3636+ when String.starts_with ~prefix:"git pull" cmd ->
3437 Some "Check your network connection. Try: git fetch origin"
3538 | Git_error _ -> None
3639 | Registry_error _ ->
3740 Some "The registry may be temporarily unavailable. Try again later."
3841 | Member_not_found h ->
3939- Some (Fmt.str "Check available members: monopam verse members (looking for '%s')" h)
4242+ Some
4343+ (Fmt.str
4444+ "Check available members: monopam verse members (looking for '%s')" h)
4045 | Workspace_exists _ ->
4146 Some "Use a different directory, or remove the existing workspace."
4247 | Not_a_workspace _ ->
4343- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace here."
4848+ Some "Run 'monopam init --handle <your-handle>' to create a workspace here."
4449 | Package_not_found (pkg, handle) ->
4550 Some (Fmt.str "Run 'monopam verse pull %s' to sync their opam repo, then check package name: %s" handle pkg)
4651 | Package_already_exists pkgs ->
···84898590let pp_status ppf s =
8691 Fmt.pf ppf "@[<v>Workspace: %a@,Registry: %s@,Members:@, @[<v>%a@]@]"
8787- Fpath.pp (Verse_config.root s.config)
9292+ Fpath.pp
9393+ (Verse_config.root s.config)
8894 s.registry.name
8995 Fmt.(list ~sep:cut pp_member_status)
9096 s.tracked_members
···118124 let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in
119125 try
120126 Eio.Path.read_dir eio_path
121121- |> List.filter (fun name ->
122122- is_directory ~fs Fpath.(verse_path / name))
127127+ |> List.filter (fun name -> is_directory ~fs Fpath.(verse_path / name))
123128 with Eio.Io _ -> []
124129125130let init ~proc ~fs ~root ~handle () =
···141146 (* Ensure the directory exists first so realpath works *)
142147 (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ());
143148 match Unix.realpath root_str with
144144- | abs_str -> (match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
149149+ | abs_str -> (
150150+ match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
145151 | exception _ -> root
146152 in
147153 Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root);
···153159 | Error msg ->
154160 Logs.err (fun m -> m "Registry clone failed: %s" msg);
155161 Error (Registry_error msg)
156156- | Ok registry ->
162162+ | Ok registry -> (
157163 Logs.info (fun m -> m "Registry loaded");
158164 (* Look up user in registry - this validates the handle *)
159165 match Verse_registry.find_member registry ~handle with
160166 | None ->
161167 Logs.err (fun m -> m "Handle %s not found in registry" handle);
162168 Error (Member_not_found handle)
163163- | Some member ->
164164- Logs.info (fun m -> m "Found member: mono=%s opam=%s" member.monorepo member.opamrepo);
165165- (* Create workspace directories *)
166166- Logs.info (fun m -> m "Creating workspace directories...");
167167- ensure_dir ~fs root;
168168- ensure_dir ~fs (Verse_config.src_path config);
169169- ensure_dir ~fs (Verse_config.verse_path config);
170170- (* Clone user's monorepo *)
171171- let mono_path = Verse_config.mono_path config in
172172- Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
173173- let mono_url = Uri.of_string member.monorepo in
174174- (match Git.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch mono_path with
169169+ | Some member -> (
170170+ Logs.info (fun m ->
171171+ m "Found member: mono=%s opam=%s" member.monorepo
172172+ member.opamrepo);
173173+ (* Create workspace directories *)
174174+ Logs.info (fun m -> m "Creating workspace directories...");
175175+ ensure_dir ~fs root;
176176+ ensure_dir ~fs (Verse_config.src_path config);
177177+ ensure_dir ~fs (Verse_config.verse_path config);
178178+ (* Clone user's monorepo *)
179179+ let mono_path = Verse_config.mono_path config in
180180+ Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
181181+ let mono_url = Uri.of_string member.monorepo in
182182+ match
183183+ Git.clone ~proc ~fs ~url:mono_url
184184+ ~branch:Verse_config.default_branch mono_path
185185+ with
186186+ | Error e ->
187187+ Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
188188+ Error (Git_error e)
189189+ | Ok () -> (
190190+ Logs.info (fun m -> m "Monorepo cloned");
191191+ (* Clone user's opam repo *)
192192+ let opam_path = Verse_config.opam_repo_path config in
193193+ Logs.info (fun m ->
194194+ m "Cloning opam repo to %a" Fpath.pp opam_path);
195195+ let opam_url = Uri.of_string member.opamrepo in
196196+ match
197197+ Git.clone ~proc ~fs ~url:opam_url
198198+ ~branch:Verse_config.default_branch opam_path
199199+ with
175200 | Error e ->
176176- Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
201201+ Logs.err (fun m ->
202202+ m "Opam repo clone failed: %a" Git.pp_error e);
177203 Error (Git_error e)
178178- | Ok () ->
179179- Logs.info (fun m -> m "Monorepo cloned");
180180- (* Clone user's opam repo *)
181181- let opam_path = Verse_config.opam_repo_path config in
182182- Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path);
183183- let opam_url = Uri.of_string member.opamrepo in
184184- (match Git.clone ~proc ~fs ~url:opam_url ~branch:Verse_config.default_branch opam_path with
185185- | Error e ->
186186- Logs.err (fun m -> m "Opam repo clone failed: %a" Git.pp_error e);
187187- Error (Git_error e)
204204+ | Ok () -> (
205205+ Logs.info (fun m -> m "Opam repo cloned");
206206+ (* Save config to XDG *)
207207+ Logs.info (fun m ->
208208+ m "Saving config to %a" Fpath.pp config_file);
209209+ match Verse_config.save ~fs config with
210210+ | Error msg ->
211211+ Logs.err (fun m -> m "Failed to save config: %s" msg);
212212+ Error (Config_error msg)
188213 | Ok () ->
189189- Logs.info (fun m -> m "Opam repo cloned");
190190- (* Save config to XDG *)
191191- Logs.info (fun m -> m "Saving config to %a" Fpath.pp config_file);
192192- (match Verse_config.save ~fs config with
193193- | Error msg ->
194194- Logs.err (fun m -> m "Failed to save config: %s" msg);
195195- Error (Config_error msg)
196196- | Ok () ->
197197- Logs.info (fun m -> m "Workspace initialized successfully");
198198- Ok ())))
214214+ Logs.info (fun m ->
215215+ m "Workspace initialized successfully");
216216+ Ok ()))))
199217200218let status ~proc ~fs ~config () =
201219 (* Load registry *)
···212230 match Verse_registry.find_member registry ~handle with
213231 | None ->
214232 (* Member not in registry but locally tracked - show anyway *)
215215- let local_path = Fpath.(Verse_config.verse_path config / handle) in
233233+ let local_path =
234234+ Fpath.(Verse_config.verse_path config / handle)
235235+ in
216236 let cloned = is_directory ~fs local_path in
217237 Some
218238 {
···257277 | Error msg -> Error (Registry_error msg)
258278 | Ok registry -> Ok registry.members
259279260260-261280(** Clone or fetch+reset a single git repo. Returns Ok true if cloned, Ok false if reset.
262281 Uses fetch+reset instead of pull since verse repos should not have local changes. *)
263282let clone_or_reset_repo ~proc ~fs ~url ~branch path =
···278297 match Verse_registry.clone_or_pull ~proc ~fs ~config () with
279298 | Error msg -> Error (Registry_error msg)
280299 | Ok registry ->
281281- let members = match handle with
282282- | Some h ->
283283- (match Verse_registry.find_member registry ~handle:h with
284284- | Some m -> [m]
300300+ let members =
301301+ match handle with
302302+ | Some h -> (
303303+ match Verse_registry.find_member registry ~handle:h with
304304+ | Some m -> [ m ]
285305 | None -> [])
286306 | None -> registry.members
287307 in
···306326 clone_or_reset_repo ~proc ~fs ~url:member.monorepo
307327 ~branch:mono_branch mono_path
308328 in
309309- let mono_err = match mono_result with
310310- | Ok true -> Logs.info (fun m -> m " Cloned %s monorepo" h); None
311311- | Ok false -> Logs.info (fun m -> m " Reset %s monorepo" h); None
329329+ let mono_err =
330330+ match mono_result with
331331+ | Ok true ->
332332+ Logs.info (fun m -> m " Cloned %s monorepo" h);
333333+ None
334334+ | Ok false ->
335335+ Logs.info (fun m -> m " Reset %s monorepo" h);
336336+ None
312337 | Error e ->
313313- Logs.warn (fun m -> m " Failed %s monorepo: %a" h Git.pp_error e);
338338+ Logs.warn (fun m ->
339339+ m " Failed %s monorepo: %a" h Git.pp_error e);
314340 Some (Fmt.str "%s monorepo: %a" h Git.pp_error e)
315341 in
316342 (* Clone or fetch+reset opam repo *)
···322348 clone_or_reset_repo ~proc ~fs ~url:member.opamrepo
323349 ~branch:opam_branch opam_path
324350 in
325325- let opam_err = match opam_result with
326326- | Ok true -> Logs.info (fun m -> m " Cloned %s opam repo" h); None
327327- | Ok false -> Logs.info (fun m -> m " Reset %s opam repo" h); None
351351+ let opam_err =
352352+ match opam_result with
353353+ | Ok true ->
354354+ Logs.info (fun m -> m " Cloned %s opam repo" h);
355355+ None
356356+ | Ok false ->
357357+ Logs.info (fun m -> m " Reset %s opam repo" h);
358358+ None
328359 | Error e ->
329329- Logs.warn (fun m -> m " Failed %s opam repo: %a" h Git.pp_error e);
360360+ Logs.warn (fun m ->
361361+ m " Failed %s opam repo: %a" h Git.pp_error e);
330362 Some (Fmt.str "%s opam: %a" h Git.pp_error e)
331363 in
332364 match (mono_err, opam_err) with
···343375 (* pull already updates registry and syncs all members *)
344376 pull ~proc ~fs ~config ()
345377346346-(** Scan a monorepo for subtree directories.
347347- Returns a list of directory names that look like subtrees (have commits). *)
378378+(** Scan a monorepo for subtree directories. Returns a list of directory names
379379+ that look like subtrees (have commits). *)
348380let scan_subtrees ~proc ~fs monorepo_path =
349381 if not (Git.is_repo ~proc ~fs monorepo_path) then []
350382 else
···352384 try
353385 Eio.Path.read_dir eio_path
354386 |> List.filter (fun name ->
355355- (* Skip hidden dirs and common non-subtree dirs *)
356356- not (String.starts_with ~prefix:"." name)
357357- && name <> "_build"
358358- && name <> "node_modules"
359359- && is_directory ~fs Fpath.(monorepo_path / name))
387387+ (* Skip hidden dirs and common non-subtree dirs *)
388388+ (not (String.starts_with ~prefix:"." name))
389389+ && name <> "_build" && name <> "node_modules"
390390+ && is_directory ~fs Fpath.(monorepo_path / name))
360391 with Eio.Io _ -> []
361392362362-(** Get subtrees from all tracked verse members.
363363- Returns a map from subtree name to list of (handle, monorepo_path) pairs. *)
393393+(** Get subtrees from all tracked verse members. Returns a map from subtree name
394394+ to list of (handle, monorepo_path) pairs. *)
364395let get_verse_subtrees ~proc ~fs ~config () =
365396 let verse_path = Verse_config.verse_path config in
366397 let tracked_handles = get_tracked_handles ~fs config in
···376407 let existing =
377408 try Hashtbl.find subtree_map subtree with Not_found -> []
378409 in
379379- Hashtbl.replace subtree_map subtree ((handle, member_mono) :: existing))
410410+ Hashtbl.replace subtree_map subtree
411411+ ((handle, member_mono) :: existing))
380412 subtrees
381413 end)
382414 tracked_handles;
···387419 packages_forked : string list; (** Package names that were forked *)
388420 source_handle : string; (** Handle of the verse member we forked from *)
389421 fork_url : string; (** URL of the fork *)
422422+ upstream_url : string; (** Original dev-repo URL (upstream) *)
423423+ subtree_name : string; (** Name for the subtree directory (derived from fork URL) *)
390424}
391425426426+(** Extract subtree name from a URL (last path component without .git suffix) *)
427427+let subtree_name_from_url url =
428428+ let uri = Uri.of_string url in
429429+ let path = Uri.path uri in
430430+ (* Remove leading slash and .git suffix *)
431431+ let path = if String.length path > 0 && path.[0] = '/' then
432432+ String.sub path 1 (String.length path - 1)
433433+ else path in
434434+ let path = if String.ends_with ~suffix:".git" path then
435435+ String.sub path 0 (String.length path - 4)
436436+ else path in
437437+ (* Get last component *)
438438+ match String.rindex_opt path '/' with
439439+ | Some i -> String.sub path (i + 1) (String.length path - i - 1)
440440+ | None -> path
441441+392442let pp_fork_result ppf r =
393393- Fmt.pf ppf "@[<v>Forked %d package(s) from %s:@, @[<v>%a@]@,Fork URL: %s@]"
443443+ Fmt.pf ppf "@[<v>Forked %d package(s) from %s:@, @[<v>%a@]@,Fork URL: %s@,Upstream: %s@,Subtree: %s@]"
394444 (List.length r.packages_forked)
395445 r.source_handle
396446 Fmt.(list ~sep:cut string) r.packages_forked
397447 r.fork_url
448448+ r.upstream_url
449449+ r.subtree_name
398450399451(** Fork a package from a verse member's opam repo into your workspace.
400452···434486 List.filter (fun p -> Package.same_repo p pkg) pkgs
435487 in
436488 let pkg_names = List.map Package.name related_pkgs in
489489+ (* Get upstream URL and subtree name *)
490490+ let upstream_url = Uri.to_string (Package.dev_repo pkg) in
491491+ let subtree_name = subtree_name_from_url fork_url in
437492 (* Check for conflicts in user's opam-repo *)
438493 let user_opam_repo = Verse_config.opam_repo_path config in
439494 let conflicts =
···445500 Error (Package_already_exists conflicts)
446501 else if dry_run then
447502 (* Dry run - just report what would be done *)
448448- Ok { packages_forked = pkg_names; source_handle = handle; fork_url }
503503+ Ok { packages_forked = pkg_names; source_handle = handle; fork_url; upstream_url; subtree_name }
449504 else begin
450505 (* Fork each package *)
451506 let results =
···472527 | Some (Error e) -> Error e
473528 | _ ->
474529 let forked_names = List.filter_map (function Ok n -> Some n | Error _ -> None) results in
475475- Ok { packages_forked = forked_names; source_handle = handle; fork_url }
530530+ Ok { packages_forked = forked_names; source_handle = handle; fork_url; upstream_url; subtree_name }
476531 end
+12-7
lib/verse.mli
···11(** Monoverse operations.
2233- Federated monorepo collaboration. Members are identified by handles
44- and validated against the registry. *)
33+ Federated monorepo collaboration. Members are identified by handles and
44+ validated against the registry. *)
5566(** {1 Error Types} *)
77···2020(** [pp_error] formats errors. *)
21212222val pp_error_with_hint : error Fmt.t
2323-(** [pp_error_with_hint] formats errors with a helpful hint for resolving them. *)
2323+(** [pp_error_with_hint] formats errors with a helpful hint for resolving them.
2424+*)
24252526val error_hint : error -> string option
2627(** [error_hint e] returns a hint string for the given error, if available. *)
···3334 local_path : Fpath.t; (** Local path under verse/ *)
3435 cloned : bool; (** Whether the monorepo is cloned locally *)
3536 clean : bool option; (** Whether the clone is clean (None if not cloned) *)
3636- ahead_behind : Git.ahead_behind option; (** Ahead/behind status (None if not cloned) *)
3737+ ahead_behind : Git.ahead_behind option;
3838+ (** Ahead/behind status (None if not cloned) *)
3739}
3840(** Status of a member's monorepo in the workspace. *)
3941···104106 (unit, error) result
105107(** [pull ~proc ~fs ~config ?handle ()] syncs all registry members.
106108107107- For each member in the registry, clones or pulls both their monorepo
108108- (to [verse/<handle>/]) and their opam repo (to [verse/<handle>-opam/]).
109109+ For each member in the registry, clones or pulls both their monorepo (to
110110+ [verse/<handle>/]) and their opam repo (to [verse/<handle>-opam/]).
109111110112 If [handle] is specified, only syncs that specific member.
111113···140142 unit ->
141143 (string, (string * Fpath.t) list) Hashtbl.t
142144(** [get_verse_subtrees ~proc ~fs ~config ()] scans all tracked verse members
143143- and returns a map from subtree name to list of (handle, monorepo_path) pairs.
145145+ and returns a map from subtree name to list of (handle, monorepo_path)
146146+ pairs.
144147145148 This allows finding which verse users have a particular repo. *)
146149···151154 packages_forked : string list; (** Package names that were forked *)
152155 source_handle : string; (** Handle of the verse member we forked from *)
153156 fork_url : string; (** URL of the fork *)
157157+ upstream_url : string; (** Original dev-repo URL (upstream) *)
158158+ subtree_name : string; (** Name for the subtree directory (derived from fork URL) *)
154159}
155160156161val pp_fork_result : fork_result Fmt.t
+6-116
lib/verse_config.ml
···11-let app_name = "monopam"
22-33-(* Simplified config: just root and handle. Paths are hardcoded. *)
44-type t = {
55- root : Fpath.t;
66- handle : string;
77-}
88-99-let root t = t.root
1010-let handle t = t.handle
1111-1212-(* Hardcoded paths derived from root *)
1313-let default_branch = "main"
1414-let mono_path t = Fpath.(t.root / "mono")
1515-let src_path t = Fpath.(t.root / "src")
1616-let opam_repo_path t = Fpath.(t.root / "opam-repo")
1717-let verse_path t = Fpath.(t.root / "verse")
1818-1919-(* Compute XDG directories following XDG Base Directory Specification *)
2020-let xdg_config_home () =
2121- match Sys.getenv_opt "XDG_CONFIG_HOME" with
2222- | Some dir when dir <> "" -> Fpath.v dir
2323- | _ ->
2424- match Sys.getenv_opt "HOME" with
2525- | Some home -> Fpath.(v home / ".config")
2626- | None -> Fpath.v "/tmp"
2727-2828-let xdg_data_home () =
2929- match Sys.getenv_opt "XDG_DATA_HOME" with
3030- | Some dir when dir <> "" -> Fpath.v dir
3131- | _ ->
3232- match Sys.getenv_opt "HOME" with
3333- | Some home -> Fpath.(v home / ".local" / "share")
3434- | None -> Fpath.v "/tmp"
3535-3636-let config_dir () = Fpath.(xdg_config_home () / app_name)
3737-let data_dir () = Fpath.(xdg_data_home () / app_name)
3838-let config_file () = Fpath.(config_dir () / "opamverse.toml")
3939-let registry_path () = Fpath.(data_dir () / "opamverse-registry")
4040-4141-let create ~root ~handle () = { root; handle }
4242-4343-let expand_tilde s =
4444- if String.length s > 0 && s.[0] = '~' then
4545- match Sys.getenv_opt "HOME" with
4646- | Some home ->
4747- if String.length s = 1 then home
4848- else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1)
4949- else s
5050- | None -> s
5151- else s
5252-5353-let fpath_codec : Fpath.t Tomlt.t =
5454- Tomlt.map
5555- ~dec:(fun s ->
5656- let s = expand_tilde s in
5757- match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m)
5858- ~enc:Fpath.to_string Tomlt.string
5959-6060-(* Simplified TOML structure:
6161- [workspace]
6262- root = "~/tangled"
11+(** Verse_config is now an alias for Config.
6326464- [identity]
6565- handle = "anil.recoil.org"
6666-*)
33+ This module is kept for backwards compatibility.
44+ All functionality has been unified into Config. *)
6756868-type workspace_section = { w_root : Fpath.t }
6969-type identity_section = { i_handle : string }
66+include Config
7077171-let workspace_codec : workspace_section Tomlt.t =
7272- Tomlt.(
7373- Table.(
7474- obj (fun w_root -> { w_root })
7575- |> mem "root" fpath_codec ~enc:(fun w -> w.w_root)
7676- |> finish))
7777-7878-let identity_codec : identity_section Tomlt.t =
7979- Tomlt.(
8080- Table.(
8181- obj (fun i_handle -> { i_handle })
8282- |> mem "handle" string ~enc:(fun i -> i.i_handle)
8383- |> finish))
8484-8585-let codec : t Tomlt.t =
8686- Tomlt.(
8787- Table.(
8888- obj (fun workspace identity ->
8989- { root = workspace.w_root; handle = identity.i_handle })
9090- |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root })
9191- |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle })
9292- |> finish))
9393-9494-let load ~fs () =
9595- let path = config_file () in
9696- let path_str = Fpath.to_string path in
9797- try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str)
9898- with
9999- | Eio.Io _ as e -> Error (Printexc.to_string e)
100100- | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
101101-102102-let save ~fs t =
103103- let dir = config_dir () in
104104- let path = config_file () in
105105- try
106106- (* Ensure XDG config directory exists *)
107107- let dir_path = Eio.Path.(fs / Fpath.to_string dir) in
108108- (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ());
109109- Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path);
110110- Ok ()
111111- with Eio.Io _ as e -> Error (Printexc.to_string e)
112112-113113-let pp ppf t =
114114- Fmt.pf ppf
115115- "@[<v>workspace:@,\
116116- \ root: %a@,\
117117- identity:@,\
118118- \ handle: %s@]"
119119- Fpath.pp t.root t.handle
88+(** Legacy type alias for package overrides *)
99+type package_override = Config.Package_config.t
+8-79
lib/verse_config.mli
···11-(** Opamverse workspace configuration.
22-33- Configuration is stored in the XDG config directory at
44- [~/.config/monopam/opamverse.toml].
55-66- The config stores just the workspace root and user's handle.
77- All paths are derived from the root:
88- - [mono/] - user's monorepo
99- - [src/] - git checkouts for subtrees
1010- - [opam-repo/] - opam overlay repository
1111- - [verse/] - other members' monorepos *)
1212-1313-(** {1 Types} *)
1414-1515-type t
1616-(** Opamverse workspace configuration. *)
1717-1818-(** {1 Accessors} *)
1919-2020-val root : t -> Fpath.t
2121-(** [root t] returns the workspace root directory. *)
2222-2323-val handle : t -> string
2424-(** [handle t] returns the user's tangled handle. *)
2525-2626-(** {1 Derived Paths} *)
11+(** Verse_config is now an alias for Config.
2722828-val default_branch : string
2929-(** Default git branch, always ["main"]. *)
3030-3131-val mono_path : t -> Fpath.t
3232-(** [mono_path t] returns the path to the user's monorepo ([root/mono/]). *)
33+ This module is kept for backwards compatibility.
44+ All functionality has been unified into Config.
3353434-val src_path : t -> Fpath.t
3535-(** [src_path t] returns the path to git checkouts ([root/src/]). *)
66+ @deprecated Use {!Config} directly. *)
3673737-val opam_repo_path : t -> Fpath.t
3838-(** [opam_repo_path t] returns the path to the opam overlay ([root/opam-repo/]). *)
88+include module type of Config
3994040-val verse_path : t -> Fpath.t
4141-(** [verse_path t] returns the path to tracked members' monorepos ([root/verse/]). *)
4242-4343-(** {1 XDG Paths} *)
4444-4545-val config_dir : unit -> Fpath.t
4646-(** [config_dir ()] returns the XDG config directory for monopam
4747- (~/.config/monopam). *)
4848-4949-val data_dir : unit -> Fpath.t
5050-(** [data_dir ()] returns the XDG data directory for monopam
5151- (~/.local/share/monopam). *)
5252-5353-val config_file : unit -> Fpath.t
5454-(** [config_file ()] returns the path to the opamverse config file
5555- (~/.config/monopam/opamverse.toml). *)
5656-5757-val registry_path : unit -> Fpath.t
5858-(** [registry_path ()] returns the path to the cloned registry git repo
5959- (~/.local/share/monopam/opamverse-registry). *)
6060-6161-(** {1 Loading and Saving} *)
6262-6363-val load : fs:Eio.Fs.dir_ty Eio.Path.t -> unit -> (t, string) result
6464-(** [load ~fs ()] loads the workspace configuration from the XDG config file.
6565-6666- @param fs Eio filesystem *)
6767-6868-val save : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> (unit, string) result
6969-(** [save ~fs config] saves the configuration to the XDG config file.
7070-7171- @param fs Eio filesystem
7272- @param config Configuration to save *)
7373-7474-val create : root:Fpath.t -> handle:string -> unit -> t
7575-(** [create ~root ~handle ()] creates a new configuration.
7676-7777- @param root Workspace root directory (absolute path)
7878- @param handle User's tangled handle *)
7979-8080-(** {1 Pretty Printing} *)
8181-8282-val pp : t Fmt.t
8383-(** [pp] formats a workspace configuration. *)
1010+(** Legacy type alias for package overrides.
1111+ @deprecated Use {!Config.Package_config.t} instead. *)
1212+type package_override = Config.Package_config.t
+34-25
lib/verse_registry.ml
···11type member = {
22 handle : string;
33+ name : string option;
34 monorepo : string;
45 monorepo_branch : string option;
56 opamrepo : string;
67 opamrepo_branch : string option;
78}
88-type t = { name : string; members : member list }
99+type t = { name : string; description : string option; members : member list }
9101011let default_url = "https://tangled.org/eeg.cl.cam.ac.uk/opamverse"
1112···2728let pp_member ppf m =
2829 let mono_str = encode_url_with_branch m.monorepo m.monorepo_branch in
2930 let opam_str = encode_url_with_branch m.opamrepo m.opamrepo_branch in
3030- Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle mono_str opam_str
3131+ let name_str = match m.name with Some n -> n | None -> m.handle in
3232+ Fmt.pf ppf "@[<hov 2>%s (%s) ->@ mono:%s@ opam:%s@]" name_str m.handle mono_str opam_str
31333234let pp ppf t =
3333- Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]"
3434- t.name Fmt.(list ~sep:cut pp_member) t.members
3535+ Fmt.pf ppf "@[<v>registry: %s%a@,members:@, @[<v>%a@]@]" t.name
3636+ Fmt.(option (fun ppf s -> pf ppf "@,description: %s" s)) t.description
3737+ Fmt.(list ~sep:cut pp_member)
3838+ t.members
35393640(* TOML structure:
3741 [registry]
···4650let member_codec : member Tomlt.t =
4751 Tomlt.(
4852 Table.(
4949- obj (fun handle monorepo_raw opamrepo_raw ->
5353+ obj (fun handle name monorepo_raw opamrepo_raw ->
5054 let monorepo, monorepo_branch = parse_url_with_branch monorepo_raw in
5155 let opamrepo, opamrepo_branch = parse_url_with_branch opamrepo_raw in
5252- { handle; monorepo; monorepo_branch; opamrepo; opamrepo_branch })
5353- |> mem "handle" string ~enc:(fun m -> m.handle)
5454- |> mem "monorepo" string ~enc:(fun m -> encode_url_with_branch m.monorepo m.monorepo_branch)
5555- |> mem "opamrepo" string ~enc:(fun m -> encode_url_with_branch m.opamrepo m.opamrepo_branch)
5656+ { handle; name; monorepo; monorepo_branch; opamrepo; opamrepo_branch })
5757+ |> mem "handle" string ~enc:(fun (m : member) -> m.handle)
5858+ |> opt_mem "name" string ~enc:(fun (m : member) -> m.name)
5959+ |> mem "monorepo" string ~enc:(fun (m : member) -> encode_url_with_branch m.monorepo m.monorepo_branch)
6060+ |> mem "opamrepo" string ~enc:(fun (m : member) -> encode_url_with_branch m.opamrepo m.opamrepo_branch)
5661 |> finish))
57625858-type registry_info = { r_name : string }
6363+type registry_info = { r_name : string; r_description : string option }
59646065let registry_info_codec : registry_info Tomlt.t =
6166 Tomlt.(
6267 Table.(
6363- obj (fun r_name -> { r_name })
6868+ obj (fun r_name r_description -> { r_name; r_description })
6469 |> mem "name" string ~enc:(fun r -> r.r_name)
7070+ |> opt_mem "description" string ~enc:(fun r -> r.r_description)
6571 |> finish))
66726773let codec : t Tomlt.t =
6874 Tomlt.(
6975 Table.(
7076 obj (fun registry members ->
7171- { name = registry.r_name; members = Option.value ~default:[] members })
7272- |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name })
7777+ { name = registry.r_name; description = registry.r_description; members = Option.value ~default:[] members })
7878+ |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name; r_description = t.description })
7379 |> opt_mem "members" (list member_codec) ~enc:(fun t ->
7474- match t.members with [] -> None | ms -> Some ms)
8080+ match t.members with [] -> None | ms -> Some ms)
7581 |> finish))
76827777-let empty_registry = { name = "opamverse"; members = [] }
8383+let empty_registry = { name = "opamverse"; description = None; members = [] }
78847985let load ~fs path =
8086 let path_str = Fpath.to_string path in
8187 Logs.info (fun m -> m "Loading registry from path: %s" path_str);
8288 try
8389 let registry = Tomlt_eio.decode_path_exn codec ~fs path_str in
8484- Logs.info (fun m -> m "Registry loaded: %d members" (List.length registry.members));
9090+ Logs.info (fun m ->
9191+ m "Registry loaded: %d members" (List.length registry.members));
8592 Ok registry
8693 with
8794 | Eio.Io _ as e ->
···9198 Logs.err (fun m -> m "Registry parse error: %s" msg);
9299 Error (Fmt.str "Invalid registry: %s" msg)
93100 | exn ->
9494- Logs.err (fun m -> m "Unexpected registry error: %s" (Printexc.to_string exn));
101101+ Logs.err (fun m ->
102102+ m "Unexpected registry error: %s" (Printexc.to_string exn));
95103 Error (Fmt.str "Registry error: %s" (Printexc.to_string exn))
9610497105let save ~fs path registry =
···117125 Logs.info (fun m -> m "Registry exists, pulling updates...");
118126 (* Pull updates, but don't fail if pull fails *)
119127 (match Git.pull ~proc ~fs registry_path with
120120- | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
121121- | Error e -> Logs.warn (fun m -> m "Registry pull failed: %a (using cached)" Git.pp_error e));
128128+ | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
129129+ | Error e ->
130130+ Logs.warn (fun m ->
131131+ m "Registry pull failed: %a (using cached)" Git.pp_error e));
122132 Logs.info (fun m -> m "Loading registry from %a" Fpath.pp registry_toml);
123133 load ~fs registry_toml
124134 end
···143153 (try Eio.Path.mkdirs ~perm:0o755 registry_eio with Eio.Io _ -> ());
144154 (* Initialize as git repo *)
145155 (match Git.init ~proc ~fs registry_path with
146146- | Ok () -> ()
147147- | Error _ -> ());
156156+ | Ok () -> ()
157157+ | Error _ -> ());
148158 (* Create empty registry file *)
149159 (match save ~fs registry_toml empty_registry with
150150- | Ok () -> ()
151151- | Error _ -> ());
160160+ | Ok () -> ()
161161+ | Error _ -> ());
152162 Ok empty_registry
153163 end
154164155155-let find_member t ~handle =
156156- List.find_opt (fun m -> m.handle = handle) t.members
165165+let find_member t ~handle = List.find_opt (fun m -> m.handle = handle) t.members
157166158167let find_members t ~handles =
159168 List.filter (fun m -> List.mem m.handle handles) t.members
+4-2
lib/verse_registry.mli
···7788type member = {
99 handle : string; (** Tangled handle (e.g., "alice.bsky.social") *)
1010+ name : string option; (** Display name (e.g., "Alice Smith") *)
1011 monorepo : string; (** Git URL of the member's monorepo *)
1112 monorepo_branch : string option; (** Optional branch for monorepo (from URL#branch) *)
1213 opamrepo : string; (** Git URL of the member's opam overlay repository *)
···19202021type t = {
2122 name : string; (** Registry name *)
2323+ description : string option; (** Registry description *)
2224 members : member list; (** List of registered members *)
2325}
2426(** The parsed registry contents. *)
···3436 config:Verse_config.t ->
3537 unit ->
3638 (t, string) result
3737-(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present,
3838- or pulls updates if it exists. Returns the parsed registry contents.
3939+(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present, or
4040+ pulls updates if it exists. Returns the parsed registry contents.
39414042 The registry is cloned to [config.registry_path].
4143