···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
···3135 let checkouts = Monopam.Verse_config.src_path verse_config in
3236 let monorepo = Monopam.Verse_config.mono_path verse_config in
3337 let default_branch = Monopam.Verse_config.default_branch in
3434- Ok (Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch ())
3838+ Ok
3939+ (Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch
4040+ ())
35413642let with_config env f =
3743 match load_config env with
···5763 `I ("remote:", "Sync between your checkout (src/) and upstream git remote");
5864 `S "LOCAL SYNC INDICATORS";
5965 `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))");
6666+ `I
6767+ ( "local:+N",
6868+ "Monorepo has N commits not yet in checkout (run $(b,monopam sync))"
6969+ );
7070+ `I
7171+ ( "local:-N",
7272+ "Checkout has N commits not yet in monorepo (run $(b,monopam sync))"
7373+ );
6274 `I ("local:sync", "Trees differ, needs sync (run $(b,monopam sync))");
6375 `S "REMOTE SYNC INDICATORS";
6476 `I ("remote:=", "Checkout and upstream remote are in sync");
6565- `I ("remote:+N", "Checkout has N commits to push (run $(b,monopam sync --remote))");
7777+ `I
7878+ ( "remote:+N",
7979+ "Checkout has N commits to push (run $(b,monopam sync --remote))" );
6680 `I ("remote:-N", "Upstream has N commits to pull (run $(b,monopam sync))");
6781 `I ("remote:+N/-M", "Diverged: checkout +N ahead, upstream +M ahead");
6882 `S "FORK ANALYSIS";
···94108 (* Check for unregistered opam files *)
95109 (match Monopam.discover_packages ~fs ~config () with
96110 | Ok pkgs ->
9797- let unregistered = Monopam.find_unregistered_opam_files ~fs ~config pkgs in
111111+ let unregistered =
112112+ Monopam.find_unregistered_opam_files ~fs ~config pkgs
113113+ in
98114 if unregistered <> [] then begin
99115 (* Get local handle abbreviation *)
100100- let handle_abbrev = match Monopam.Verse_config.load ~fs () with
101101- | Ok vc ->
116116+ let handle_abbrev =
117117+ match Monopam.Verse_config.load ~fs () with
118118+ | Ok vc -> (
102119 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)
120120+ match String.split_on_char '.' h with
121121+ | first :: _ ->
122122+ if String.length first <= 4 then first
123123+ else String.sub first 0 3
124124+ | [] -> h)
106125 | Error _ -> "local"
107126 in
108127 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)
128128+ Fmt.(styled `Bold string)
129129+ "Unregistered:"
130130+ Fmt.(styled `Faint int)
131131+ (List.length unregistered);
132132+ List.iter
133133+ (fun (_r, p) ->
134134+ Fmt.pr " %-22s %a\n" p
135135+ Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
136136+ handle_abbrev)
113137 unregistered
114138 end
115139 | Error _ -> ());
···118142 | Error _ -> ()
119143 | Ok verse_config ->
120144 let forks =
121121- Monopam.Forks.compute ~proc ~fs ~verse_config ~monopam_config:config ()
145145+ Monopam.Forks.compute ~proc ~fs ~verse_config
146146+ ~monopam_config:config ()
122147 in
123148 if forks.repos <> [] then
124149 Fmt.pr "%a" (Monopam.Forks.pp_summary' ~show_all) forks);
···160185 `S "PHASES";
161186 `P "The sync command executes these phases in order:";
162187 `I ("1. Validate", "Abort if the monorepo has uncommitted changes");
163163- `I ("2. Push", "Export monorepo changes to checkouts (parallel) [--skip-push skips]");
188188+ `I
189189+ ( "2. Push",
190190+ "Export monorepo changes to checkouts (parallel) [--skip-push skips]"
191191+ );
164192 `I ("3. Fetch", "Clone/fetch from remotes (parallel) [--skip-pull skips]");
165193 `I ("4. Merge", "Fast-forward merge checkouts [--skip-pull skips]");
166194 `I ("5. Subtree", "Pull subtrees into monorepo [--skip-pull skips]");
167195 `I ("6. Finalize", "Update README.md, CLAUDE.md, and dune-project");
168196 `I ("7. Remote", "Push to upstream remotes if --remote (parallel)");
169197 `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.");
198198+ `I
199199+ ( "--skip-push",
200200+ "Skip exporting monorepo changes to checkouts. Use when you know you \
201201+ have no local changes to export." );
202202+ `I
203203+ ( "--skip-pull",
204204+ "Skip fetching and pulling from remotes. Use when you only want to \
205205+ export local changes without pulling remote updates." );
174206 `S "PREREQUISITES";
175207 `P "Before running sync:";
176176- `I ("-", "Commit all changes in the monorepo: $(b,git add -A && git commit)");
208208+ `I
209209+ ( "-",
210210+ "Commit all changes in the monorepo: $(b,git add -A && git commit)" );
177211 `I ("-", "For --remote: ensure git credentials/SSH keys are configured");
178212 ]
179213 in
···197231 with_config env @@ fun config ->
198232 let fs = Eio.Stdenv.fs env in
199233 let proc = Eio.Stdenv.process_mgr env in
200200- match Monopam.sync ~proc ~fs ~config ?package ~remote ~skip_push ~skip_pull () with
234234+ match
235235+ Monopam.sync ~proc ~fs ~config ?package ~remote ~skip_push ~skip_pull ()
236236+ with
201237 | Ok summary ->
202202- if summary.errors = [] then
203203- `Ok ()
238238+ if summary.errors = [] then `Ok ()
204239 else begin
205205- Fmt.epr "Sync completed with %d errors.@." (List.length summary.errors);
240240+ Fmt.epr "Sync completed with %d errors.@."
241241+ (List.length summary.errors);
206242 `Ok ()
207243 end
208244 | Error e ->
···210246 `Error (false, "sync failed")
211247 in
212248 Cmd.v info
213213- Term.(ret (const run $ package_arg $ remote_arg $ skip_push_arg $ skip_pull_arg $ logging_term))
249249+ Term.(
250250+ ret
251251+ (const run $ package_arg $ remote_arg $ skip_push_arg $ skip_pull_arg
252252+ $ logging_term))
214253215254(* Changes command *)
216255···223262 `P
224263 "By default, generates weekly entries. Use --daily to generate daily \
225264 entries instead.";
226226- `P
227227- "Changes are stored in the .changes directory at the monorepo root:";
265265+ `P "Changes are stored in the .changes directory at the monorepo root:";
228266 `I (".changes/<repo>.json", "Weekly changelog entries");
229267 `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:";
268268+ `I
269269+ ( ".changes/YYYYMMDD.json",
270270+ "Aggregated daily entries (default with --daily)" );
271271+ `P "Also generates aggregated markdown files at the monorepo root:";
233272 `I ("CHANGES.md", "Aggregated weekly changelog");
234273 `I ("DAILY-CHANGES.md", "Aggregated daily changelog");
235274 `P "Each entry includes:";
···245284 (empty summary and changes) rather than 'no changes' text.";
246285 `P
247286 "When using --daily, an aggregated JSON file is generated by default \
248248- for the poe Zulip bot broadcasting system. Use --no-aggregate to skip.";
287287+ for the poe Zulip bot broadcasting system. Use --no-aggregate to \
288288+ skip.";
249289 `P
250290 "If a per-repo-per-day JSON file already exists for a past day, that \
251291 repo is skipped for that day to avoid redundant Claude API calls.";
···257297 Arg.(value & flag & info [ "daily"; "d" ] ~doc)
258298 in
259299 let weeks =
260260- let doc = "Number of past weeks to analyze (default: 1, current week only). Ignored if --daily is set." in
300300+ let doc =
301301+ "Number of past weeks to analyze (default: 1, current week only). \
302302+ Ignored if --daily is set."
303303+ in
261304 Arg.(value & opt int 1 & info [ "w"; "weeks" ] ~doc)
262305 in
263306 let days =
264264- let doc = "Number of past days to analyze when using --daily (default: 1, today only)" in
307307+ let doc =
308308+ "Number of past days to analyze when using --daily (default: 1, today \
309309+ only)"
310310+ in
265311 Arg.(value & opt int 1 & info [ "days" ] ~doc)
266312 in
267313 let history =
268268- let doc = "Number of recent entries to include in aggregated markdown (default: 12 for weekly, 30 for daily)" in
314314+ let doc =
315315+ "Number of recent entries to include in aggregated markdown (default: 12 \
316316+ for weekly, 30 for daily)"
317317+ in
269318 Arg.(value & opt int 12 & info [ "history" ] ~doc)
270319 in
271320 let dry_run =
···273322 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
274323 in
275324 let no_aggregate =
276276- let doc = "Skip generating .changes/YYYYMMDD.json aggregated file (--daily generates it by default)" in
325325+ let doc =
326326+ "Skip generating .changes/YYYYMMDD.json aggregated file (--daily \
327327+ generates it by default)"
328328+ in
277329 Arg.(value & flag & info [ "no-aggregate" ] ~doc)
278330 in
279331 let run package daily weeks days history dry_run no_aggregate () =
···288340 let history = if history = 12 then 30 else history in
289341 (* Aggregate by default for daily, unless --no-aggregate is passed *)
290342 let aggregate = not no_aggregate in
291291- Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history ~dry_run ~aggregate ()
343343+ Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history
344344+ ~dry_run ~aggregate ()
292345 end
293346 else
294294- Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history ~dry_run ()
347347+ Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history
348348+ ~dry_run ()
295349 in
296350 match result with
297351 | Ok () ->
···318372 `S Manpage.s_description;
319373 `P
320374 "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.";
375375+ This ensures your opam overlay reflects any changes you made to .opam \
376376+ files in the monorepo.";
323377 `S "HOW IT WORKS";
324378 `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)");
379379+ `I
380380+ ( "1.",
381381+ "Reads the .opam file from the monorepo subtree (e.g., \
382382+ mono/eio/eio.opam)" );
383383+ `I
384384+ ( "2.",
385385+ "Compares with the opam-repo version (e.g., \
386386+ opam-repo/packages/eio/eio.dev/opam)" );
327387 `I ("3.", "If different, copies monorepo → opam-repo");
328388 `I ("4.", "Stages and commits changes in opam-repo");
329389 `S "PRECEDENCE";
···343403 let proc = Eio.Stdenv.process_mgr env in
344404 match Monopam.sync_opam_files ~proc ~fs ~config ?package () with
345405 | 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);
406406+ if result.synced = [] then Fmt.pr "All opam files already in sync.@."
407407+ else Fmt.pr "Synced %d opam files.@." (List.length result.synced);
350408 `Ok ()
351409 | Error e ->
352410 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
···382440 `Error (false, "configuration error")
383441384442let verse_root_arg =
385385- let doc = "Path to workspace root directory. Defaults to current directory." in
443443+ let doc =
444444+ "Path to workspace root directory. Defaults to current directory."
445445+ in
386446 Arg.(
387447 value
388448 & opt (some (conv (Fpath.of_string, Fpath.pp))) None
···390450391451let verse_handle_arg =
392452 let doc = "Tangled handle (e.g., alice.bsky.social)" in
393393- Arg.(required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
453453+ Arg.(
454454+ required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
394455395456let verse_handle_opt_pos_arg =
396396- let doc = "Tangled handle. If not specified, operates on all tracked members." in
457457+ let doc =
458458+ "Tangled handle. If not specified, operates on all tracked members."
459459+ in
397460 Arg.(value & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
398461399462let verse_init_cmd =
···402465 [
403466 `S Manpage.s_description;
404467 `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.";
468468+ "Creates a new opamverse workspace for federated monorepo \
469469+ collaboration. An opamverse workspace lets you browse and track other \
470470+ developers' monorepos alongside your own.";
408471 `S "WORKSPACE STRUCTURE";
409409- `P "The init command creates the following directory structure at the workspace root:";
472472+ `P
473473+ "The init command creates the following directory structure at the \
474474+ workspace root:";
410475 `I ("mono/", "Your monorepo - use with standard monopam commands");
411476 `I ("src/", "Your source checkouts - individual git repos");
412477 `I ("verse/", "Other users' monorepos, organized by handle");
413478 `P "Configuration and data are stored in XDG directories:";
414479 `I ("~/.config/monopam/opamverse.toml", "Workspace configuration");
415415- `I ("~/.local/share/monopam/opamverse-registry/", "Git clone of the community registry");
480480+ `I
481481+ ( "~/.local/share/monopam/opamverse-registry/",
482482+ "Git clone of the community registry" );
416483 `S "CONFIGURATION FILE";
417484 `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\"";
485485+ `Pre
486486+ "[workspace]\n\
487487+ root = \"/path/to/workspace\"\n\
488488+ default_branch = \"main\"\n\n\
489489+ [paths]\n\
490490+ mono = \"mono\"\n\
491491+ src = \"src\"\n\
492492+ verse = \"verse\"\n\n\
493493+ [identity]\n\
494494+ handle = \"yourname.bsky.social\"";
427495 `S "AUTHENTICATION";
428428- `P
429429- "Before running init, you must authenticate with the tangled network:";
496496+ `P "Before running init, you must authenticate with the tangled network:";
430497 `Pre "tangled auth login";
431498 `P
432499 "The handle you provide is validated against the AT Protocol identity \
433500 system to ensure it exists and you are authenticated.";
434501 `S "REGISTRY";
435502 `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 \
438438- registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
503503+ "The opamverse registry is a git repository containing an \
504504+ opamverse.toml file that lists community members and their monorepo \
505505+ URLs. The default registry is at: \
506506+ https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
439507 `S Manpage.s_examples;
440508 `P "Initialize a workspace in ~/tangled:";
441441- `Pre "cd ~/tangled\n\
442442- monopam verse init --handle alice.bsky.social";
509509+ `Pre "cd ~/tangled\nmonopam verse init --handle alice.bsky.social";
443510 `P "Initialize with explicit root path:";
444511 `Pre "monopam verse init --root ~/my-workspace --handle alice.bsky.social";
445512 ]
···452519 let root =
453520 match root with
454521 | Some r -> r
455455- | None ->
522522+ | None -> (
456523 let cwd_path = Eio.Stdenv.cwd env in
457524 let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
458525 match Fpath.of_string cwd_str with
459526 | Ok p -> p
460460- | Error (`Msg _) -> Fpath.v "."
527527+ | Error (`Msg _) -> Fpath.v ".")
461528 in
462529 match Monopam.Verse.init ~proc ~fs ~root ~handle () with
463530 | Ok () ->
···467534 Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
468535 `Error (false, "init failed")
469536 in
470470- Cmd.v info Term.(ret (const run $ verse_root_arg $ verse_handle_arg $ logging_term))
537537+ Cmd.v info
538538+ Term.(ret (const run $ verse_root_arg $ verse_handle_arg $ logging_term))
471539472540let verse_members_cmd =
473541 let doc = "List registry members" in
···476544 `S Manpage.s_description;
477545 `P
478546 "Lists all members registered in the opamverse community registry. \
479479- This shows everyone who has published their monorepo for collaboration.";
547547+ This shows everyone who has published their monorepo for \
548548+ collaboration.";
480549 `P
481550 "The registry is automatically pulled (git pull) when running this \
482551 command to ensure you see the latest members.";
···484553 `P
485554 "The registry is a git repository containing an opamverse.toml file \
486555 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\"";
556556+ `Pre
557557+ "[registry]\n\
558558+ name = \"tangled-community\"\n\n\
559559+ [[members]]\n\
560560+ handle = \"alice.bsky.social\"\n\
561561+ monorepo = \"https://github.com/alice/mono\"\n\n\
562562+ [[members]]\n\
563563+ handle = \"bob.example.com\"\n\
564564+ monorepo = \"https://github.com/bob/mono\"";
495565 `S "OUTPUT";
496566 `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";
567567+ `Pre
568568+ "alice.bsky.social -> https://github.com/alice/mono\n\
569569+ bob.example.com -> https://github.com/bob/mono";
499570 `S "ADDING YOURSELF";
500571 `P
501572 "To add yourself to the registry, submit a pull request to the \
···537608 `P "Without arguments: syncs all members in the registry.";
538609 `S "ERROR HANDLING";
539610 `P
540540- "If a sync fails for one member (e.g., network error), the error \
541541- is reported but other members are still synced.";
611611+ "If a sync fails for one member (e.g., network error), the error is \
612612+ reported but other members are still synced.";
542613 `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/";
614614+ `Pre
615615+ "# Sync all registry members\n\
616616+ monopam verse pull\n\n\
617617+ # Sync a specific member\n\
618618+ monopam verse pull alice.bsky.social\n\n\
619619+ # Browse their code\n\
620620+ ls verse/alice.bsky.social/";
549621 ]
550622 in
551623 let info = Cmd.info "pull" ~doc ~man in
···574646 changes. This is the command to run regularly to stay up to date.";
575647 `S "WHAT IT DOES";
576648 `P "The sync command performs two operations:";
577577- `I ("1.", "Updates the registry: git pull in ~/.local/share/monopam/opamverse-registry/");
649649+ `I
650650+ ( "1.",
651651+ "Updates the registry: git pull in \
652652+ ~/.local/share/monopam/opamverse-registry/" );
578653 `I ("2.", "Pulls all tracked members: git pull in each verse/<handle>/");
579654 `S "USE CASES";
580655 `P "Run sync when you want to:";
···583658 `I ("-", "Catch up after being away for a while");
584659 `S "COMPARISON WITH PULL";
585660 `P
586586- "'verse sync' updates the registry AND pulls members. \
587587- 'verse pull' only pulls members (skips registry update).";
661661+ "'verse sync' updates the registry AND pulls members. 'verse pull' \
662662+ only pulls members (skips registry update).";
588663 `S Manpage.s_examples;
589589- `Pre "# Daily sync routine\n\
590590- cd ~/tangled\n\
591591- monopam verse sync\n\
592592- monopam verse status";
664664+ `Pre
665665+ "# Daily sync routine\n\
666666+ cd ~/tangled\n\
667667+ monopam verse sync\n\
668668+ monopam verse status";
593669 ]
594670 in
595671 let info = Cmd.info "sync" ~doc ~man in
···616692 `P
617693 "The opamverse system enables federated collaboration across multiple \
618694 developers' monorepos. Each developer maintains their own monorepo \
619619- (managed by standard monopam commands), and can track other developers' \
620620- monorepos for code browsing, learning, and collaboration.";
695695+ (managed by standard monopam commands), and can track other \
696696+ developers' monorepos for code browsing, learning, and collaboration.";
621697 `P
622698 "Members are identified by tangled handles - decentralized identities \
623699 from the AT Protocol network (the same system used by Bluesky).";
624700 `S "QUICK START FOR NEW USERS";
625701 `P "Run these commands in order to get started:";
626626- `Pre "# Step 1: Authenticate with tangled (one-time setup)\n\
627627- tangled auth login\n\n\
628628- # Step 2: Create and initialize your workspace\n\
629629- mkdir ~/tangled && cd ~/tangled\n\
630630- monopam verse init --handle yourname.bsky.social\n\n\
631631- # Step 3: Sync all community members\n\
632632- monopam verse pull\n\n\
633633- # Step 4: Browse their code\n\
634634- ls verse/\n\
635635- cd verse/alice.bsky.social && dune build\n\n\
636636- # Step 5: Keep everything updated (run daily/weekly)\n\
637637- monopam verse sync";
702702+ `Pre
703703+ "# Step 1: Authenticate with tangled (one-time setup)\n\
704704+ tangled auth login\n\n\
705705+ # Step 2: Create and initialize your workspace\n\
706706+ mkdir ~/tangled && cd ~/tangled\n\
707707+ monopam verse init --handle yourname.bsky.social\n\n\
708708+ # Step 3: Sync all community members\n\
709709+ monopam verse pull\n\n\
710710+ # Step 4: Browse their code\n\
711711+ ls verse/\n\
712712+ cd verse/alice.bsky.social && dune build\n\n\
713713+ # Step 5: Keep everything updated (run daily/weekly)\n\
714714+ monopam verse sync";
638715 `S "KEY CONCEPTS";
639639- `I ("Workspace", "A directory containing your monorepo plus all registry members' repos");
640640- `I ("Registry", "A git repository listing community members and their repo URLs");
641641- `I ("Handle", "A tangled identity like 'alice.bsky.social' validated via AT Protocol");
716716+ `I
717717+ ( "Workspace",
718718+ "A directory containing your monorepo plus all registry members' \
719719+ repos" );
720720+ `I
721721+ ( "Registry",
722722+ "A git repository listing community members and their repo URLs" );
723723+ `I
724724+ ( "Handle",
725725+ "A tangled identity like 'alice.bsky.social' validated via AT \
726726+ Protocol" );
642727 `S "WORKSPACE STRUCTURE";
643728 `P "An opamverse workspace has this layout:";
644644- `Pre "~/tangled/ # workspace root\n\
645645- ├── mono/ # YOUR monorepo\n\
646646- ├── src/ # YOUR fork checkouts\n\
647647- ├── opam-repo/ # YOUR opam overlay\n\
648648- └── verse/\n\
649649- \ ├── alice.bsky.social/ # Alice's monorepo\n\
650650- \ ├── alice.bsky.social-opam/ # Alice's opam overlay\n\
651651- \ ├── bob.example.com/ # Bob's monorepo\n\
652652- \ └── bob.example.com-opam/ # Bob's opam overlay";
729729+ `Pre
730730+ "~/tangled/ # workspace root\n\
731731+ ├── mono/ # YOUR monorepo\n\
732732+ ├── src/ # YOUR fork checkouts\n\
733733+ ├── opam-repo/ # YOUR opam overlay\n\
734734+ └── verse/\n\
735735+ \ ├── alice.bsky.social/ # Alice's monorepo\n\
736736+ \ ├── alice.bsky.social-opam/ # Alice's opam overlay\n\
737737+ \ ├── bob.example.com/ # Bob's monorepo\n\
738738+ \ └── bob.example.com-opam/ # Bob's opam overlay";
653739 `P "Configuration and data are stored in XDG directories:";
654654- `Pre "~/.config/monopam/\n\
655655- └── opamverse.toml # workspace configuration\n\n\
656656- ~/.local/share/monopam/\n\
657657- └── opamverse-registry/ # cloned registry git repo";
740740+ `Pre
741741+ "~/.config/monopam/\n\
742742+ └── opamverse.toml # workspace configuration\n\n\
743743+ ~/.local/share/monopam/\n\
744744+ └── opamverse-registry/ # cloned registry git repo";
658745 `S "COMMAND FLOW";
659746 `P "The expected sequence of commands for typical workflows:";
660747 `P "$(b,First-time setup) (once per machine):";
661661- `Pre "tangled auth login # authenticate\n\
662662- monopam verse init --handle you.bsky.social # create workspace";
748748+ `Pre
749749+ "tangled auth login # authenticate\n\
750750+ monopam verse init --handle you.bsky.social # create workspace";
663751 `P "$(b,Syncing all members):";
664664- `Pre "monopam verse pull # clone/pull all members\n\
665665- monopam verse status # check status";
752752+ `Pre
753753+ "monopam verse pull # clone/pull all \
754754+ members\n\
755755+ monopam verse status # check status";
666756 `P "$(b,Daily maintenance):";
667667- `Pre "monopam verse sync # update everything\n\
668668- monopam verse status # check for changes";
757757+ `Pre
758758+ "monopam verse sync # update everything\n\
759759+ monopam verse status # check for changes";
669760 `P "$(b,Working in your own monorepo):";
670670- `Pre "cd ~/tangled/mono\n\
671671- monopam pull # fetch upstream changes\n\
672672- # ... make edits ...\n\
673673- monopam push # export to checkouts";
761761+ `Pre
762762+ "cd ~/tangled/mono\n\
763763+ monopam pull # fetch upstream \
764764+ changes\n\
765765+ # ... make edits ...\n\
766766+ monopam push # export to checkouts";
674767 `S "INTEGRATION WITH MONOPAM";
675768 `P
676769 "The verse system complements standard monopam commands. Your mono/ \
677770 directory works exactly like a normal monopam-managed monorepo:";
678678- `Pre "# Work in your monorepo\n\
679679- cd ~/tangled/mono\n\
680680- monopam status\n\
681681- monopam pull\n\
682682- # ... make changes ...\n\
683683- monopam push";
771771+ `Pre
772772+ "# Work in your monorepo\n\
773773+ cd ~/tangled/mono\n\
774774+ monopam status\n\
775775+ monopam pull\n\
776776+ # ... make changes ...\n\
777777+ monopam push";
684778 `P
685685- "The verse/ directories are for reading and learning from others' code. \
686686- You generally don't push to them (unless you're a collaborator).";
779779+ "The verse/ directories are for reading and learning from others' \
780780+ code. You generally don't push to them (unless you're a \
781781+ collaborator).";
687782 `S "REGISTRY FORMAT";
688688- `P
689689- "The registry is a git repository containing opamverse.toml:";
690690- `Pre "[registry]\n\
691691- name = \"tangled-community\"\n\n\
692692- [[members]]\n\
693693- handle = \"alice.bsky.social\"\n\
694694- monorepo = \"https://github.com/alice/mono\"";
695695- `P
696696- "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
783783+ `P "The registry is a git repository containing opamverse.toml:";
784784+ `Pre
785785+ "[registry]\n\
786786+ name = \"tangled-community\"\n\n\
787787+ [[members]]\n\
788788+ handle = \"alice.bsky.social\"\n\
789789+ monorepo = \"https://github.com/alice/mono\"";
790790+ `P "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
697791 `S "COMMANDS REFERENCE";
698792 `I ("init", "Create a new workspace with config and directories");
699793 `I ("status", "Show members and their git status");
···710804 in
711805 let info = Cmd.info "verse" ~doc ~man in
712806 Cmd.group info
713713- [
714714- verse_init_cmd;
715715- verse_members_cmd;
716716- verse_pull_cmd;
717717- verse_sync_cmd;
718718- ]
807807+ [ verse_init_cmd; verse_members_cmd; verse_pull_cmd; verse_sync_cmd ]
719808720809(* Doctor command *)
721810···725814 [
726815 `S Manpage.s_description;
727816 `P
728728- "Analyzes your workspace health and provides actionable recommendations. \
729729- Uses Claude AI to analyze commits from verse collaborators, categorizing \
730730- them by type, priority, and risk level.";
817817+ "Analyzes your workspace health and provides actionable \
818818+ recommendations. Uses Claude AI to analyze commits from verse \
819819+ collaborators, categorizing them by type, priority, and risk level.";
731820 `S "WHAT IT DOES";
732821 `P "The doctor command:";
733822 `I ("1.", "Syncs the workspace (unless $(b,--no-sync) is specified)");
···736825 `I ("4.", "Analyzes fork relationships with verse members");
737826 `I ("5.", "Uses Claude to categorize and prioritize their commits");
738827 `I ("6.", "Generates actionable recommendations");
739739- `P "The status output from $(b,monopam status) is automatically included \
740740- in the prompt sent to Claude, so Claude doesn't need to run it separately.";
828828+ `P
829829+ "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 \
831831+ separately.";
741832 `S "OUTPUT FORMATS";
742833 `P "By default, outputs human-readable text with colors.";
743834 `P "Use $(b,--json) for JSON output suitable for tooling.";
···777868 Fmt.pr "Warning: sync failed: %a@." Monopam.pp_error_with_hint e;
778869 Fmt.pr "Continuing with analysis...@."
779870 end;
780780- let report = Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package ~no_sync () in
781781- if json then
782782- print_endline (Monopam.Doctor.to_json report)
783783- else
784784- Fmt.pr "%a@." Monopam.Doctor.pp_report report;
871871+ let report =
872872+ Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package
873873+ ~no_sync ()
874874+ in
875875+ if json then print_endline (Monopam.Doctor.to_json report)
876876+ else Fmt.pr "%a@." Monopam.Doctor.pp_report report;
785877 `Ok ()
786878 in
787787- Cmd.v info Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
879879+ Cmd.v info
880880+ Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
788881789882(* Main command group *)
790883···813906 `P "Monopam manages three directory trees:";
814907 `I
815908 ( "mono/",
816816- "The monorepo combining all packages as git subtrees. This is where you \
817817- make changes." );
909909+ "The monorepo combining all packages as git subtrees. This is where \
910910+ you make changes." );
818911 `I
819912 ( "src/",
820913 "Individual git checkouts of each unique repository. Used for review \
···833926 `I
834927 ( "4. monopam sync --remote",
835928 "Sync again, including pushing to upstream git remotes" );
836836- `P
837837- "For finer control, use $(b,push) and $(b,pull) separately:";
929929+ `P "For finer control, use $(b,push) and $(b,pull) separately:";
838930 `I
839931 ( "monopam push",
840932 "Export monorepo changes to checkouts (for manual review/push)" );
841933 `I
842934 ( "monopam pull",
843843- "Pull remote changes into monorepo (when you know there are no local changes)" );
935935+ "Pull remote changes into monorepo (when you know there are no local \
936936+ changes)" );
844937 `S "CHECKING STATUS";
845938 `P "Run $(b,monopam status) to see the state of all repositories:";
846939 `I ("local:+N", "Your monorepo is N commits ahead of the checkout");
+475-353
lib/changes.ml
···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. *)
+107-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 ->
4646+ Printf.sprintf "[%s](https://tangled.org/@anil.recoil.org/%s.git)" repo
4747+ repo
43484449let format_for_zulip ~entries ~include_date ~date =
4545- if entries = [] then
4646- "No changes to report."
5050+ if entries = [] then "No changes to report."
4751 else begin
4852 let buf = Buffer.create 1024 in
4953 if include_date then begin
···5256 | None -> Buffer.add_string buf "Recent updates:\n\n"
5357 end;
5458 (* 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
5959+ let by_type =
6060+ [
6161+ (Changes_aggregated.New_library, "New Libraries", []);
6262+ (Changes_aggregated.Feature, "Features", []);
6363+ (Changes_aggregated.Bugfix, "Bug Fixes", []);
6464+ (Changes_aggregated.Documentation, "Documentation", []);
6565+ (Changes_aggregated.Refactor, "Improvements", []);
6666+ (Changes_aggregated.Unknown, "Other Changes", []);
6767+ ]
6868+ in
6969+ let grouped =
7070+ List.map
7171+ (fun (ct, title, _) ->
7272+ let matching =
7373+ List.filter
7474+ (fun (e : Changes_aggregated.entry) -> e.change_type = ct)
7575+ entries
7676+ in
7777+ (ct, title, matching))
7878+ by_type
6679 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;
8080+ List.iter
8181+ (fun (_ct, title, entries) ->
8282+ if entries <> [] then begin
8383+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" title);
8484+ List.iter
8585+ (fun (entry : Changes_aggregated.entry) ->
8686+ let repo_link =
8787+ format_repo_link entry.repository entry.repo_url
8888+ in
8989+ Buffer.add_string buf
9090+ (Printf.sprintf "**%s**: %s\n" repo_link entry.summary);
9191+ List.iter
9292+ (fun change ->
9393+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
9494+ entry.changes;
9595+ if entry.contributors <> [] then
9696+ Buffer.add_string buf
9797+ (Printf.sprintf "*Contributors: %s*\n"
9898+ (String.concat ", " entry.contributors));
9999+ Buffer.add_string buf "\n")
100100+ entries
101101+ end)
102102+ grouped;
80103 Buffer.contents buf
81104 end
8210583106let format_summary ~entries =
8484- if entries = [] then
8585- "No new changes."
107107+ if entries = [] then "No new changes."
86108 else
87109 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")
110110+ let repos =
111111+ List.sort_uniq String.compare
112112+ (List.map (fun (e : Changes_aggregated.entry) -> e.repository) entries)
113113+ in
114114+ Printf.sprintf "%d change%s across %d repositor%s: %s" count
115115+ (if count = 1 then "" else "s")
116116+ (List.length repos)
117117+ (if List.length repos = 1 then "y" else "ies")
93118 (String.concat ", " repos)
9411995120(** {1 Daily Changes (Real-time)} *)
···101126 daily_changes_since ~fs ~changes_dir ~since <> []
102127103128let format_daily_for_zulip ~entries ~include_date ~date =
104104- if entries = [] then
105105- "No changes to report."
129129+ if entries = [] then "No changes to report."
106130 else begin
107131 let buf = Buffer.create 1024 in
108132 if include_date then begin
109133 match date with
110110- | Some d -> Buffer.add_string buf (Printf.sprintf "## Changes for %s\n\n" d)
134134+ | Some d ->
135135+ Buffer.add_string buf (Printf.sprintf "## Changes for %s\n\n" d)
111136 | None -> Buffer.add_string buf "## Recent Changes\n\n"
112137 end;
113138 (* 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;
139139+ let repos =
140140+ List.sort_uniq String.compare
141141+ (List.map (fun (e : Changes_daily.entry) -> e.repository) entries)
142142+ in
143143+ List.iter
144144+ (fun repo ->
145145+ let repo_entries =
146146+ List.filter
147147+ (fun (e : Changes_daily.entry) -> e.repository = repo)
148148+ entries
149149+ in
150150+ if repo_entries <> [] then begin
151151+ let first_entry = List.hd repo_entries in
152152+ let repo_link = format_repo_link repo first_entry.repo_url in
153153+ Buffer.add_string buf (Printf.sprintf "### %s\n\n" repo_link);
154154+ List.iter
155155+ (fun (entry : Changes_daily.entry) ->
156156+ Buffer.add_string buf (Printf.sprintf "**%s**\n" entry.summary);
157157+ List.iter
158158+ (fun change ->
159159+ Buffer.add_string buf (Printf.sprintf "- %s\n" change))
160160+ entry.changes;
161161+ if entry.contributors <> [] then
162162+ Buffer.add_string buf
163163+ (Printf.sprintf "*Contributors: %s*\n"
164164+ (String.concat ", " entry.contributors));
165165+ Buffer.add_string buf "\n")
166166+ repo_entries
167167+ end)
168168+ repos;
131169 Buffer.contents buf
132170 end
133171134172let format_daily_summary ~entries =
135135- if entries = [] then
136136- "No new changes."
173173+ if entries = [] then "No new changes."
137174 else
138175 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")
176176+ let repos =
177177+ List.sort_uniq String.compare
178178+ (List.map (fun (e : Changes_daily.entry) -> e.repository) entries)
179179+ in
180180+ Printf.sprintf "%d change%s across %d repositor%s: %s" count
181181+ (if count = 1 then "" else "s")
182182+ (List.length repos)
183183+ (if List.length repos = 1 then "y" else "ies")
144184 (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. *)
+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
+562-305
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";
···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(** 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 *)
1010type 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 *)
1111+ handle : string; (** Member handle or "me" *)
1212+ url : Uri.t; (** Normalized git URL *)
1313+ packages : string list; (** Opam packages from this repo *)
1414}
1515+(** A dev-repo source from a specific member *)
15161617(** Fork relationship between two sources *)
1718type 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 *)
1919+ | Same_url (** Same git URL *)
2020+ | Same_commit (** Different URLs but same HEAD *)
2121+ | I_am_ahead of int (** They forked from me, I'm N commits ahead *)
2222+ | I_am_behind of int (** I forked from them, they're N commits ahead *)
2223 | Diverged of { common_ancestor : string; my_ahead : int; their_ahead : int }
2323- | Unrelated (** No common history *)
2424- | Not_fetched (** Remote not yet fetched *)
2424+ | Unrelated (** No common history *)
2525+ | Not_fetched (** Remote not yet fetched *)
25262626-(** Analysis result for a single repository *)
2727type repo_analysis = {
2828- repo_name : string; (** Repository basename *)
2929- my_source : repo_source option; (** My dev-repo if I have it *)
2828+ repo_name : string; (** Repository basename *)
2929+ my_source : repo_source option; (** My dev-repo if I have it *)
3030 verse_sources : (string * repo_source * relationship) list;
3131- (** (handle, source, relationship to me) *)
3131+ (** (handle, source, relationship to me) *)
3232}
3333+(** Analysis result for a single repository *)
33343535+type t = { repos : repo_analysis list }
3436(** Full fork analysis result *)
3535-type t = {
3636- repos : repo_analysis list;
3737-}
38373938let pp_relationship ppf = function
4039 | Same_url -> Fmt.string ppf "same URL"
···4645 | Unrelated -> Fmt.string ppf "unrelated"
4746 | Not_fetched -> Fmt.string ppf "not fetched"
48474949-let pp_repo_source ppf src =
5050- Fmt.pf ppf "%s" (Uri.to_string src.url)
4848+let pp_repo_source ppf src = Fmt.pf ppf "%s" (Uri.to_string src.url)
51495250let pp_repo_analysis ppf analysis =
5351 Fmt.pf ppf "@[<v 2>%s:@," analysis.repo_name;
···8179 | I_am_ahead n -> Fmt.(styled `Cyan (fun ppf -> pf ppf "-%d")) ppf n
8280 | I_am_behind n -> Fmt.(styled `Red (fun ppf -> pf ppf "+%d")) ppf n
8381 | 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)
8282+ Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "+%d/-%d" a b))
8383+ ppf (their_ahead, my_ahead)
8584 | Unrelated -> Fmt.(styled `Magenta string) ppf "?"
8685 | Not_fetched -> Fmt.(styled `Faint string) ppf "~"
8786···9190 List.filter (fun (_, _, rel) -> is_actionable rel) analysis.verse_sources
9291 in
9392 let in_sync =
9494- List.for_all (fun (_, _, rel) ->
9595- match rel with Same_url | Same_commit -> true | _ -> false)
9393+ List.for_all
9494+ (fun (_, _, rel) ->
9595+ match rel with Same_url | Same_commit -> true | _ -> false)
9696 analysis.verse_sources
9797 in
9898 let all_not_fetched =
9999- List.for_all (fun (_, _, rel) ->
100100- match rel with Not_fetched -> true | _ -> false)
9999+ List.for_all
100100+ (fun (_, _, rel) -> match rel with Not_fetched -> true | _ -> false)
101101 analysis.verse_sources
102102 in
103103 (actionable, in_sync, all_not_fetched)
···106106let abbrev_handle h =
107107 (* Use first part before dot, max 3 chars *)
108108 match String.split_on_char '.' h with
109109- | first :: _ -> if String.length first <= 4 then first else String.sub first 0 3
109109+ | first :: _ ->
110110+ if String.length first <= 4 then first else String.sub first 0 3
110111 | [] -> h
111112112113(** Print a list of (handle, rel) pairs with colors *)
113114let pp_changes ppf actionable =
114115 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)
116116+ List.iter
117117+ (fun (h, _, rel) ->
118118+ if not !first then Fmt.pf ppf " ";
119119+ first := false;
120120+ Fmt.pf ppf "%s%a" (abbrev_handle h) pp_rel_short rel)
119121 actionable
120122121123(** Succinct summary: dense one-line-per-repo format *)
···127129 let in_sync = ref [] in
128130 let not_mine = ref [] in
129131130130- 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)
132132+ List.iter
133133+ (fun r ->
134134+ let actionable, is_in_sync, _ = summarize_repo r in
135135+ match r.my_source with
136136+ | None -> not_mine := r :: !not_mine
137137+ | Some _ when actionable <> [] ->
138138+ with_actions := (r, actionable) :: !with_actions
139139+ | Some _ when is_in_sync -> in_sync := r :: !in_sync
140140+ | Some _ ->
141141+ (* Has verse sources but all same URL - treat as in sync *)
142142+ in_sync := r :: !in_sync)
142143 t.repos;
143144144145 (* Print header with counts *)
···146147 let sync_count = List.length !in_sync in
147148 let other_count = List.length !not_mine in
148149 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;
150150+ Fmt.(styled `Bold string)
151151+ "Verse:"
152152+ Fmt.(styled (if action_count > 0 then `Red else `Green) int)
153153+ action_count
154154+ Fmt.(styled `Green int)
155155+ sync_count
156156+ Fmt.(styled `Faint int)
157157+ other_count;
153158154159 (* Print repos needing attention - dense format *)
155160 if !with_actions <> [] then
156156- List.iter (fun (r, actionable) ->
157157- Fmt.pf ppf " %-22s %a\n" r.repo_name pp_changes actionable)
161161+ List.iter
162162+ (fun (r, actionable) ->
163163+ Fmt.pf ppf " %-22s %a\n" r.repo_name pp_changes actionable)
158164 (List.rev !with_actions);
159165160166 (* Print in-sync repos if show_all *)
161167 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) "=")
168168+ let in_sync_sorted =
169169+ List.sort (fun a b -> String.compare a.repo_name b.repo_name) !in_sync
170170+ in
171171+ List.iter
172172+ (fun r ->
173173+ Fmt.pf ppf " %-22s %a\n" r.repo_name Fmt.(styled `Green string) "=")
165174 in_sync_sorted
166175 end;
167176···169178 if !not_mine <> [] then begin
170179 if show_all then begin
171180 (* 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))
181181+ let not_mine_sorted =
182182+ List.sort
183183+ (fun a b -> String.compare a.repo_name b.repo_name)
184184+ !not_mine
185185+ in
186186+ List.iter
187187+ (fun r ->
188188+ let handles =
189189+ List.map (fun (h, _, _) -> abbrev_handle h) r.verse_sources
190190+ |> List.sort_uniq String.compare
191191+ in
192192+ Fmt.pf ppf " %-22s %a\n" r.repo_name
193193+ Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
194194+ (String.concat "," handles))
178195 not_mine_sorted
179179- end else begin
196196+ end
197197+ else begin
180198 (* Compact summary *)
181199 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)
200200+ List.iter
201201+ (fun r ->
202202+ List.iter
203203+ (fun (h, _, _) ->
204204+ let existing =
205205+ try Hashtbl.find grouped h with Not_found -> []
206206+ in
207207+ Hashtbl.replace grouped h (r.repo_name :: existing))
208208+ r.verse_sources)
187209 !not_mine;
188188- Fmt.pf ppf " %a " Fmt.(styled (`Bold) string) "Others:";
210210+ Fmt.pf ppf " %a " Fmt.(styled `Bold string) "Others:";
189211 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))
212212+ Hashtbl.iter
213213+ (fun h repos ->
214214+ if not !first then Fmt.pf ppf ", ";
215215+ first := false;
216216+ Fmt.(styled `Faint (fun ppf (h, n) -> pf ppf "%s(%d)" h n))
217217+ ppf
218218+ (abbrev_handle h, List.length repos))
194219 grouped;
195220 Fmt.pf ppf "\n"
196221 end
···199224200225let pp_summary ppf t = pp_summary' ~show_all:false ppf t
201226202202-(** Normalize a git URL for comparison.
203203- Handles: git+https, https, git@, with/without .git suffix *)
227227+(** Normalize a git URL for comparison. Handles: git+https, https, git@,
228228+ with/without .git suffix *)
204229let normalize_url url =
205230 let s = Uri.to_string url in
206231 (* Strip git+ prefix *)
207207- let s = if String.starts_with ~prefix:"git+" s then
232232+ let s =
233233+ if String.starts_with ~prefix:"git+" s then
208234 String.sub s 4 (String.length s - 4)
209235 else s
210236 in
···219245 else s
220246 in
221247 (* Strip .git suffix *)
222222- let s = if String.ends_with ~suffix:".git" s then
248248+ let s =
249249+ if String.ends_with ~suffix:".git" s then
223250 String.sub s 0 (String.length s - 4)
224251 else s
225252 in
226253 (* Strip trailing slash *)
227227- let s = if String.ends_with ~suffix:"/" s then
228228- String.sub s 0 (String.length s - 1)
254254+ let s =
255255+ if String.ends_with ~suffix:"/" s then String.sub s 0 (String.length s - 1)
229256 else s
230257 in
231258 Uri.of_string s
···257284 let versions = Eio.Path.read_dir eio_pkg in
258285 match versions with
259286 | [] -> None
260260- | version :: _ ->
287287+ | version :: _ -> (
261288 let opam_path = Fpath.(pkg_dir / version / "opam") in
262289 let eio_opam = Eio.Path.(fs / Fpath.to_string opam_path) in
263290 try
264291 let content = Eio.Path.load eio_opam in
265265- let opamfile = OpamParser.FullPos.string content (Fpath.to_string opam_path) in
292292+ let opamfile =
293293+ OpamParser.FullPos.string content (Fpath.to_string opam_path)
294294+ in
266295 match Opam_repo.find_dev_repo opamfile.file_contents with
267296 | None -> None
268297 | Some url_str ->
269298 if Opam_repo.is_git_url url_str then
270299 Some (pkg_name, Opam_repo.normalize_git_url url_str)
271300 else None
272272- with _ -> None
301301+ with _ -> None)
273302 with _ -> None)
274303 package_names
275304 with _ -> []
···277306(** Fetch a verse opam repo *)
278307let fetch_verse_opam_repo ~proc ~fs path =
279308 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);
309309+ let cmd = [ "git"; "fetch"; "--quiet" ] in
310310+ Log.debug (fun m ->
311311+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp path);
282312 Eio.Switch.run @@ fun sw ->
283283- let child = Eio.Process.spawn proc ~sw ~cwd
313313+ let child =
314314+ Eio.Process.spawn proc ~sw ~cwd
284315 ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
285316 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
286317 cmd
···289320 | `Exited 0 -> ()
290321 | _ -> Log.debug (fun m -> m "Failed to fetch %a" Fpath.pp path)
291322292292-(** Scan all verse opam repos and build a map: repo_basename -> [(handle, url, [packages])] *)
323323+(** Scan all verse opam repos and build a map: repo_basename ->
324324+ [(handle, url, [packages])] *)
293325let scan_all_verse_opam_repos ~proc ~fs ~verse_path () =
294326 let eio_verse = Eio.Path.(fs / Fpath.to_string verse_path) in
295327 let entries = try Eio.Path.read_dir eio_verse with _ -> [] in
296328 (* Find opam repo directories (ending in -opam) *)
297297- let opam_dirs = List.filter (fun name -> String.ends_with ~suffix:"-opam" name) entries in
329329+ let opam_dirs =
330330+ List.filter (fun name -> String.ends_with ~suffix:"-opam" name) entries
331331+ in
298332 (* Fetch each opam repo first *)
299333 Log.info (fun m -> m "Fetching %d verse opam repos" (List.length opam_dirs));
300300- List.iter (fun opam_dir ->
301301- let opam_path = Fpath.(verse_path / opam_dir) in
302302- fetch_verse_opam_repo ~proc ~fs opam_path)
334334+ List.iter
335335+ (fun opam_dir ->
336336+ let opam_path = Fpath.(verse_path / opam_dir) in
337337+ fetch_verse_opam_repo ~proc ~fs opam_path)
303338 opam_dirs;
304339 (* Build map: repo_basename -> [(handle, url, [packages])] *)
305340 let repo_map = Hashtbl.create 64 in
306341 List.iter
307342 (fun opam_dir ->
308308- let handle = String.sub opam_dir 0 (String.length opam_dir - 5) in (* strip -opam *)
343343+ let handle = String.sub opam_dir 0 (String.length opam_dir - 5) in
344344+ (* strip -opam *)
309345 let opam_path = Fpath.(verse_path / opam_dir) in
310346 let pkg_urls = scan_verse_opam_repo ~fs opam_path in
311347 (* Group by repo basename *)
···313349 List.iter
314350 (fun (pkg_name, url) ->
315351 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
352352+ let existing =
353353+ try Hashtbl.find by_repo repo with Not_found -> (url, [])
354354+ in
355355+ let existing_url, pkgs = existing in
318356 Hashtbl.replace by_repo repo (existing_url, pkg_name :: pkgs))
319357 pkg_urls;
320358 (* Add to main map *)
321359 Hashtbl.iter
322360 (fun repo (url, pkgs) ->
323361 let source = { handle; url; packages = pkgs } in
324324- let existing = try Hashtbl.find repo_map repo with Not_found -> [] in
362362+ let existing =
363363+ try Hashtbl.find repo_map repo with Not_found -> []
364364+ in
325365 Hashtbl.replace repo_map repo (source :: existing))
326366 by_repo)
327367 opam_dirs;
···337377 (fun pkg ->
338378 let repo = Package.repo_name pkg in
339379 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
380380+ let existing =
381381+ try Hashtbl.find repo_map repo with Not_found -> (url, [])
382382+ in
383383+ let _, pkgs = existing in
342384 Hashtbl.replace repo_map repo (url, Package.name pkg :: pkgs))
343385 packages;
344386 repo_map
···349391(** Check if a remote exists *)
350392let remote_exists ~proc ~fs ~repo remote_name =
351393 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
352352- let result = Eio.Switch.run @@ fun sw ->
394394+ let result =
395395+ Eio.Switch.run @@ fun sw ->
353396 let buf = Buffer.create 256 in
354354- let child = Eio.Process.spawn proc ~sw ~cwd
355355- ~stdout:(Eio.Flow.buffer_sink buf)
397397+ let child =
398398+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
356399 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
357357- ["git"; "remote"; "get-url"; remote_name]
400400+ [ "git"; "remote"; "get-url"; remote_name ]
358401 in
359359- match Eio.Process.await child with
360360- | `Exited 0 -> true
361361- | _ -> false
402402+ match Eio.Process.await child with `Exited 0 -> true | _ -> false
362403 in
363404 result
364405365406(** Add a git remote *)
366407let add_remote ~proc ~fs ~repo ~name ~url () =
367408 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);
409409+ let cmd = [ "git"; "remote"; "add"; name; Uri.to_string url ] in
410410+ Log.debug (fun m ->
411411+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
370412 Eio.Switch.run @@ fun sw ->
371371- let child = Eio.Process.spawn proc ~sw ~cwd
413413+ let child =
414414+ Eio.Process.spawn proc ~sw ~cwd
372415 ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
373416 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
374417 cmd
···380423(** Fetch a remote *)
381424let fetch_remote ~proc ~fs ~repo ~remote () =
382425 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
383383- let cmd = ["git"; "fetch"; remote] in
426426+ let cmd = [ "git"; "fetch"; remote ] in
384427 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);
428428+ Log.debug (fun m ->
429429+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
386430 Eio.Switch.run @@ fun sw ->
387387- let child = Eio.Process.spawn proc ~sw ~cwd
431431+ let child =
432432+ Eio.Process.spawn proc ~sw ~cwd
388433 ~stdout:(Eio.Flow.buffer_sink (Buffer.create 256))
389434 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 256))
390435 cmd
···396441(** Get the commit SHA for a ref *)
397442let get_ref_commit ~proc ~fs ~repo ref_name =
398443 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);
444444+ let cmd = [ "git"; "rev-parse"; ref_name ] in
445445+ Log.debug (fun m ->
446446+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
401447 Eio.Switch.run @@ fun sw ->
402448 let buf = Buffer.create 64 in
403403- let child = Eio.Process.spawn proc ~sw ~cwd
404404- ~stdout:(Eio.Flow.buffer_sink buf)
449449+ let child =
450450+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
405451 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
406452 cmd
407453 in
···416462 match (my_commit, their_commit) with
417463 | None, _ | _, None -> Not_fetched
418464 | Some my_sha, Some their_sha when my_sha = their_sha -> Same_commit
419419- | Some my_sha, Some their_sha ->
465465+ | Some my_sha, Some their_sha -> (
420466 (* Check ancestry *)
421467 let cwd = Eio.Path.(fs / Fpath.to_string repo) in
422468 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);
469469+ let cmd = [ "git"; "merge-base"; "--is-ancestor"; commit1; commit2 ] in
470470+ Log.debug (fun m ->
471471+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
425472 Eio.Switch.run @@ fun sw ->
426426- let child = Eio.Process.spawn proc ~sw ~cwd
473473+ let child =
474474+ Eio.Process.spawn proc ~sw ~cwd
427475 ~stdout:(Eio.Flow.buffer_sink (Buffer.create 16))
428476 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
429477 cmd
430478 in
431431- match Eio.Process.await child with
432432- | `Exited 0 -> true
433433- | _ -> false
479479+ match Eio.Process.await child with `Exited 0 -> true | _ -> false
434480 in
435481 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);
482482+ let cmd = [ "git"; "rev-list"; "--count"; base ^ ".." ^ head ] in
483483+ Log.debug (fun m ->
484484+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
438485 Eio.Switch.run @@ fun sw ->
439486 let buf = Buffer.create 16 in
440440- let child = Eio.Process.spawn proc ~sw ~cwd
441441- ~stdout:(Eio.Flow.buffer_sink buf)
487487+ let child =
488488+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
442489 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
443490 cmd
444491 in
445492 match Eio.Process.await child with
446446- | `Exited 0 -> (try int_of_string (String.trim (Buffer.contents buf)) with _ -> 0)
493493+ | `Exited 0 -> (
494494+ try int_of_string (String.trim (Buffer.contents buf)) with _ -> 0)
447495 | _ -> 0
448496 in
449497 let my_is_ancestor = is_ancestor my_sha their_sha in
450498 let their_is_ancestor = is_ancestor their_sha my_sha in
451499 match (my_is_ancestor, their_is_ancestor) with
452452- | true, true -> Same_commit (* shouldn't happen if SHAs differ *)
500500+ | true, true -> Same_commit (* shouldn't happen if SHAs differ *)
453501 | true, false ->
454502 (* My commit is ancestor of theirs -> I'm behind *)
455503 let behind = count_commits my_sha their_sha in
···458506 (* Their commit is ancestor of mine -> I'm ahead *)
459507 let ahead = count_commits their_sha my_sha in
460508 I_am_ahead ahead
461461- | false, false ->
509509+ | false, false -> (
462510 (* 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);
511511+ let cmd = [ "git"; "merge-base"; my_sha; their_sha ] in
512512+ Log.debug (fun m ->
513513+ m "Running: %s (in %a)" (String.concat " " cmd) Fpath.pp repo);
465514 let merge_base =
466515 Eio.Switch.run @@ fun sw ->
467516 let buf = Buffer.create 64 in
468468- let child = Eio.Process.spawn proc ~sw ~cwd
469469- ~stdout:(Eio.Flow.buffer_sink buf)
517517+ let child =
518518+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
470519 ~stderr:(Eio.Flow.buffer_sink (Buffer.create 16))
471520 cmd
472521 in
···479528 | Some base ->
480529 let my_ahead = count_commits base my_sha in
481530 let their_ahead = count_commits base their_sha in
482482- Diverged { common_ancestor = base; my_ahead; their_ahead }
531531+ Diverged { common_ancestor = base; my_ahead; their_ahead }))
483532484533(** Compute fork analysis for all repos *)
485534let compute ~proc ~fs ~verse_config ~monopam_config () =
···530579 match my_source with
531580 | Some my when urls_equal my.url src.url -> Same_url
532581 | _ when not have_checkout -> Not_fetched
533533- | _ ->
582582+ | _ -> (
534583 let remote_name = verse_remote_name src.handle in
535584 (* 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 ())
585585+ if
586586+ not
587587+ (remote_exists ~proc ~fs ~repo:checkout_path
588588+ remote_name)
589589+ then begin
590590+ Log.info (fun m ->
591591+ m "Adding remote %s -> %a" remote_name Uri.pp
592592+ src.url);
593593+ ignore
594594+ (add_remote ~proc ~fs ~repo:checkout_path
595595+ ~name:remote_name ~url:src.url ())
539596 end;
540597 (* Fetch remote *)
541541- (match fetch_remote ~proc ~fs ~repo:checkout_path ~remote:remote_name () with
598598+ match
599599+ fetch_remote ~proc ~fs ~repo:checkout_path
600600+ ~remote:remote_name ()
601601+ with
542602 | Error _ -> Not_fetched
543603 | Ok () ->
544604 (* Compare refs *)
545605 let my_ref = "origin/main" in
546606 let their_ref = remote_name ^ "/main" in
547547- compare_refs ~proc ~fs ~repo:checkout_path ~my_ref ~their_ref ())
607607+ compare_refs ~proc ~fs ~repo:checkout_path ~my_ref
608608+ ~their_ref ())
548609 in
549610 (src.handle, src, rel))
550611 verse_sources
···554615 all_repos []
555616 in
556617 (* Sort by repo name *)
557557- let repos = List.sort (fun a b -> String.compare a.repo_name b.repo_name) analyses in
618618+ let repos =
619619+ List.sort (fun a b -> String.compare a.repo_name b.repo_name) analyses
620620+ in
558621 { repos }
+29-31
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. *)
···7776 unit ->
7877 t
7978(** [compute ~proc ~fs ~verse_config ~monopam_config ()] 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
7979+ analysis by: 1. Scanning my opam repo for dev-repo URLs 2. Scanning all
8080+ verse opam repos for dev-repo URLs 3. Adding git remotes to my checkouts for
8181+ each member's fork 4. Fetching remotes and comparing commit histories
85828686- This is an expensive operation as it fetches from all verse member remotes. *)
8383+ This is an expensive operation as it fetches from all verse member remotes.
8484+*)
+39-40
lib/git.ml
···6060 try
6161 let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in
6262 result.exit_code = 0
6363- with Eio.Io _ -> false (* Directory doesn't exist or not accessible *)
6363+ with Eio.Io _ -> false (* Directory doesn't exist or not accessible *)
64646565let is_dirty ~proc ~fs path =
6666 let cwd = path_to_eio ~fs path in
···228228229229let add_remote ~proc ~fs ~name ~url path =
230230 let cwd = path_to_eio ~fs path in
231231- run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ]
232232- |> Result.map ignore
231231+ run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ] |> Result.map ignore
233232234233let remove_remote ~proc ~fs ~name path =
235234 let cwd = path_to_eio ~fs path in
236236- run_git_ok ~proc ~cwd [ "remote"; "remove"; name ]
237237- |> Result.map ignore
235235+ run_git_ok ~proc ~cwd [ "remote"; "remove"; name ] |> Result.map ignore
238236239237let set_remote_url ~proc ~fs ~name ~url path =
240238 let cwd = path_to_eio ~fs path in
241241- run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ]
242242- |> Result.map ignore
239239+ run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ] |> Result.map ignore
243240244241let ensure_remote ~proc ~fs ~name ~url path =
245242 let remotes = list_remotes ~proc ~fs path in
···249246 | Some existing_url when existing_url = url -> Ok ()
250247 | _ -> set_remote_url ~proc ~fs ~name ~url path
251248 end
252252- else
253253- add_remote ~proc ~fs ~name ~url path
249249+ else add_remote ~proc ~fs ~name ~url path
254250255251type log_entry = {
256252 hash : string;
···296292 let args =
297293 match until with Some u -> args @ [ "--until=" ^ u ] | None -> args
298294 in
299299- let args = match filter_path with Some p -> args @ [ "--"; p ] | None -> args in
295295+ let args =
296296+ match filter_path with Some p -> args @ [ "--"; p ] | None -> args
297297+ in
300298 match run_git_ok ~proc ~cwd args with
301299 | Ok output -> Ok (parse_log_entries output)
302300 | Error e -> Error e
···306304 let format_arg = "--format=%H%n%an%n%aI%n%s%n%b%x00" in
307305 let range = Printf.sprintf "%s..%s" base tip in
308306 let args = [ "log"; format_arg; range ] in
309309- let args = match max_count with
307307+ let args =
308308+ match max_count with
310309 | Some n -> args @ [ "-n"; string_of_int n ]
311310 | None -> args
312311 in
···314313 | Ok output -> Ok (parse_log_entries output)
315314 | Error e -> Error e
316315317317-(** Parse a subtree merge/squash commit message to extract the upstream commit range.
318318- Messages look like: "Squashed 'prefix/' changes from abc123..def456"
319319- or "Squashed 'prefix/' content from commit abc123"
320320- Returns the end commit (most recent) if found. *)
316316+(** Parse a subtree merge/squash commit message to extract the upstream commit
317317+ range. Messages look like: "Squashed 'prefix/' changes from abc123..def456"
318318+ or "Squashed 'prefix/' content from commit abc123" Returns the end commit
319319+ (most recent) if found. *)
321320let parse_subtree_message subject =
322321 (* Helper to extract hex commit hash starting at position *)
323322 let extract_hex s start =
324323 let len = String.length s in
325324 let rec find_end i =
326325 if i >= len then i
327327- else match s.[i] with
328328- | '0'..'9' | 'a'..'f' -> find_end (i + 1)
329329- | _ -> i
326326+ else
327327+ match s.[i] with '0' .. '9' | 'a' .. 'f' -> find_end (i + 1) | _ -> i
330328 in
331329 let end_pos = find_end start in
332330 if end_pos > start then Some (String.sub s start (end_pos - start))
···337335 match String.index_opt subject '.' with
338336 | Some i when i + 1 < String.length subject && subject.[i + 1] = '.' ->
339337 extract_hex subject (i + 2)
340340- | _ ->
338338+ | _ -> (
341339 (* Pattern 2: "Squashed 'prefix/' content from commit abc123" *)
342342- (match String.split_on_char ' ' subject |> List.rev with
340340+ match String.split_on_char ' ' subject |> List.rev with
343341 | last :: "commit" :: "from" :: _ -> extract_hex last 0
344344- | _ -> None)
345345- (* Pattern 3: "Add 'prefix/' from commit abc123" *)
342342+ | _ -> None) (* Pattern 3: "Add 'prefix/' from commit abc123" *)
346343 else if String.starts_with ~prefix:"Add '" subject then
347344 match String.split_on_char ' ' subject |> List.rev with
348345 | last :: "commit" :: "from" :: _ -> extract_hex last 0
349346 | _ -> None
350350- else
351351- None
347347+ else None
352348353353-(** Find the last subtree-related commit for a given prefix.
354354- Searches git log for commits with subtree merge/squash messages. *)
349349+(** Find the last subtree-related commit for a given prefix. Searches git log
350350+ for commits with subtree merge/squash messages. *)
355351let subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () =
356352 let cwd = path_to_eio ~fs repo in
357353 (* Search for subtree-related commits - don't use path filter as it can miss merge commits *)
358354 let grep_pattern = Printf.sprintf "^Squashed '%s/'" prefix in
359359- match run_git_ok ~proc ~cwd
360360- [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ] with
355355+ match
356356+ run_git_ok ~proc ~cwd [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ]
357357+ with
361358 | Error _ -> None
362362- | Ok "" ->
359359+ | Ok "" -> (
363360 (* Try alternate pattern: Add 'prefix/' from commit *)
364361 let add_pattern = Printf.sprintf "^Add '%s/'" prefix in
365365- (match run_git_ok ~proc ~cwd
366366- [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ] with
362362+ match
363363+ run_git_ok ~proc ~cwd
364364+ [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ]
365365+ with
367366 | Error _ -> None
368367 | Ok "" -> None
369369- | Ok line ->
368368+ | Ok line -> (
370369 (* line is "abc1234 Add 'prefix/' from commit ..." *)
371370 let hash = String.sub line 0 (min 7 (String.length line)) in
372371 (* Get the full commit message to parse *)
373372 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
374373 | Error _ -> None
375375- | Ok subject -> parse_subtree_message subject)
376376- | Ok line ->
374374+ | Ok subject -> parse_subtree_message subject))
375375+ | Ok line -> (
377376 let hash = String.sub line 0 (min 7 (String.length line)) in
378377 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
379378 | Error _ -> None
380380- | Ok subject -> parse_subtree_message subject
379379+ | Ok subject -> parse_subtree_message subject)
381380382381(** Check if commit1 is an ancestor of commit2. *)
383382let is_ancestor ~proc ~fs ~repo ~commit1 ~commit2 () =
384383 let cwd = path_to_eio ~fs repo in
385385- let result = run_git ~proc ~cwd
386386- [ "merge-base"; "--is-ancestor"; commit1; commit2 ] in
384384+ let result =
385385+ run_git ~proc ~cwd [ "merge-base"; "--is-ancestor"; commit1; commit2 ]
386386+ in
387387 result.exit_code = 0
388388389389(** Find the merge-base (common ancestor) of two commits. *)
···394394(** Count commits between two commits (exclusive of base, inclusive of head). *)
395395let count_commits_between ~proc ~fs ~repo ~base ~head () =
396396 let cwd = path_to_eio ~fs repo in
397397- match run_git_ok ~proc ~cwd
398398- [ "rev-list"; "--count"; base ^ ".." ^ head ] with
397397+ match run_git_ok ~proc ~cwd [ "rev-list"; "--count"; base ^ ".." ^ head ] with
399398 | Error _ -> 0
400400- | Ok s -> try int_of_string (String.trim s) with _ -> 0
399399+ | Ok s -> ( try int_of_string (String.trim s) with _ -> 0)
+9-7
lib/git.mli
···276276 ?remote:string ->
277277 Fpath.t ->
278278 string option
279279-(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote,
280280- or [None] if not set or the remote doesn't exist.
279279+(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote, or
280280+ [None] if not set or the remote doesn't exist.
281281282282 @param remote Remote name (default: "origin") *)
283283···322322 url:string ->
323323 Fpath.t ->
324324 (unit, error) result
325325-(** [set_remote_url ~proc ~fs ~name ~url path] updates the URL for an existing remote. *)
325325+(** [set_remote_url ~proc ~fs ~name ~url path] updates the URL for an existing
326326+ remote. *)
326327327328val ensure_remote :
328329 proc:_ Eio.Process.mgr ->
···331332 url:string ->
332333 Fpath.t ->
333334 (unit, error) result
334334-(** [ensure_remote ~proc ~fs ~name ~url path] ensures a remote exists with the given URL.
335335- If the remote exists with a different URL, it is updated.
336336- If the remote doesn't exist, it is added. *)
335335+(** [ensure_remote ~proc ~fs ~name ~url path] ensures a remote exists with the
336336+ given URL. If the remote exists with a different URL, it is updated. If the
337337+ remote doesn't exist, it is added. *)
337338338339(** {1 Commit History} *)
339340···369370 ?max_count:int ->
370371 Fpath.t ->
371372 (log_entry list, error) result
372372-(** [log_range ~proc ~fs ~base ~tip ?max_count repo] retrieves commits between refs.
373373+(** [log_range ~proc ~fs ~base ~tip ?max_count repo] retrieves commits between
374374+ refs.
373375374376 Gets commits reachable from [tip] but not from [base] (i.e., [base..tip]).
375377
+543-296
lib/monopam.ml
···3434 | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name
3535 | Claude_error msg -> Fmt.pf ppf "Claude error: %s" msg
36363737-(** Returns a hint string for the given error, or None if no hint is available. *)
3737+(** Returns a hint string for the given error, or None if no hint is available.
3838+*)
3839let error_hint = function
3940 | Config_error _ ->
4040- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace."
4141+ Some
4242+ "Run 'monopam verse init --handle <your-handle>' to create a workspace."
4143 | Repo_error (Opam_repo.No_dev_repo _) ->
4242- Some "Add a 'dev-repo' field to the package's opam file pointing to a git URL."
4444+ Some
4545+ "Add a 'dev-repo' field to the package's opam file pointing to a git \
4646+ URL."
4347 | Repo_error (Opam_repo.Not_git_remote _) ->
4448 Some "The dev-repo must be a git URL (git+https:// or git://)."
4549 | Repo_error _ -> None
···5357 Some "Check that the remote is configured: git remote -v"
5458 | Git_error (Git.Branch_not_found _) ->
5559 Some "Check available branches: git branch -a"
5656- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git push" cmd ->
6060+ | Git_error (Git.Command_failed (cmd, _))
6161+ when String.starts_with ~prefix:"git push" cmd ->
5762 Some "Check your network connection and git credentials."
5858- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git subtree" cmd ->
6363+ | Git_error (Git.Command_failed (cmd, _))
6464+ when String.starts_with ~prefix:"git subtree" cmd ->
5965 Some "Run 'monopam status' to check repository state."
6066 | Git_error _ -> None
6167 | Dirty_state _ ->
6262- Some "Commit changes in the monorepo first: cd mono && git add -A && git commit"
6868+ Some
6969+ "Commit changes in the monorepo first: cd mono && git add -A && git \
7070+ commit"
6371 | Package_not_found _ ->
6472 Some "Check available packages: ls opam-repo/packages/"
6573 | Claude_error msg when String.starts_with ~prefix:"Failed to decode" msg ->
···131139 (fun pkg ->
132140 let repo = Package.repo_name pkg in
133141 let name = Package.name pkg in
134134- let existing = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
142142+ let existing =
143143+ try Hashtbl.find registered_by_repo repo with Not_found -> []
144144+ in
135145 Hashtbl.replace registered_by_repo repo (name :: existing))
136146 pkgs;
137147 (* Get unique subtree directories *)
···153163 let repo = Package.repo_name pkg in
154164 let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in
155165 let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in
156156- let registered = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
166166+ let registered =
167167+ try Hashtbl.find registered_by_repo repo with Not_found -> []
168168+ in
157169 try
158170 Eio.Path.read_dir eio_path
159171 |> List.filter_map (fun name ->
···240252 else dev_repo
241253 in
242254 let repo_cell =
243243- if i = 0 then Printf.sprintf "[**%s**](%s)" repo display_url
244244- else ""
255255+ if i = 0 then Printf.sprintf "[**%s**](%s)" repo display_url else ""
245256 in
246257 let synopsis = Option.value ~default:"" (Package.synopsis pkg) in
247258 Buffer.add_string buf
248248- (Printf.sprintf "| %s | %s | %s |\n" repo_cell
249249- (Package.name pkg) synopsis))
259259+ (Printf.sprintf "| %s | %s | %s |\n" repo_cell (Package.name pkg)
260260+ synopsis))
250261 pkgs)
251262 grouped;
252263 Buffer.add_string buf "\n---\n\n";
···365376(** Collect all external dependencies by scanning monorepo subtree directories.
366377 This scans all .opam files in each subtree directory to find dependencies,
367378 ensuring we get dependencies from all packages in a directory, not just
368368- those registered in the opam overlay.
369369- Returns a sorted, deduplicated list of package names that are dependencies
370370- but not packages in the repo itself. *)
379379+ those registered in the opam overlay. Returns a sorted, deduplicated list of
380380+ package names that are dependencies but not packages in the repo itself. *)
371381let collect_external_deps ~fs ~config pkgs =
372382 let monorepo = Config.Paths.monorepo config in
373383 (* Get unique repos to avoid scanning the same directory multiple times *)
···411421 (* Filter out packages that are in the repo *)
412422 List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps
413423414414-(** Generate dune-project content for the monorepo root.
415415- Lists all external dependencies as a virtual package. *)
424424+(** Generate dune-project content for the monorepo root. Lists all external
425425+ dependencies as a virtual package. *)
416426let generate_dune_project ~fs ~config pkgs =
417427 let external_deps = collect_external_deps ~fs ~config pkgs in
418428 let buf = Buffer.create 1024 in
···458468 Eio.Switch.run (fun sw ->
459469 let child =
460470 Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
461461- [ "git"; "commit"; "-m"; "Update dune-project with external dependencies" ]
471471+ [
472472+ "git";
473473+ "commit";
474474+ "-m";
475475+ "Update dune-project with external dependencies";
476476+ ]
462477 in
463478 ignore (Eio.Process.await child));
464479 Log.app (fun m ->
···623638 match (scheme, host) with
624639 | Some ("https" | "http"), Some "github.com" ->
625640 (* https://github.com/user/repo.git -> git@github.com:user/repo.git *)
626626- let path = if String.length path > 0 && path.[0] = '/' then
627627- String.sub path 1 (String.length path - 1)
628628- else path in
641641+ let path =
642642+ if String.length path > 0 && path.[0] = '/' then
643643+ String.sub path 1 (String.length path - 1)
644644+ else path
645645+ in
629646 Printf.sprintf "git@github.com:%s" path
630647 | Some ("https" | "http"), Some "tangled.org" ->
631648 (* https://tangled.org/@anil.recoil.org/foo -> git@git.recoil.org:anil.recoil.org/foo *)
632632- let path = if String.length path > 0 && path.[0] = '/' then
633633- String.sub path 1 (String.length path - 1)
634634- else path in
649649+ let path =
650650+ if String.length path > 0 && path.[0] = '/' then
651651+ String.sub path 1 (String.length path - 1)
652652+ else path
653653+ in
635654 (* Strip leading @ from username if present *)
636636- let path = if String.length path > 0 && path.[0] = '@' then
637637- String.sub path 1 (String.length path - 1)
638638- else path in
655655+ let path =
656656+ if String.length path > 0 && path.[0] = '@' then
657657+ String.sub path 1 (String.length path - 1)
658658+ else path
659659+ in
639660 (* Strip .git suffix if present *)
640640- let path = if String.ends_with ~suffix:".git" path then
641641- String.sub path 0 (String.length path - 4)
642642- else path in
661661+ let path =
662662+ if String.ends_with ~suffix:".git" path then
663663+ String.sub path 0 (String.length path - 4)
664664+ else path
665665+ in
643666 Printf.sprintf "git@git.recoil.org:%s" path
644667 | _ ->
645668 (* Return original URL for other cases *)
···742765 else begin
743766 (* Opam repo doesn't exist - clone it if we have a URL *)
744767 match opam_repo_url with
745745- | Some url ->
746746- Log.info (fun m -> m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
768768+ | Some url -> (
769769+ Log.info (fun m ->
770770+ m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
747771 let url = Uri.of_string url in
748772 let branch = Config.default_branch config in
749749- (match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
773773+ match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
750774 | Ok () -> Log.info (fun m -> m "Opam repo cloned successfully")
751751- | Error e -> Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e))
775775+ | Error e ->
776776+ Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e)
777777+ )
752778 | None ->
753753- Log.info (fun m -> m "Opam repo at %a does not exist and no URL provided" Fpath.pp opam_repo)
779779+ Log.info (fun m ->
780780+ m "Opam repo at %a does not exist and no URL provided" Fpath.pp
781781+ opam_repo)
754782 end;
755783 (* Ensure directories exist before computing status *)
756784 ensure_checkouts_dir ~fs:fs_t ~config;
···940968 in
941969 let* () =
942970 if needs_clone then begin
943943- Log.info (fun m ->
944944- m "Creating checkout for %s" (Package.repo_name pkg));
971971+ Log.info (fun m -> m "Creating checkout for %s" (Package.repo_name pkg));
945972 ensure_checkout ~proc ~fs:(fs :> _ Eio.Path.t) ~config pkg
946973 end
947974 else Ok ()
···952979 Log.info (fun m -> m "Pushing subtree %s to checkout" prefix);
953980 let* _ =
954981 run_git_in ~proc ~cwd:monorepo_eio
955955- [
956956- "subtree"; "push"; "--prefix"; prefix; checkout_path; sync_branch;
957957- ]
982982+ [ "subtree"; "push"; "--prefix"; prefix; checkout_path; sync_branch ]
958983 in
959984 (* Merge sync branch into the target branch in checkout *)
960985 Log.debug (fun m -> m "Merging %s into %s" sync_branch branch);
961986 let* _ =
962962- run_git_in ~proc ~cwd:checkout_eio
963963- [ "merge"; "--ff-only"; sync_branch ]
987987+ run_git_in ~proc ~cwd:checkout_eio [ "merge"; "--ff-only"; sync_branch ]
964988 in
965989 (* Delete the sync branch *)
966990 Log.debug (fun m -> m "Cleaning up %s branch" sync_branch);
967967- ignore
968968- (run_git_in ~proc ~cwd:checkout_eio [ "branch"; "-d"; sync_branch ]);
991991+ ignore (run_git_in ~proc ~cwd:checkout_eio [ "branch"; "-d"; sync_branch ]);
969992 Ok ()
970993 end
971994···10271050 m "[%d/%d] Pushing %s to %s" i total
10281051 (Package.repo_name pkg) push_url);
10291052 (* Set the push URL for origin *)
10301030- (match Git.set_push_url ~proc ~fs:fs_t ~url:push_url checkout_dir with
10311031- | Ok () -> ()
10321032- | Error e ->
10331033- Log.warn (fun m ->
10341034- m "Failed to set push URL: %a" Git.pp_error e));
10531053+ (match
10541054+ Git.set_push_url ~proc ~fs:fs_t ~url:push_url
10551055+ checkout_dir
10561056+ with
10571057+ | Ok () -> ()
10581058+ | Error e ->
10591059+ Log.warn (fun m ->
10601060+ m "Failed to set push URL: %a" Git.pp_error e));
10351061 match
10361062 Git.push_remote ~proc ~fs:fs_t ~branch checkout_dir
10371063 with
···10731099 | `Push_remote -> Fmt.string ppf "push-remote"
1074110010751101let pp_sync_failure ppf f =
10761076- Fmt.pf ppf "%s (%a): %a" f.repo_name pp_sync_phase f.phase Git.pp_error f.error
11021102+ Fmt.pf ppf "%s (%a): %a" f.repo_name pp_sync_phase f.phase Git.pp_error
11031103+ f.error
1077110410781105let pp_sync_summary ppf s =
10791106 Fmt.pf ppf "Synced: %d, Unchanged: %d, Pulled: %d commits, Pushed: %d commits"
10801107 s.repos_synced s.repos_unchanged s.commits_pulled s.commits_pushed;
10811108 if s.errors <> [] then
10821082- Fmt.pf ppf "@.Errors (%d):@. @[<v>%a@]"
10831083- (List.length s.errors)
10841084- Fmt.(list ~sep:cut pp_sync_failure) s.errors
11091109+ Fmt.pf ppf "@.Errors (%d):@. @[<v>%a@]" (List.length s.errors)
11101110+ Fmt.(list ~sep:cut pp_sync_failure)
11111111+ s.errors
1085111210861113(* Helper to ensure checkout exists, returning whether it was cloned *)
10871114let ensure_checkout_safe ~proc ~fs ~config pkg =
···11001127 Log.info (fun m ->
11011128 m "Cloning %s from %a (branch: %s)" (Package.repo_name pkg) Uri.pp
11021129 (Package.dev_repo pkg) branch);
11031103- match Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir with
11301130+ match
11311131+ Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir
11321132+ with
11041133 | Ok () -> Ok (true, 0)
11051134 | Error e -> Error e
11061135 end
···11461175 Log.info (fun m -> m "Pushing %s to %s" (Package.repo_name pkg) push_url);
11471176 (* Set the push URL for origin *)
11481177 (match Git.set_push_url ~proc ~fs ~url:push_url checkout_dir with
11491149- | Ok () -> ()
11501150- | Error e ->
11511151- Log.warn (fun m -> m "Failed to set push URL: %a" Git.pp_error e));
11781178+ | Ok () -> ()
11791179+ | Error e -> Log.warn (fun m -> m "Failed to set push URL: %a" Git.pp_error e));
11521180 Git.push_remote ~proc ~fs ~branch checkout_dir
1153118111541182(* Sanitize handle for use as git remote name *)
11551183let sanitize_remote_name handle =
11561184 (* Replace @ and . with - for valid git remote names *)
11571157- String.map (function
11581158- | '@' | '.' -> '-'
11591159- | c -> c) handle
11851185+ String.map (function '@' | '.' -> '-' | c -> c) handle
1160118611611187(* Ensure verse remotes for a single repo *)
11621188let ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg =
···11691195 else begin
11701196 (* Get all verse members who have this repo *)
11711197 let members_with_repo =
11721172- Hashtbl.find_opt verse_subtrees repo_name
11731173- |> Option.value ~default:[]
11981198+ Hashtbl.find_opt verse_subtrees repo_name |> Option.value ~default:[]
11741199 in
1175120011761201 (* Get current remotes *)
11771202 let current_remotes = Git.list_remotes ~proc ~fs checkout_dir in
11781203 let verse_remotes =
11791179- List.filter (fun r -> String.starts_with ~prefix:"verse-" r) current_remotes
12041204+ List.filter
12051205+ (fun r -> String.starts_with ~prefix:"verse-" r)
12061206+ current_remotes
11801207 in
1181120811821209 (* Build set of expected verse remotes *)
11831210 let expected_remotes =
11841184- List.map (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle) members_with_repo
12111211+ List.map
12121212+ (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle)
12131213+ members_with_repo
11851214 in
1186121511871216 (* Add/update remotes for verse members *)
11881188- List.iter (fun (handle, verse_mono_path) ->
12171217+ List.iter
12181218+ (fun (handle, verse_mono_path) ->
11891219 let remote_name = "verse-" ^ sanitize_remote_name handle in
11901220 (* Point to their src/ checkout for this repo *)
11911221 let verse_src = Fpath.(parent verse_mono_path / "src" / repo_name) in
11921222 if Sys.file_exists (Fpath.to_string verse_src) then begin
11931223 let url = Fpath.to_string verse_src in
11941194- match Git.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir with
11951195- | Ok () -> Log.debug (fun m -> m "Ensured verse remote %s -> %s" remote_name url)
11961196- | Error e -> Log.warn (fun m -> m "Failed to add verse remote %s: %a" remote_name Git.pp_error e)
12241224+ match
12251225+ Git.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir
12261226+ with
12271227+ | Ok () ->
12281228+ Log.debug (fun m ->
12291229+ m "Ensured verse remote %s -> %s" remote_name url)
12301230+ | Error e ->
12311231+ Log.warn (fun m ->
12321232+ m "Failed to add verse remote %s: %a" remote_name Git.pp_error
12331233+ e)
11971234 end)
11981235 members_with_repo;
1199123612001237 (* Remove outdated verse remotes *)
12011201- List.iter (fun remote_name ->
12381238+ List.iter
12391239+ (fun remote_name ->
12021240 if not (List.mem remote_name expected_remotes) then begin
12031241 Log.debug (fun m -> m "Removing outdated verse remote %s" remote_name);
12041242 match Git.remove_remote ~proc ~fs ~name:remote_name checkout_dir with
12051243 | Ok () -> ()
12061206- | Error e -> Log.warn (fun m -> m "Failed to remove verse remote %s: %a" remote_name Git.pp_error e)
12441244+ | Error e ->
12451245+ Log.warn (fun m ->
12461246+ m "Failed to remove verse remote %s: %a" remote_name
12471247+ Git.pp_error e)
12071248 end)
12081249 verse_remotes
12091250 end
···12111252(* Sync verse remotes for all repos *)
12121253let sync_verse_remotes ~proc ~fs ~config ~verse_config repos =
12131254 Log.app (fun m -> m " Updating verse remotes...");
12141214- let verse_subtrees = Verse.get_verse_subtrees ~proc ~fs ~config:verse_config () in
12151215- List.iter (fun pkg ->
12551255+ let verse_subtrees =
12561256+ Verse.get_verse_subtrees ~proc ~fs ~config:verse_config ()
12571257+ in
12581258+ List.iter
12591259+ (fun pkg ->
12161260 ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg)
12171261 repos
12181262···12211265 let checkouts_root = Config.Paths.checkouts config in
12221266 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
12231267 let remotes = Git.list_remotes ~proc ~fs checkout_dir in
12241224- let verse_remotes = List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes in
12251225- List.iter (fun remote ->
12681268+ let verse_remotes =
12691269+ List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes
12701270+ in
12711271+ List.iter
12721272+ (fun remote ->
12261273 Log.debug (fun m -> m "Fetching from verse remote %s" remote);
12271274 match Git.fetch ~proc ~fs ~remote checkout_dir with
12281275 | Ok () -> ()
12291229- | Error e -> Log.debug (fun m -> m "Failed to fetch from %s: %a" remote Git.pp_error e))
12761276+ | Error e ->
12771277+ Log.debug (fun m ->
12781278+ m "Failed to fetch from %s: %a" remote Git.pp_error e))
12301279 verse_remotes
1231128012321232-let sync ~proc ~fs ~config ?package ?(remote = false) ?(skip_push = false) ?(skip_pull = false) () =
12811281+let sync ~proc ~fs ~config ?package ?(remote = false) ?(skip_push = false)
12821282+ ?(skip_pull = false) () =
12331283 let fs_t = fs_typed fs in
12341284 (* Update the opam repo first - clone if needed *)
12351285 let opam_repo = Config.Paths.opam_repo config in
···12791329 (* git subtree push is read-only on the monorepo, so safe to parallelize *)
12801330 let push_results =
12811331 if skip_push then begin
12821282- Log.app (fun m -> m " Skipping push to checkouts (--skip-push)");
13321332+ Log.app (fun m ->
13331333+ m " Skipping push to checkouts (--skip-push)");
12831334 List.map (fun pkg -> Ok (Package.repo_name pkg)) repos
12841335 end
12851336 else begin
12861286- Log.app (fun m -> m " Pushing monorepo changes to checkouts (parallel)...");
12871287- Eio.Fiber.List.map ~max_fibers:4 (fun pkg ->
12881288- let repo_name = Package.repo_name pkg in
12891289- Log.info (fun m -> m "Push to checkout: %s" repo_name);
12901290- match push_one ~proc ~fs ~config pkg with
12911291- | Ok () -> Ok repo_name
12921292- | Error (Git_error e) ->
12931293- Error { repo_name; phase = `Push_checkout; error = e }
12941294- | Error _ -> Ok repo_name)
13371337+ Log.app (fun m ->
13381338+ m " Pushing monorepo changes to checkouts (parallel)...");
13391339+ Eio.Fiber.List.map ~max_fibers:4
13401340+ (fun pkg ->
13411341+ let repo_name = Package.repo_name pkg in
13421342+ Log.info (fun m -> m "Push to checkout: %s" repo_name);
13431343+ match push_one ~proc ~fs ~config pkg with
13441344+ | Ok () -> Ok repo_name
13451345+ | Error (Git_error e) ->
13461346+ Error { repo_name; phase = `Push_checkout; error = e }
13471347+ | Error _ -> Ok repo_name)
12951348 repos
12961349 end
12971350 in
12981351 let push_errors =
12991299- List.filter_map (function Error e -> Some e | Ok _ -> None) push_results
13521352+ List.filter_map
13531353+ (function Error e -> Some e | Ok _ -> None)
13541354+ push_results
13001355 in
1301135613021357 (* Steps 3-5: Pull phases (fetch, merge, subtree) - skip if --skip-pull *)
13031303- let fetch_errors, unchanged_count, total_commits_pulled, merge_errors, subtree_errors =
13581358+ let ( fetch_errors,
13591359+ unchanged_count,
13601360+ total_commits_pulled,
13611361+ merge_errors,
13621362+ subtree_errors ) =
13041363 if skip_pull then begin
13051305- Log.app (fun m -> m " Skipping pull from remotes (--skip-pull)");
13641364+ Log.app (fun m ->
13651365+ m " Skipping pull from remotes (--skip-pull)");
13061366 ([], List.length repos, 0, ref [], ref [])
13071367 end
13081368 else begin
13091369 (* Step 3: Fetch phase - clone/fetch from remotes (PARALLEL) *)
13101370 Log.app (fun m -> m " Fetching from remotes (parallel)...");
13111311- let fetch_results = Eio.Fiber.List.map ~max_fibers:4 (fun pkg ->
13121312- let repo_name = Package.repo_name pkg in
13131313- (* First ensure checkout exists *)
13141314- match ensure_checkout_safe ~proc ~fs:fs_t ~config pkg with
13151315- | Error e -> Error { repo_name; phase = `Fetch; error = e }
13161316- | Ok (was_cloned, _) ->
13171317- if was_cloned then Ok (repo_name, true, 0)
13181318- else
13191319- match fetch_checkout_safe ~proc ~fs:fs_t ~config pkg with
13201320- | Error e -> Error { repo_name; phase = `Fetch; error = e }
13211321- | Ok commits -> Ok (repo_name, false, commits))
13221322- repos
13711371+ let fetch_results =
13721372+ Eio.Fiber.List.map ~max_fibers:4
13731373+ (fun pkg ->
13741374+ let repo_name = Package.repo_name pkg in
13751375+ (* First ensure checkout exists *)
13761376+ match
13771377+ ensure_checkout_safe ~proc ~fs:fs_t ~config pkg
13781378+ with
13791379+ | Error e ->
13801380+ Error { repo_name; phase = `Fetch; error = e }
13811381+ | Ok (was_cloned, _) -> (
13821382+ if was_cloned then Ok (repo_name, true, 0)
13831383+ else
13841384+ match
13851385+ fetch_checkout_safe ~proc ~fs:fs_t ~config pkg
13861386+ with
13871387+ | Error e ->
13881388+ Error { repo_name; phase = `Fetch; error = e }
13891389+ | Ok commits -> Ok (repo_name, false, commits)))
13901390+ repos
13231391 in
13241392 let fetch_errs, fetch_successes =
13251325- List.partition_map (function
13261326- | Error e -> Left e
13271327- | Ok r -> Right r)
13931393+ List.partition_map
13941394+ (function Error e -> Left e | Ok r -> Right r)
13281395 fetch_results
13291396 in
13301330- let cloned = List.filter (fun (_, c, _) -> c) fetch_successes in
13311331- let updated = List.filter (fun (_, c, commits) -> not c && commits > 0) fetch_successes in
13321332- let unchanged = List.length fetch_successes - List.length cloned - List.length updated in
13331333- let commits_pulled = List.fold_left (fun acc (_, _, c) -> acc + c) 0 fetch_successes in
13341334- Log.app (fun m -> m " Pulled: %d cloned, %d updated, %d unchanged"
13351335- (List.length cloned) (List.length updated) unchanged);
13971397+ let cloned =
13981398+ List.filter (fun (_, c, _) -> c) fetch_successes
13991399+ in
14001400+ let updated =
14011401+ List.filter
14021402+ (fun (_, c, commits) -> (not c) && commits > 0)
14031403+ fetch_successes
14041404+ in
14051405+ let unchanged =
14061406+ List.length fetch_successes
14071407+ - List.length cloned - List.length updated
14081408+ in
14091409+ let commits_pulled =
14101410+ List.fold_left
14111411+ (fun acc (_, _, c) -> acc + c)
14121412+ 0 fetch_successes
14131413+ in
14141414+ Log.app (fun m ->
14151415+ m " Pulled: %d cloned, %d updated, %d unchanged"
14161416+ (List.length cloned) (List.length updated) unchanged);
1336141713371418 (* Step 4: Merge phase - fast-forward merge checkouts (SEQUENTIAL) *)
13381419 Log.app (fun m -> m " Merging checkouts...");
13391420 let merge_errs = ref [] in
13401340- List.iter (fun pkg ->
14211421+ List.iter
14221422+ (fun pkg ->
13411423 match merge_checkout_safe ~proc ~fs:fs_t ~config pkg with
13421424 | Ok () -> ()
13431425 | Error e ->
13441344- merge_errs := { repo_name = Package.repo_name pkg;
13451345- phase = `Merge; error = e } :: !merge_errs)
14261426+ merge_errs :=
14271427+ {
14281428+ repo_name = Package.repo_name pkg;
14291429+ phase = `Merge;
14301430+ error = e;
14311431+ }
14321432+ :: !merge_errs)
13461433 repos;
1347143413481435 (* Step 5: Subtree phase - pull subtrees into monorepo (SEQUENTIAL) *)
···13521439 let subtree_errs = ref [] in
13531440 if monorepo_dirty then begin
13541441 Log.warn (fun m ->
13551355- m "Monorepo has uncommitted changes, skipping subtree pulls");
13561356- Log.app (fun m -> m " Skipping subtree updates (local modifications)...")
14421442+ m
14431443+ "Monorepo has uncommitted changes, skipping subtree \
14441444+ pulls");
14451445+ Log.app (fun m ->
14461446+ m " Skipping subtree updates (local modifications)...")
13571447 end
13581448 else begin
13591449 Log.app (fun m -> m " Updating subtrees...");
13601360- List.iteri (fun i pkg ->
14501450+ List.iteri
14511451+ (fun i pkg ->
13611452 Log.info (fun m ->
13621453 m "[%d/%d] Subtree %s" (i + 1) total
13631454 (Package.subtree_prefix pkg));
13641455 match pull_subtree ~proc ~fs ~config pkg with
13651456 | Ok _ -> ()
13661457 | Error (Git_error e) ->
13671367- subtree_errs := { repo_name = Package.repo_name pkg;
13681368- phase = `Subtree; error = e } :: !subtree_errs
14581458+ subtree_errs :=
14591459+ {
14601460+ repo_name = Package.repo_name pkg;
14611461+ phase = `Subtree;
14621462+ error = e;
14631463+ }
14641464+ :: !subtree_errs
13691465 | Error _ -> ())
13701466 repos
13711467 end;
13721372- (fetch_errs, unchanged, commits_pulled, merge_errs, subtree_errs)
14681468+ ( fetch_errs,
14691469+ unchanged,
14701470+ commits_pulled,
14711471+ merge_errs,
14721472+ subtree_errs )
13731473 end
13741474 in
1375147513761476 (* Step 5.5: Verse remotes - update and fetch from verse members *)
13771477 (match Verse_config.load ~fs:(fs_t :> _ Eio.Path.t) () with
13781378- | Error _ -> () (* No verse config, skip verse remotes *)
13791379- | Ok verse_config ->
13801380- sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config repos;
13811381- (* Fetch from verse remotes in parallel *)
13821382- Log.app (fun m -> m " Fetching from verse remotes...");
13831383- Eio.Fiber.List.iter ~max_fibers:4 (fun pkg ->
13841384- fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
13851385- repos);
14781478+ | Error _ -> () (* No verse config, skip verse remotes *)
14791479+ | Ok verse_config ->
14801480+ sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config repos;
14811481+ (* Fetch from verse remotes in parallel *)
14821482+ Log.app (fun m -> m " Fetching from verse remotes...");
14831483+ Eio.Fiber.List.iter ~max_fibers:4
14841484+ (fun pkg -> fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
14851485+ repos);
1386148613871487 (* Step 6: Finalize - write README.md, CLAUDE.md, and dune-project (SEQUENTIAL) *)
13881388- Log.app (fun m -> m " Writing README.md, CLAUDE.md, and dune-project...");
14881488+ Log.app (fun m ->
14891489+ m " Writing README.md, CLAUDE.md, and dune-project...");
13891490 write_readme ~proc ~fs:fs_t ~config all_pkgs;
13901491 write_claude_md ~proc ~fs:fs_t ~config;
13911492 write_dune_project ~proc ~fs:fs_t ~config all_pkgs;
···13951496 if remote then begin
13961497 Log.app (fun m -> m " Pushing to upstream remotes...");
13971498 (* Limit to 2 concurrent pushes to avoid overwhelming remotes *)
13981398- let push_results = Eio.Fiber.List.map ~max_fibers:2 (fun pkg ->
13991399- let repo_name = Package.repo_name pkg in
14001400- match push_remote_safe ~proc ~fs:fs_t ~config pkg with
14011401- | Error e -> Error { repo_name; phase = `Push_remote; error = e }
14021402- | Ok () ->
14031403- Log.app (fun m -> m " Pushed %s" repo_name);
14041404- Ok repo_name)
14051405- repos
14991499+ let push_results =
15001500+ Eio.Fiber.List.map ~max_fibers:2
15011501+ (fun pkg ->
15021502+ let repo_name = Package.repo_name pkg in
15031503+ match push_remote_safe ~proc ~fs:fs_t ~config pkg with
15041504+ | Error e ->
15051505+ Error { repo_name; phase = `Push_remote; error = e }
15061506+ | Ok () ->
15071507+ Log.app (fun m -> m " Pushed %s" repo_name);
15081508+ Ok repo_name)
15091509+ repos
14061510 in
14071511 let errors, successes =
14081408- List.partition_map (function
14091409- | Error e -> Left e
14101410- | Ok r -> Right r)
15121512+ List.partition_map
15131513+ (function Error e -> Left e | Ok r -> Right r)
14111514 push_results
14121515 in
14131413- Log.app (fun m -> m " Pushed: %d repos to upstream" (List.length successes));
15161516+ Log.app (fun m ->
15171517+ m " Pushed: %d repos to upstream" (List.length successes));
14141518 errors
14151519 end
14161520 else []
···1418152214191523 (* Collect all errors *)
14201524 let all_errors =
14211421- push_errors @ fetch_errors @ !merge_errors @ !subtree_errors @ remote_errors
15251525+ push_errors @ fetch_errors @ !merge_errors @ !subtree_errors
15261526+ @ remote_errors
14221527 in
14231423- let summary = {
14241424- repos_synced = List.length repos - List.length all_errors;
14251425- repos_unchanged = unchanged_count;
14261426- commits_pulled = total_commits_pulled;
14271427- commits_pushed = 0; (* TODO: track this *)
14281428- errors = all_errors;
14291429- } in
15281528+ let summary =
15291529+ {
15301530+ repos_synced = List.length repos - List.length all_errors;
15311531+ repos_unchanged = unchanged_count;
15321532+ commits_pulled = total_commits_pulled;
15331533+ commits_pushed = 0;
15341534+ (* TODO: track this *)
15351535+ errors = all_errors;
15361536+ }
15371537+ in
1430153814311539 (* Print summary *)
14321432- Log.app (fun m -> m "@.Summary: %d synced, %d errors"
14331433- summary.repos_synced (List.length summary.errors));
15401540+ Log.app (fun m ->
15411541+ m "@.Summary: %d synced, %d errors" summary.repos_synced
15421542+ (List.length summary.errors));
14341543 if summary.errors <> [] then
14351435- List.iter (fun e ->
14361436- Log.warn (fun m -> m " %a" pp_sync_failure e))
15441544+ List.iter
15451545+ (fun e -> Log.warn (fun m -> m " %a" pp_sync_failure e))
14371546 summary.errors;
1438154714391548 Ok summary
···14431552(* Opam metadata sync: copy .opam files from monorepo subtrees to opam-repo *)
1444155314451554type opam_sync_result = {
14461446- synced : string list; (* packages that were updated *)
14471447- unchanged : string list; (* packages that were already in sync *)
14481448- missing : string list; (* packages where monorepo has no .opam file *)
14491449- orphaned : string list; (* packages in opam-repo but subtree missing from monorepo *)
15551555+ synced : string list; (* packages that were updated *)
15561556+ unchanged : string list; (* packages that were already in sync *)
15571557+ missing : string list; (* packages where monorepo has no .opam file *)
15581558+ orphaned : string list;
15591559+ (* packages in opam-repo but subtree missing from monorepo *)
14501560}
1451156114521562let pp_opam_sync_result ppf r =
···14551565 (List.length r.orphaned)
1456156614571567(* Read file contents safely, returning None if file doesn't exist *)
14581458-let read_file_opt path =
14591459- try Some (Eio.Path.load path)
14601460- with Eio.Io _ -> None
15681568+let read_file_opt path = try Some (Eio.Path.load path) with Eio.Io _ -> None
1461156914621570(* Sync a single package's opam file from monorepo to opam-repo *)
14631571let sync_opam_file ~proc ~fs ~config pkg =
···14681576 let version = Package.version pkg in
1469157714701578 (* Source: monorepo/<subtree>/<name>.opam *)
14711471- let src_path = Eio.Path.(fs / Fpath.to_string monorepo / subtree_prefix / (name ^ ".opam")) in
15791579+ let src_path =
15801580+ Eio.Path.(fs / Fpath.to_string monorepo / subtree_prefix / (name ^ ".opam"))
15811581+ in
1472158214731583 (* Destination: opam-repo/packages/<name>/<name>.<version>/opam *)
14741474- let pkg_dir = Fpath.(opam_repo / "packages" / name / (name ^ "." ^ version)) in
15841584+ let pkg_dir =
15851585+ Fpath.(opam_repo / "packages" / name / (name ^ "." ^ version))
15861586+ in
14751587 let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in
1476158814771589 match read_file_opt src_path with
···14801592 `Missing name
14811593 | Some src_content ->
14821594 let dst_content = read_file_opt dst_path in
14831483- if Some src_content = dst_content then
14841484- `Unchanged name
15951595+ if Some src_content = dst_content then `Unchanged name
14851596 else begin
14861597 (* Create destination directory if needed *)
14871598 let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in
···14911602 Eio.Path.save ~create:(`Or_truncate 0o644) dst_path src_content;
14921603 (* Stage the change *)
14931604 let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
14941494- let rel_path = Printf.sprintf "packages/%s/%s.%s/opam" name name version in
16051605+ let rel_path =
16061606+ Printf.sprintf "packages/%s/%s.%s/opam" name name version
16071607+ in
14951608 Eio.Switch.run (fun sw ->
14961609 let child =
14971610 Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
···15151628 if pkgs = [] && package <> None then
15161629 Error (Package_not_found (Option.get package))
15171630 else begin
15181518- Log.app (fun m -> m "Syncing opam files for %d packages..." (List.length pkgs));
16311631+ Log.app (fun m ->
16321632+ m "Syncing opam files for %d packages..." (List.length pkgs));
15191633 let synced = ref [] in
15201634 let unchanged = ref [] in
15211635 let missing = ref [] in
15221636 let orphaned = ref [] in
1523163715241638 (* Check each package *)
15251525- List.iter (fun pkg ->
16391639+ List.iter
16401640+ (fun pkg ->
15261641 (* Check if the subtree exists in monorepo *)
15271642 let monorepo = Config.Paths.monorepo config in
15281643 let subtree_prefix = Package.subtree_prefix pkg in
15291529- let subtree_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subtree_prefix in
16441644+ let subtree_exists =
16451645+ Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subtree_prefix
16461646+ in
1530164715311648 if not subtree_exists then
15321649 (* Subtree doesn't exist - package is orphaned in opam-repo *)
···15381655 | `Missing name -> missing := name :: !missing)
15391656 pkgs;
1540165715411541- let result = {
15421542- synced = List.rev !synced;
15431543- unchanged = List.rev !unchanged;
15441544- missing = List.rev !missing;
15451545- orphaned = List.rev !orphaned;
15461546- } in
16581658+ let result =
16591659+ {
16601660+ synced = List.rev !synced;
16611661+ unchanged = List.rev !unchanged;
16621662+ missing = List.rev !missing;
16631663+ orphaned = List.rev !orphaned;
16641664+ }
16651665+ in
1547166615481667 (* Commit if there were changes *)
15491668 if result.synced <> [] then begin
15501669 let opam_repo = Config.Paths.opam_repo config in
15511670 let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
15521552- let msg = Printf.sprintf "Sync opam files from monorepo (%d packages)"
15531553- (List.length result.synced) in
16711671+ let msg =
16721672+ Printf.sprintf "Sync opam files from monorepo (%d packages)"
16731673+ (List.length result.synced)
16741674+ in
15541675 Eio.Switch.run (fun sw ->
15551676 let child =
15561677 Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
···1562168315631684 (* Report orphaned packages *)
15641685 if result.orphaned <> [] then begin
15651565- Log.warn (fun m -> m "Found %d orphaned packages in opam-repo (subtree missing from monorepo):"
15661566- (List.length result.orphaned));
15671567- List.iter (fun name ->
15681568- Log.warn (fun m -> m " %s" name))
16861686+ Log.warn (fun m ->
16871687+ m
16881688+ "Found %d orphaned packages in opam-repo (subtree missing from \
16891689+ monorepo):"
16901690+ (List.length result.orphaned));
16911691+ List.iter
16921692+ (fun name -> Log.warn (fun m -> m " %s" name))
15691693 result.orphaned;
15701570- Log.warn (fun m -> m "To remove, delete from opam-repo/packages/ and commit.")
16941694+ Log.warn (fun m ->
16951695+ m "To remove, delete from opam-repo/packages/ and commit.")
15711696 end;
1572169715731698 Log.app (fun m -> m "%a" pp_opam_sync_result result);
···1604172916051730(* Changes command - generate weekly changelogs using Claude *)
1606173116071607-let changes ~proc ~fs ~config ~clock ?package ?(weeks = 1) ?(history = 12) ?(dry_run = false) () =
17321732+let changes ~proc ~fs ~config ~clock ?package ?(weeks = 1) ?(history = 12)
17331733+ ?(dry_run = false) () =
16081734 let fs_t = fs_typed fs in
16091735 let monorepo = Config.Paths.monorepo config in
1610173616111737 (* Get current time and calculate week boundaries *)
16121738 let now = Eio.Time.now clock in
16131613- let now_ptime = match Ptime.of_float_s now with
16141614- | Some t -> t
16151615- | None -> Ptime.v (0, 0L) (* fallback to epoch *)
17391739+ let now_ptime =
17401740+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
17411741+ (* fallback to epoch *)
16161742 in
1617174316181744 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
16191745 | Error e -> Error e
16201746 | Ok all_pkgs ->
16211747 let repos = unique_repos all_pkgs in
16221622- let repos = match package with
17481748+ let repos =
17491749+ match package with
16231750 | None -> repos
16241751 | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
16251752 in
16261753 if repos = [] && package <> None then
16271754 Error (Package_not_found (Option.get package))
16281755 else begin
16291629- Log.info (fun m -> m "Processing changelogs for %d repositories" (List.length repos));
17561756+ Log.info (fun m ->
17571757+ m "Processing changelogs for %d repositories" (List.length repos));
1630175816311759 (* Process each repository *)
16321760 let all_changes_files = ref [] in
16331761 let rec process_repos = function
16341762 | [] -> Ok ()
16351635- | pkg :: rest ->
17631763+ | pkg :: rest -> (
16361764 let repo_name = Package.repo_name pkg in
1637176516381766 Log.info (fun m -> m "Processing %s" repo_name);
···16401768 (* Load existing changes from .changes/<repo>.json *)
16411769 match Changes.load ~fs:fs_t ~monorepo repo_name with
16421770 | Error e -> Error (Claude_error e)
16431643- | Ok changes_file ->
17711771+ | Ok changes_file -> (
16441772 (* Process each week *)
16451773 let rec process_weeks week_offset updated_cf =
16461774 if week_offset >= weeks then Ok updated_cf
16471775 else begin
16481776 (* Calculate week boundaries *)
16491649- let offset_seconds = float_of_int (week_offset * 7 * 24 * 60 * 60) in
16501650- let week_time = match Ptime.of_float_s (now -. offset_seconds) with
17771777+ let offset_seconds =
17781778+ float_of_int (week_offset * 7 * 24 * 60 * 60)
17791779+ in
17801780+ let week_time =
17811781+ match Ptime.of_float_s (now -. offset_seconds) with
16511782 | Some t -> t
16521783 | None -> now_ptime
16531784 in
16541654- let week_start, week_end = Changes.week_of_ptime week_time in
17851785+ let week_start, week_end =
17861786+ Changes.week_of_ptime week_time
17871787+ in
1655178816561789 (* Skip if week already has an entry *)
16571790 if Changes.has_week updated_cf ~week_start then begin
16581658- Log.info (fun m -> m " Week %s already has entry, skipping" week_start);
17911791+ Log.info (fun m ->
17921792+ m " Week %s already has entry, skipping" week_start);
16591793 process_weeks (week_offset + 1) updated_cf
16601794 end
16611795 else begin
16621796 (* Get commits for this week *)
16631797 let since = week_start ^ " 00:00:00" in
16641798 let until = week_end ^ " 23:59:59" in
16651665- match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
17991799+ match
18001800+ Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name
18011801+ monorepo
18021802+ with
16661803 | Error e -> Error (Git_error e)
16671804 | Ok commits ->
16681805 if commits = [] then begin
16691669- Log.info (fun m -> m " No commits for week %s" week_start);
18061806+ Log.info (fun m ->
18071807+ m " No commits for week %s" week_start);
16701808 process_weeks (week_offset + 1) updated_cf
16711809 end
16721810 else begin
16731673- Log.info (fun m -> m " Found %d commits for week %s" (List.length commits) week_start);
18111811+ Log.info (fun m ->
18121812+ m " Found %d commits for week %s"
18131813+ (List.length commits) week_start);
1674181416751815 if dry_run then begin
16761676- Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s week %s"
16771677- (List.length commits) repo_name week_start);
18161816+ Log.app (fun m ->
18171817+ m
18181818+ " [DRY RUN] Would analyze %d commits \
18191819+ for %s week %s"
18201820+ (List.length commits) repo_name week_start);
16781821 process_weeks (week_offset + 1) updated_cf
16791822 end
16801823 else begin
16811824 (* Analyze commits with Claude *)
16821825 Eio.Switch.run @@ fun sw ->
16831683- match Changes.analyze_commits ~sw ~process_mgr:proc ~clock
16841684- ~repository:repo_name ~week_start ~week_end commits with
18261826+ match
18271827+ Changes.analyze_commits ~sw ~process_mgr:proc
18281828+ ~clock ~repository:repo_name ~week_start
18291829+ ~week_end commits
18301830+ with
16851831 | Error e -> Error (Claude_error e)
16861832 | Ok None ->
16871687- Log.info (fun m -> m " No user-facing changes for week %s" week_start);
18331833+ Log.info (fun m ->
18341834+ m " No user-facing changes for week %s"
18351835+ week_start);
16881836 process_weeks (week_offset + 1) updated_cf
16891837 | Ok (Some response) ->
16901690- Log.app (fun m -> m " Generated changelog for %s week %s" repo_name week_start);
18381838+ Log.app (fun m ->
18391839+ m " Generated changelog for %s week %s"
18401840+ repo_name week_start);
16911841 (* Create new entry *)
16921692- let first_hash = (List.hd commits).Git.hash in
16931693- let last_hash = (List.hd (List.rev commits)).Git.hash in
16941694- let entry : Changes.weekly_entry = {
16951695- week_start;
16961696- week_end;
16971697- summary = response.Changes.summary;
16981698- changes = response.Changes.changes;
16991699- commit_range = {
17001700- from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
17011701- to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
17021702- count = List.length commits;
17031703- };
17041704- } in
18421842+ let first_hash =
18431843+ (List.hd commits).Git.hash
18441844+ in
18451845+ let last_hash =
18461846+ (List.hd (List.rev commits)).Git.hash
18471847+ in
18481848+ let entry : Changes.weekly_entry =
18491849+ {
18501850+ week_start;
18511851+ week_end;
18521852+ summary = response.Changes.summary;
18531853+ changes = response.Changes.changes;
18541854+ commit_range =
18551855+ {
18561856+ from_hash =
18571857+ String.sub first_hash 0
18581858+ (min 7
18591859+ (String.length first_hash));
18601860+ to_hash =
18611861+ String.sub last_hash 0
18621862+ (min 7 (String.length last_hash));
18631863+ count = List.length commits;
18641864+ };
18651865+ }
18661866+ in
17051867 (* Add entry (sorted by date descending) *)
17061868 let new_entries =
17071869 entry :: updated_cf.Changes.entries
17081870 |> List.sort (fun e1 e2 ->
17091709- String.compare e2.Changes.week_start e1.Changes.week_start)
18711871+ String.compare e2.Changes.week_start
18721872+ e1.Changes.week_start)
17101873 in
17111874 process_weeks (week_offset + 1)
17121875 { updated_cf with entries = new_entries }
···17171880 in
17181881 match process_weeks 0 changes_file with
17191882 | Error e -> Error e
17201720- | Ok updated_cf ->
18831883+ | Ok updated_cf -> (
17211884 (* Save if changed and not dry run *)
17221885 let save_result =
17231723- if not dry_run && updated_cf.entries <> changes_file.entries then
18861886+ if
18871887+ (not dry_run)
18881888+ && updated_cf.entries <> changes_file.entries
18891889+ then (
17241890 match Changes.save ~fs:fs_t ~monorepo updated_cf with
17251891 | Error e -> Error (Claude_error e)
17261892 | Ok () ->
17271727- Log.app (fun m -> m "Saved .changes/%s.json" repo_name);
17281728- Ok ()
18931893+ Log.app (fun m ->
18941894+ m "Saved .changes/%s.json" repo_name);
18951895+ Ok ())
17291896 else Ok ()
17301897 in
17311898 match save_result with
17321899 | Error e -> Error e
17331900 | Ok () ->
17341901 all_changes_files := updated_cf :: !all_changes_files;
17351735- process_repos rest
19021902+ process_repos rest)))
17361903 in
17371904 match process_repos repos with
17381905 | Error e -> Error e
17391906 | Ok () ->
17401907 (* Generate aggregated CHANGES.md *)
17411741- if not dry_run && !all_changes_files <> [] then begin
19081908+ if (not dry_run) && !all_changes_files <> [] then begin
17421909 let markdown = Changes.aggregate ~history !all_changes_files in
17431743- let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "CHANGES.md") in
17441744- Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
19101910+ let changes_md_path =
19111911+ Eio.Path.(fs_t / Fpath.to_string monorepo / "CHANGES.md")
19121912+ in
19131913+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path
19141914+ markdown;
17451915 Log.app (fun m -> m "Generated CHANGES.md at monorepo root")
17461916 end;
17471917 Ok ()
···1749191917501920(* Daily changes command - generate daily changelogs using Claude *)
1751192117521752-let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30) ?(dry_run = false) ?(aggregate = false) () =
19221922+let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30)
19231923+ ?(dry_run = false) ?(aggregate = false) () =
17531924 let fs_t = fs_typed fs in
17541925 let monorepo = Config.Paths.monorepo config in
1755192617561927 (* Get current time *)
17571928 let now = Eio.Time.now clock in
17581758- let now_ptime = match Ptime.of_float_s now with
17591759- | Some t -> t
17601760- | None -> Ptime.v (0, 0L) (* fallback to epoch *)
19291929+ let now_ptime =
19301930+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
19311931+ (* fallback to epoch *)
17611932 in
1762193317631934 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
17641935 | Error e -> Error e
17651936 | Ok all_pkgs ->
17661937 let repos = unique_repos all_pkgs in
17671767- let repos = match package with
19381938+ let repos =
19391939+ match package with
17681940 | None -> repos
17691941 | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
17701942 in
17711943 if repos = [] && package <> None then
17721944 Error (Package_not_found (Option.get package))
17731945 else begin
17741774- Log.info (fun m -> m "Processing daily changelogs for %d repositories" (List.length repos));
19461946+ Log.info (fun m ->
19471947+ m "Processing daily changelogs for %d repositories"
19481948+ (List.length repos));
1775194917761950 (* Process each repository *)
17771951 let all_changes_files = ref [] in
17781952 let rec process_repos = function
17791953 | [] -> Ok ()
17801780- | pkg :: rest ->
19541954+ | pkg :: rest -> (
17811955 let repo_name = Package.repo_name pkg in
1782195617831957 Log.info (fun m -> m "Processing %s" repo_name);
···17871961 if day_offset >= days then Ok ()
17881962 else begin
17891963 (* Calculate day boundaries *)
17901790- let offset_seconds = float_of_int (day_offset * 24 * 60 * 60) in
17911791- let day_time = match Ptime.of_float_s (now -. offset_seconds) with
19641964+ let offset_seconds =
19651965+ float_of_int (day_offset * 24 * 60 * 60)
19661966+ in
19671967+ let day_time =
19681968+ match Ptime.of_float_s (now -. offset_seconds) with
17921969 | Some t -> t
17931970 | None -> now_ptime
17941971 in
···17991976 (* For today, skip only if file has entries (may need to catch new commits) *)
18001977 let should_skip =
18011978 if is_today then
18021802- Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name &&
18031803- (match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
18041804- | Ok cf -> Changes.has_day cf ~date
18051805- | Error _ -> false)
18061806- else
18071979 Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name
19801980+ &&
19811981+ match
19821982+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
19831983+ with
19841984+ | Ok cf -> Changes.has_day cf ~date
19851985+ | Error _ -> false
19861986+ else Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name
18081987 in
18091988 if should_skip then begin
18101810- Log.info (fun m -> m " Day %s already processed, skipping" date);
18111811- (match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
18121812- | Ok cf -> all_changes_files := cf :: !all_changes_files
18131813- | Error _ -> ());
19891989+ Log.info (fun m ->
19901990+ m " Day %s already processed, skipping" date);
19911991+ (match
19921992+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
19931993+ with
19941994+ | Ok cf -> all_changes_files := cf :: !all_changes_files
19951995+ | Error _ -> ());
18141996 process_days (day_offset + 1)
18151997 end
18161998 else
18171999 (* Load existing daily changes from .changes/<repo>-<date>.json *)
18181818- match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
20002000+ match
20012001+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
20022002+ with
18192003 | Error e -> Error (Claude_error e)
18201820- | Ok changes_file ->
20042004+ | Ok changes_file -> (
18212005 (* Get commits for this day *)
18222006 let since = date ^ " 00:00:00" in
18232007 let until = date ^ " 23:59:59" in
18241824- match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
20082008+ match
20092009+ Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name
20102010+ monorepo
20112011+ with
18252012 | Error e -> Error (Git_error e)
18262013 | Ok commits ->
18272014 if commits = [] then begin
18281828- Log.info (fun m -> m " No commits for day %s" date);
20152015+ Log.info (fun m ->
20162016+ m " No commits for day %s" date);
18292017 process_days (day_offset + 1)
18302018 end
18312019 else begin
18321832- Log.info (fun m -> m " Found %d commits for day %s" (List.length commits) date);
20202020+ Log.info (fun m ->
20212021+ m " Found %d commits for day %s"
20222022+ (List.length commits) date);
1833202318342024 if dry_run then begin
18351835- Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s on %s"
18361836- (List.length commits) repo_name date);
20252025+ Log.app (fun m ->
20262026+ m
20272027+ " [DRY RUN] Would analyze %d commits \
20282028+ for %s on %s"
20292029+ (List.length commits) repo_name date);
18372030 process_days (day_offset + 1)
18382031 end
18392032 else begin
18402033 (* Analyze commits with Claude *)
18412034 Eio.Switch.run @@ fun sw ->
18421842- match Changes.analyze_commits_daily ~sw ~process_mgr:proc ~clock
18431843- ~repository:repo_name ~date commits with
20352035+ match
20362036+ Changes.analyze_commits_daily ~sw
20372037+ ~process_mgr:proc ~clock
20382038+ ~repository:repo_name ~date commits
20392039+ with
18442040 | Error e -> Error (Claude_error e)
18452041 | Ok None ->
18461846- Log.info (fun m -> m " No user-facing changes for day %s" date);
20422042+ Log.info (fun m ->
20432043+ m " No user-facing changes for day %s"
20442044+ date);
18472045 process_days (day_offset + 1)
18481848- | Ok (Some response) ->
18491849- Log.app (fun m -> m " Generated changelog for %s on %s" repo_name date);
20462046+ | Ok (Some response) -> (
20472047+ Log.app (fun m ->
20482048+ m " Generated changelog for %s on %s"
20492049+ repo_name date);
18502050 (* Extract unique contributors from commits *)
18512051 let contributors =
18522052 commits
18531853- |> List.map (fun (c : Git.log_entry) -> c.author)
20532053+ |> List.map (fun (c : Git.log_entry) ->
20542054+ c.author)
18542055 |> List.sort_uniq String.compare
18552056 in
18562057 (* Get repo URL from package dev_repo *)
···18582059 let uri = Package.dev_repo pkg in
18592060 let url = Uri.to_string uri in
18602061 (* Strip git+ prefix if present for display *)
18611861- if String.starts_with ~prefix:"git+" url then
18621862- Some (String.sub url 4 (String.length url - 4))
18631863- else
18641864- Some url
20622062+ if String.starts_with ~prefix:"git+" url
20632063+ then
20642064+ Some
20652065+ (String.sub url 4
20662066+ (String.length url - 4))
20672067+ else Some url
18652068 in
18662069 (* Create new entry with hour and timestamp *)
18671867- let first_hash = (List.hd commits).Git.hash in
18681868- let last_hash = (List.hd (List.rev commits)).Git.hash in
18691869- let (_, ((hour, _, _), _)) = Ptime.to_date_time now_ptime in
18701870- let entry : Changes.daily_entry = {
18711871- date;
18721872- hour;
18731873- timestamp = now_ptime;
18741874- summary = response.Changes.summary;
18751875- changes = response.Changes.changes;
18761876- commit_range = {
18771877- from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
18781878- to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
18791879- count = List.length commits;
18801880- };
18811881- contributors;
18821882- repo_url;
18831883- } in
20702070+ let first_hash =
20712071+ (List.hd commits).Git.hash
20722072+ in
20732073+ let last_hash =
20742074+ (List.hd (List.rev commits)).Git.hash
20752075+ in
20762076+ let _, ((hour, _, _), _) =
20772077+ Ptime.to_date_time now_ptime
20782078+ in
20792079+ let entry : Changes.daily_entry =
20802080+ {
20812081+ date;
20822082+ hour;
20832083+ timestamp = now_ptime;
20842084+ summary = response.Changes.summary;
20852085+ changes = response.Changes.changes;
20862086+ commit_range =
20872087+ {
20882088+ from_hash =
20892089+ String.sub first_hash 0
20902090+ (min 7
20912091+ (String.length first_hash));
20922092+ to_hash =
20932093+ String.sub last_hash 0
20942094+ (min 7 (String.length last_hash));
20952095+ count = List.length commits;
20962096+ };
20972097+ contributors;
20982098+ repo_url;
20992099+ }
21002100+ in
18842101 (* Add entry (sorted by timestamp descending) *)
18852102 let new_entries =
18862103 entry :: changes_file.Changes.entries
18872104 |> List.sort (fun e1 e2 ->
18881888- Ptime.compare e2.Changes.timestamp e1.Changes.timestamp)
21052105+ Ptime.compare e2.Changes.timestamp
21062106+ e1.Changes.timestamp)
18892107 in
18901890- let updated_cf = { changes_file with Changes.entries = new_entries } in
21082108+ let updated_cf =
21092109+ {
21102110+ changes_file with
21112111+ Changes.entries = new_entries;
21122112+ }
21132113+ in
18912114 (* Save the per-day file *)
18921892- match Changes.save_daily ~fs:fs_t ~monorepo ~date updated_cf with
21152115+ match
21162116+ Changes.save_daily ~fs:fs_t ~monorepo
21172117+ ~date updated_cf
21182118+ with
18932119 | Error e -> Error (Claude_error e)
18942120 | Ok () ->
18951895- Log.app (fun m -> m "Saved .changes/%s-%s.json" repo_name date);
18961896- all_changes_files := updated_cf :: !all_changes_files;
18971897- process_days (day_offset + 1)
21212121+ Log.app (fun m ->
21222122+ m "Saved .changes/%s-%s.json"
21232123+ repo_name date);
21242124+ all_changes_files :=
21252125+ updated_cf :: !all_changes_files;
21262126+ process_days (day_offset + 1))
18982127 end
18991899- end
21282128+ end)
19002129 end
19012130 in
19022131 match process_days 0 with
19032132 | Error e -> Error e
19041904- | Ok () -> process_repos rest
21332133+ | Ok () -> process_repos rest)
19052134 in
19062135 match process_repos repos with
19072136 | Error e -> Error e
19082137 | Ok () ->
19092138 (* Generate aggregated DAILY-CHANGES.md *)
19101910- if not dry_run && !all_changes_files <> [] then begin
19111911- let raw_markdown = Changes.aggregate_daily ~history !all_changes_files in
21392139+ if (not dry_run) && !all_changes_files <> [] then begin
21402140+ let raw_markdown =
21412141+ Changes.aggregate_daily ~history !all_changes_files
21422142+ in
19122143 (* Refine the markdown through Claude for better narrative *)
19132144 Log.info (fun m -> m "Refining daily changelog with Claude...");
19141914- let markdown = Eio.Switch.run @@ fun sw ->
19151915- match Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock raw_markdown with
21452145+ let markdown =
21462146+ Eio.Switch.run @@ fun sw ->
21472147+ match
21482148+ Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock
21492149+ raw_markdown
21502150+ with
19162151 | Ok refined ->
19171917- Log.app (fun m -> m "Refined daily changelog for readability");
21522152+ Log.app (fun m ->
21532153+ m "Refined daily changelog for readability");
19182154 refined
19192155 | Error e ->
19201920- Log.warn (fun m -> m "Failed to refine changelog: %s (using raw version)" e);
21562156+ Log.warn (fun m ->
21572157+ m "Failed to refine changelog: %s (using raw version)" e);
19212158 raw_markdown
19222159 in
19231923- let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md") in
19241924- Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
21602160+ let changes_md_path =
21612161+ Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md")
21622162+ in
21632163+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path
21642164+ markdown;
19252165 Log.app (fun m -> m "Generated DAILY-CHANGES.md at monorepo root")
19262166 end;
19272167 (* Generate aggregated JSON file if requested *)
19281928- if not dry_run && aggregate then begin
21682168+ if (not dry_run) && aggregate then begin
19292169 let today = Changes.date_of_ptime now_ptime in
19302170 let git_head =
19312171 match Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD" monorepo with
19322172 | Ok hash -> String.sub hash 0 (min 7 (String.length hash))
19332173 | Error _ -> "unknown"
19342174 in
19351935- match Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today ~git_head ~now:now_ptime with
19361936- | Ok () -> Log.app (fun m -> m "Generated aggregated file .changes/%s.json"
19371937- (String.concat "" (String.split_on_char '-' today)))
19381938- | Error e -> Log.warn (fun m -> m "Failed to generate aggregated file: %s" e)
21752175+ match
21762176+ Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today
21772177+ ~git_head ~now:now_ptime
21782178+ with
21792179+ | Ok () ->
21802180+ Log.app (fun m ->
21812181+ m "Generated aggregated file .changes/%s.json"
21822182+ (String.concat "" (String.split_on_char '-' today)))
21832183+ | Error e ->
21842184+ Log.warn (fun m ->
21852185+ m "Failed to generate aggregated file: %s" e)
19392186 end;
19402187 Ok ()
19412188 end
+39-38
lib/monopam.mli
···5151(** [pp_error] formats errors. *)
52525353val pp_error_with_hint : error Fmt.t
5454-(** [pp_error_with_hint] formats errors with a helpful hint for resolving them. *)
5454+(** [pp_error_with_hint] formats errors with a helpful hint for resolving them.
5555+*)
55565657val error_hint : error -> string option
5758(** [error_hint e] returns a hint string for the given error, if available. *)
···8182 ?opam_repo_url:string ->
8283 unit ->
8384 (unit, error) result
8484-(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from remotes.
8585+(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from
8686+ remotes.
85878688 For each package (or the specified package): 1. Clones or fetches the
8789 individual checkout 2. Adds or pulls the subtree in the monorepo
···9597 @param fs Eio filesystem
9698 @param config Monopam configuration
9799 @param package Optional specific package to pull
9898- @param opam_repo_url Optional URL to clone opam-repo from if it doesn't exist *)
100100+ @param opam_repo_url
101101+ Optional URL to clone opam-repo from if it doesn't exist *)
99102100103(** {2 Push} *)
101104···127130128131(** {2 Sync} *)
129132130130-(** Phase where a sync failure occurred. *)
131133type sync_phase = [ `Push_checkout | `Fetch | `Merge | `Subtree | `Push_remote ]
134134+(** Phase where a sync failure occurred. *)
132135133133-(** A failure during sync for a specific repository. *)
134136type sync_failure = {
135137 repo_name : string;
136138 phase : sync_phase;
137139 error : Git.error;
138140}
141141+(** A failure during sync for a specific repository. *)
139142140140-(** Summary of a sync operation. *)
141143type sync_summary = {
142144 repos_synced : int;
143145 repos_unchanged : int;
···145147 commits_pushed : int;
146148 errors : sync_failure list;
147149}
150150+(** Summary of a sync operation. *)
148151149152val pp_sync_phase : sync_phase Fmt.t
150153(** [pp_sync_phase] formats a sync phase. *)
···168171(** [sync ~proc ~fs ~config ?package ?remote ?skip_push ?skip_pull ()]
169172 synchronizes the monorepo with upstream repositories.
170173171171- This is the primary command for all sync operations. It performs both
172172- push and pull operations in the correct order:
173173- 1. Validate: check for dirty state (abort if dirty)
174174- 2. Push phase: export monorepo changes to checkouts (parallel)
175175- 3. Fetch phase: clone/fetch from remotes (parallel)
176176- 4. Merge phase: fast-forward merge checkouts (sequential)
177177- 5. Subtree phase: pull subtrees into monorepo (sequential)
178178- 6. Finalize: write README.md and dune-project (sequential)
179179- 7. Remote phase: push to upstream remotes if [~remote:true] (parallel)
174174+ This is the primary command for all sync operations. It performs both push
175175+ and pull operations in the correct order: 1. Validate: check for dirty state
176176+ (abort if dirty) 2. Push phase: export monorepo changes to checkouts
177177+ (parallel) 3. Fetch phase: clone/fetch from remotes (parallel) 4. Merge
178178+ phase: fast-forward merge checkouts (sequential) 5. Subtree phase: pull
179179+ subtrees into monorepo (sequential) 6. Finalize: write README.md and
180180+ dune-project (sequential) 7. Remote phase: push to upstream remotes if
181181+ [~remote:true] (parallel)
180182181183 The fetch and remote push phases run concurrently for improved performance.
182184···190192191193(** {2 Opam Metadata Sync} *)
192194193193-(** Result of syncing opam files from monorepo to opam-repo. *)
194195type opam_sync_result = {
195196 synced : string list; (** Packages that were updated *)
196197 unchanged : string list; (** Packages that were already in sync *)
197198 missing : string list; (** Packages where monorepo has no .opam file *)
198198- orphaned : string list; (** Packages in opam-repo but subtree missing from monorepo *)
199199+ orphaned : string list;
200200+ (** Packages in opam-repo but subtree missing from monorepo *)
199201}
202202+(** Result of syncing opam files from monorepo to opam-repo. *)
200203201204val pp_opam_sync_result : opam_sync_result Fmt.t
202205(** [pp_opam_sync_result] formats an opam sync result. *)
···211214(** [sync_opam_files ~proc ~fs ~config ?package ()] synchronizes .opam files
212215 from monorepo subtrees to the opam-repo overlay.
213216214214- For each package (or the specified package):
215215- 1. Checks if the subtree exists in the monorepo
216216- 2. If subtree missing, reports as orphaned (needs manual removal)
217217- 3. Reads the .opam file from the monorepo subtree
218218- 4. Compares with the opam-repo version
219219- 5. If different, copies monorepo → opam-repo (local always wins)
220220- 6. Stages and commits changes in opam-repo
217217+ For each package (or the specified package): 1. Checks if the subtree exists
218218+ in the monorepo 2. If subtree missing, reports as orphaned (needs manual
219219+ removal) 3. Reads the .opam file from the monorepo subtree 4. Compares with
220220+ the opam-repo version 5. If different, copies monorepo → opam-repo (local
221221+ always wins) 6. Stages and commits changes in opam-repo
221222222223 Orphaned packages (in opam-repo but subtree missing from monorepo) are
223224 reported with a warning suggesting manual removal.
···317318(** [changes ~proc ~fs ~config ~clock ?package ?weeks ?history ?dry_run ()]
318319 generates weekly changelog entries using Claude AI.
319320320320- For each repository (or the specified package's repository):
321321- 1. Loads or creates .changes/<repo>.json
322322- 2. For each week that doesn't have an entry, retrieves git commits
323323- 3. Sends commits to Claude for analysis
324324- 4. Saves changelog entries back to .changes/<repo>.json
321321+ For each repository (or the specified package's repository): 1. Loads or
322322+ creates .changes/<repo>.json 2. For each week that doesn't have an entry,
323323+ retrieves git commits 3. Sends commits to Claude for analysis 4. Saves
324324+ changelog entries back to .changes/<repo>.json
325325326326 Also generates an aggregated CHANGES.md at the monorepo root.
327327···346346 ?aggregate:bool ->
347347 unit ->
348348 (unit, error) result
349349-(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run ?aggregate ()]
350350- generates daily changelog entries using Claude AI.
349349+(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run
350350+ ?aggregate ()] generates daily changelog entries using Claude AI.
351351352352- For each repository (or the specified package's repository):
353353- 1. Loads or creates .changes/<repo>-daily.json
354354- 2. For each day that doesn't have an entry, retrieves git commits
355355- 3. Sends commits to Claude for analysis
356356- 4. Saves changelog entries back to .changes/<repo>-daily.json
352352+ For each repository (or the specified package's repository): 1. Loads or
353353+ creates .changes/<repo>-daily.json 2. For each day that doesn't have an
354354+ entry, retrieves git commits 3. Sends commits to Claude for analysis 4.
355355+ Saves changelog entries back to .changes/<repo>-daily.json
357356358357 Also generates an aggregated DAILY-CHANGES.md at the monorepo root.
359358 Repositories with no user-facing changes will have blank entries.
···367366 @param clock Eio clock for time operations
368367 @param package Optional specific repository to process
369368 @param days Number of past days to analyze (default: 1)
370370- @param history Number of recent days to include in DAILY-CHANGES.md (default: 30)
369369+ @param history
370370+ Number of recent days to include in DAILY-CHANGES.md (default: 30)
371371 @param dry_run If true, preview changes without writing files
372372- @param aggregate If true, also generate .changes/YYYYMMDD.json aggregated file *)
372372+ @param aggregate
373373+ If true, also generate .changes/YYYYMMDD.json aggregated file *)
+9-10
lib/opam_repo.ml
···5959 | OP.Option (inner, _) -> extract_dep_name inner
6060 | _ -> None
61616262-(** Extract all dependency package names from a depends value.
6363- 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. *)
6464let extract_depends_list (v : OP.value) : string list =
6565 match v.pelem with
6666- | OP.List { pelem = items; _ } ->
6767- List.filter_map extract_dep_name items
6868- | _ -> (
6969- 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 -> [])
70687169let find_depends (items : OP.opamfile_item list) : string list =
7270 List.find_map
···163161 let _, errors = scan_all ~fs repo_path in
164162 errors
165163166166-(** Scan a directory for .opam files and extract all dependencies.
167167- This is used to find dependencies from monorepo subtree directories,
168168- 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. *)
169167let scan_opam_files_for_deps ~fs dir_path =
170168 let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in
171169 try
···179177 try
180178 let content = Eio.Path.load opam_path in
181179 let opamfile =
182182- OpamParser.FullPos.string content (Fpath.to_string dir_path ^ "/" ^ opam_file)
180180+ OpamParser.FullPos.string content
181181+ (Fpath.to_string dir_path ^ "/" ^ opam_file)
183182 in
184183 find_depends opamfile.file_contents
185184 with _ -> [])
+4-3
lib/opam_repo.mli
···8080(** [scan_opam_files_for_deps ~fs dir_path] scans a directory for .opam files
8181 and extracts all dependencies from them.
82828383- This is used to find dependencies from monorepo subtree directories,
8484- 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.
85858686 @param fs Eio filesystem capability
8787 @param dir_path Path to the directory to scan
···9090(** {1 Low-level Opam File Parsing} *)
91919292val find_dev_repo : OpamParserTypes.FullPos.opamfile_item list -> string option
9393-(** [find_dev_repo items] extracts the dev-repo field from parsed opam file items. *)
9393+(** [find_dev_repo items] extracts the dev-repo field from parsed opam file
9494+ items. *)
+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. *)
+85-49
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
···155166 (* Helper to print remote sync info *)
156167 let pp_remote ab =
157168 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)
169169+ Fmt.pf ppf " %a"
170170+ Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b))
171171+ (ab.ahead, ab.behind)
159172 else if ab.ahead > 0 then
160160- Fmt.pf ppf " %a" Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n)) ab.ahead
173173+ Fmt.pf ppf " %a"
174174+ Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n))
175175+ ab.ahead
161176 else if ab.behind > 0 then
162162- Fmt.pf ppf " %a" Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n)) ab.behind
177177+ Fmt.pf ppf " %a"
178178+ Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n))
179179+ ab.behind
163180 in
164181 match (t.checkout, t.subtree, t.subtree_sync) with
165182 (* Local sync issues with count *)
166183 | 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;
184184+ Fmt.pf ppf "%-22s %a" name
185185+ Fmt.(styled `Blue (fun ppf n -> pf ppf "local:-%d" n))
186186+ n;
168187 pp_remote ab
169188 | 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;
189189+ Fmt.pf ppf "%-22s %a" name
190190+ Fmt.(styled `Blue (fun ppf n -> pf ppf "local:+%d" n))
191191+ n;
171192 pp_remote ab
172193 (* Trees differ but can't determine count *)
173194 | Clean ab, Present, Trees_differ ->
···175196 pp_remote ab
176197 (* Remote sync issues only *)
177198 | 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)
199199+ Fmt.pf ppf "%-22s %a" name
200200+ Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b))
201201+ (ab.ahead, ab.behind)
179202 | 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
203203+ Fmt.pf ppf "%-22s %a" name
204204+ Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n))
205205+ ab.ahead
181206 | 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
207207+ Fmt.pf ppf "%-22s %a" name
208208+ Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n))
209209+ ab.behind
183210 (* Other issues *)
184211 | Clean _, Not_added, _ ->
185212 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Magenta string) "(no subtree)"
···197224 let actionable = filter_actionable statuses in
198225 let synced = List.filter is_fully_synced statuses |> List.length in
199226 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
227227+ let local_sync_needed =
228228+ List.filter needs_local_sync statuses |> List.length
229229+ in
201230 let remote_needed = List.filter needs_remote_action statuses |> List.length in
202231 let action_count = List.length actionable in
203232 (* Header line with colors *)
204233 if dirty > 0 then
205234 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
235235+ Fmt.(styled `Bold string)
236236+ "Packages:" total
237237+ Fmt.(styled `Green int)
238238+ synced
239239+ Fmt.(styled `Yellow int)
240240+ dirty
209241 else if action_count > 0 then begin
210242 Fmt.pf ppf "%a %d total, %a synced"
211211- Fmt.(styled `Bold string) "Packages:" total
212212- Fmt.(styled `Green int) synced;
243243+ Fmt.(styled `Bold string)
244244+ "Packages:" total
245245+ Fmt.(styled `Green int)
246246+ synced;
213247 if local_sync_needed > 0 then
214248 Fmt.pf ppf ", %a local sync" Fmt.(styled `Blue int) local_sync_needed;
215249 if remote_needed > 0 then
···218252 end
219253 else
220254 Fmt.pf ppf "%a %d total, %a\n"
221221- Fmt.(styled `Bold string) "Packages:" total
222222- Fmt.(styled `Green string) "all synced";
255255+ Fmt.(styled `Bold string)
256256+ "Packages:" total
257257+ Fmt.(styled `Green string)
258258+ "all synced";
223259 (* Only show actionable items *)
224260 if actionable <> [] then
225261 List.iter (fun t -> Fmt.pf ppf " %a\n" pp_compact t) actionable
+2-3
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
+104-71
lib/verse.ml
···16161717let error_hint = function
1818 | Config_error _ ->
1919- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace."
1919+ Some
2020+ "Run 'monopam verse init --handle <your-handle>' to create a workspace."
2021 | Git_error (Git.Dirty_worktree _) ->
2122 Some "Commit or stash your changes first: git status"
2222- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git clone" cmd ->
2323+ | Git_error (Git.Command_failed (cmd, _))
2424+ when String.starts_with ~prefix:"git clone" cmd ->
2325 Some "Check the URL is correct and you have network access."
2424- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git pull" cmd ->
2626+ | Git_error (Git.Command_failed (cmd, _))
2727+ when String.starts_with ~prefix:"git pull" cmd ->
2528 Some "Check your network connection. Try: git fetch origin"
2629 | Git_error _ -> None
2730 | Registry_error _ ->
2831 Some "The registry may be temporarily unavailable. Try again later."
2932 | Member_not_found h ->
3030- Some (Fmt.str "Check available members: monopam verse members (looking for '%s')" h)
3333+ Some
3434+ (Fmt.str
3535+ "Check available members: monopam verse members (looking for '%s')" h)
3136 | Workspace_exists _ ->
3237 Some "Use a different directory, or remove the existing workspace."
3338 | Not_a_workspace _ ->
3434- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace here."
3939+ Some
4040+ "Run 'monopam verse init --handle <your-handle>' to create a workspace \
4141+ here."
35423643let pp_error_with_hint ppf e =
3744 pp_error ppf e;
···69767077let pp_status ppf s =
7178 Fmt.pf ppf "@[<v>Workspace: %a@,Registry: %s@,Members:@, @[<v>%a@]@]"
7272- Fpath.pp (Verse_config.root s.config)
7979+ Fpath.pp
8080+ (Verse_config.root s.config)
7381 s.registry.name
7482 Fmt.(list ~sep:cut pp_member_status)
7583 s.tracked_members
···103111 let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in
104112 try
105113 Eio.Path.read_dir eio_path
106106- |> List.filter (fun name ->
107107- is_directory ~fs Fpath.(verse_path / name))
114114+ |> List.filter (fun name -> is_directory ~fs Fpath.(verse_path / name))
108115 with Eio.Io _ -> []
109116110117let init ~proc ~fs ~root ~handle () =
···126133 (* Ensure the directory exists first so realpath works *)
127134 (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ());
128135 match Unix.realpath root_str with
129129- | abs_str -> (match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
136136+ | abs_str -> (
137137+ match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
130138 | exception _ -> root
131139 in
132140 Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root);
···138146 | Error msg ->
139147 Logs.err (fun m -> m "Registry clone failed: %s" msg);
140148 Error (Registry_error msg)
141141- | Ok registry ->
149149+ | Ok registry -> (
142150 Logs.info (fun m -> m "Registry loaded");
143151 (* Look up user in registry - this validates the handle *)
144152 match Verse_registry.find_member registry ~handle with
145153 | None ->
146154 Logs.err (fun m -> m "Handle %s not found in registry" handle);
147155 Error (Member_not_found handle)
148148- | Some member ->
149149- Logs.info (fun m -> m "Found member: mono=%s opam=%s" member.monorepo member.opamrepo);
150150- (* Create workspace directories *)
151151- Logs.info (fun m -> m "Creating workspace directories...");
152152- ensure_dir ~fs root;
153153- ensure_dir ~fs (Verse_config.src_path config);
154154- ensure_dir ~fs (Verse_config.verse_path config);
155155- (* Clone user's monorepo *)
156156- let mono_path = Verse_config.mono_path config in
157157- Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
158158- let mono_url = Uri.of_string member.monorepo in
159159- (match Git.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch mono_path with
156156+ | Some member -> (
157157+ Logs.info (fun m ->
158158+ m "Found member: mono=%s opam=%s" member.monorepo
159159+ member.opamrepo);
160160+ (* Create workspace directories *)
161161+ Logs.info (fun m -> m "Creating workspace directories...");
162162+ ensure_dir ~fs root;
163163+ ensure_dir ~fs (Verse_config.src_path config);
164164+ ensure_dir ~fs (Verse_config.verse_path config);
165165+ (* Clone user's monorepo *)
166166+ let mono_path = Verse_config.mono_path config in
167167+ Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
168168+ let mono_url = Uri.of_string member.monorepo in
169169+ match
170170+ Git.clone ~proc ~fs ~url:mono_url
171171+ ~branch:Verse_config.default_branch mono_path
172172+ with
173173+ | Error e ->
174174+ Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
175175+ Error (Git_error e)
176176+ | Ok () -> (
177177+ Logs.info (fun m -> m "Monorepo cloned");
178178+ (* Clone user's opam repo *)
179179+ let opam_path = Verse_config.opam_repo_path config in
180180+ Logs.info (fun m ->
181181+ m "Cloning opam repo to %a" Fpath.pp opam_path);
182182+ let opam_url = Uri.of_string member.opamrepo in
183183+ match
184184+ Git.clone ~proc ~fs ~url:opam_url
185185+ ~branch:Verse_config.default_branch opam_path
186186+ with
160187 | Error e ->
161161- Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
188188+ Logs.err (fun m ->
189189+ m "Opam repo clone failed: %a" Git.pp_error e);
162190 Error (Git_error e)
163163- | Ok () ->
164164- Logs.info (fun m -> m "Monorepo cloned");
165165- (* Clone user's opam repo *)
166166- let opam_path = Verse_config.opam_repo_path config in
167167- Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path);
168168- let opam_url = Uri.of_string member.opamrepo in
169169- (match Git.clone ~proc ~fs ~url:opam_url ~branch:Verse_config.default_branch opam_path with
170170- | Error e ->
171171- Logs.err (fun m -> m "Opam repo clone failed: %a" Git.pp_error e);
172172- Error (Git_error e)
191191+ | Ok () -> (
192192+ Logs.info (fun m -> m "Opam repo cloned");
193193+ (* Save config to XDG *)
194194+ Logs.info (fun m ->
195195+ m "Saving config to %a" Fpath.pp config_file);
196196+ match Verse_config.save ~fs config with
197197+ | Error msg ->
198198+ Logs.err (fun m -> m "Failed to save config: %s" msg);
199199+ Error (Config_error msg)
173200 | Ok () ->
174174- Logs.info (fun m -> m "Opam repo cloned");
175175- (* Save config to XDG *)
176176- Logs.info (fun m -> m "Saving config to %a" Fpath.pp config_file);
177177- (match Verse_config.save ~fs config with
178178- | Error msg ->
179179- Logs.err (fun m -> m "Failed to save config: %s" msg);
180180- Error (Config_error msg)
181181- | Ok () ->
182182- Logs.info (fun m -> m "Workspace initialized successfully");
183183- Ok ())))
201201+ Logs.info (fun m ->
202202+ m "Workspace initialized successfully");
203203+ Ok ()))))
184204185205let status ~proc ~fs ~config () =
186206 (* Load registry *)
···197217 match Verse_registry.find_member registry ~handle with
198218 | None ->
199219 (* Member not in registry but locally tracked - show anyway *)
200200- let local_path = Fpath.(Verse_config.verse_path config / handle) in
220220+ let local_path =
221221+ Fpath.(Verse_config.verse_path config / handle)
222222+ in
201223 let cloned = is_directory ~fs local_path in
202224 Some
203225 {
···242264 | Error msg -> Error (Registry_error msg)
243265 | Ok registry -> Ok registry.members
244266245245-246246-(** Clone or pull a single git repo. Returns Ok true if cloned, Ok false if pulled. *)
267267+(** Clone or pull a single git repo. Returns Ok true if cloned, Ok false if
268268+ pulled. *)
247269let clone_or_pull_repo ~proc ~fs ~url ~branch path =
248270 if Git.is_repo ~proc ~fs path then begin
249249- match Git.pull ~proc ~fs path with
250250- | Error e -> Error e
251251- | Ok () -> Ok false
271271+ match Git.pull ~proc ~fs path with Error e -> Error e | Ok () -> Ok false
252272 end
253273 else begin
254274 let url = Uri.of_string url in
···262282 match Verse_registry.clone_or_pull ~proc ~fs ~config () with
263283 | Error msg -> Error (Registry_error msg)
264284 | Ok registry ->
265265- let members = match handle with
266266- | Some h ->
267267- (match Verse_registry.find_member registry ~handle:h with
268268- | Some m -> [m]
285285+ let members =
286286+ match handle with
287287+ | Some h -> (
288288+ match Verse_registry.find_member registry ~handle:h with
289289+ | Some m -> [ m ]
269290 | None -> [])
270291 | None -> registry.members
271292 in
···287308 clone_or_pull_repo ~proc ~fs ~url:member.monorepo
288309 ~branch:Verse_config.default_branch mono_path
289310 in
290290- let mono_err = match mono_result with
291291- | Ok true -> Logs.info (fun m -> m " Cloned %s monorepo" h); None
292292- | Ok false -> Logs.info (fun m -> m " Pulled %s monorepo" h); None
311311+ let mono_err =
312312+ match mono_result with
313313+ | Ok true ->
314314+ Logs.info (fun m -> m " Cloned %s monorepo" h);
315315+ None
316316+ | Ok false ->
317317+ Logs.info (fun m -> m " Pulled %s monorepo" h);
318318+ None
293319 | Error e ->
294294- Logs.warn (fun m -> m " Failed %s monorepo: %a" h Git.pp_error e);
320320+ Logs.warn (fun m ->
321321+ m " Failed %s monorepo: %a" h Git.pp_error e);
295322 Some (Fmt.str "%s monorepo: %a" h Git.pp_error e)
296323 in
297324 (* Clone or pull opam repo *)
···300327 clone_or_pull_repo ~proc ~fs ~url:member.opamrepo
301328 ~branch:Verse_config.default_branch opam_path
302329 in
303303- let opam_err = match opam_result with
304304- | Ok true -> Logs.info (fun m -> m " Cloned %s opam repo" h); None
305305- | Ok false -> Logs.info (fun m -> m " Pulled %s opam repo" h); None
330330+ let opam_err =
331331+ match opam_result with
332332+ | Ok true ->
333333+ Logs.info (fun m -> m " Cloned %s opam repo" h);
334334+ None
335335+ | Ok false ->
336336+ Logs.info (fun m -> m " Pulled %s opam repo" h);
337337+ None
306338 | Error e ->
307307- Logs.warn (fun m -> m " Failed %s opam repo: %a" h Git.pp_error e);
339339+ Logs.warn (fun m ->
340340+ m " Failed %s opam repo: %a" h Git.pp_error e);
308341 Some (Fmt.str "%s opam: %a" h Git.pp_error e)
309342 in
310343 match (mono_err, opam_err) with
···321354 (* pull already updates registry and syncs all members *)
322355 pull ~proc ~fs ~config ()
323356324324-(** Scan a monorepo for subtree directories.
325325- Returns a list of directory names that look like subtrees (have commits). *)
357357+(** Scan a monorepo for subtree directories. Returns a list of directory names
358358+ that look like subtrees (have commits). *)
326359let scan_subtrees ~proc ~fs monorepo_path =
327360 if not (Git.is_repo ~proc ~fs monorepo_path) then []
328361 else
···330363 try
331364 Eio.Path.read_dir eio_path
332365 |> List.filter (fun name ->
333333- (* Skip hidden dirs and common non-subtree dirs *)
334334- not (String.starts_with ~prefix:"." name)
335335- && name <> "_build"
336336- && name <> "node_modules"
337337- && is_directory ~fs Fpath.(monorepo_path / name))
366366+ (* Skip hidden dirs and common non-subtree dirs *)
367367+ (not (String.starts_with ~prefix:"." name))
368368+ && name <> "_build" && name <> "node_modules"
369369+ && is_directory ~fs Fpath.(monorepo_path / name))
338370 with Eio.Io _ -> []
339371340340-(** Get subtrees from all tracked verse members.
341341- Returns a map from subtree name to list of (handle, monorepo_path) pairs. *)
372372+(** Get subtrees from all tracked verse members. Returns a map from subtree name
373373+ to list of (handle, monorepo_path) pairs. *)
342374let get_verse_subtrees ~proc ~fs ~config () =
343375 let verse_path = Verse_config.verse_path config in
344376 let tracked_handles = get_tracked_handles ~fs config in
···354386 let existing =
355387 try Hashtbl.find subtree_map subtree with Not_found -> []
356388 in
357357- Hashtbl.replace subtree_map subtree ((handle, member_mono) :: existing))
389389+ Hashtbl.replace subtree_map subtree
390390+ ((handle, member_mono) :: existing))
358391 subtrees
359392 end)
360393 tracked_handles;
+10-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···1717(** [pp_error] formats errors. *)
18181919val pp_error_with_hint : error Fmt.t
2020-(** [pp_error_with_hint] formats errors with a helpful hint for resolving them. *)
2020+(** [pp_error_with_hint] formats errors with a helpful hint for resolving them.
2121+*)
21222223val error_hint : error -> string option
2324(** [error_hint e] returns a hint string for the given error, if available. *)
···3031 local_path : Fpath.t; (** Local path under verse/ *)
3132 cloned : bool; (** Whether the monorepo is cloned locally *)
3233 clean : bool option; (** Whether the clone is clean (None if not cloned) *)
3333- ahead_behind : Git.ahead_behind option; (** Ahead/behind status (None if not cloned) *)
3434+ ahead_behind : Git.ahead_behind option;
3535+ (** Ahead/behind status (None if not cloned) *)
3436}
3537(** Status of a member's monorepo in the workspace. *)
3638···101103 (unit, error) result
102104(** [pull ~proc ~fs ~config ?handle ()] syncs all registry members.
103105104104- For each member in the registry, clones or pulls both their monorepo
105105- (to [verse/<handle>/]) and their opam repo (to [verse/<handle>-opam/]).
106106+ For each member in the registry, clones or pulls both their monorepo (to
107107+ [verse/<handle>/]) and their opam repo (to [verse/<handle>-opam/]).
106108107109 If [handle] is specified, only syncs that specific member.
108110···137139 unit ->
138140 (string, (string * Fpath.t) list) Hashtbl.t
139141(** [get_verse_subtrees ~proc ~fs ~config ()] scans all tracked verse members
140140- and returns a map from subtree name to list of (handle, monorepo_path) pairs.
142142+ and returns a map from subtree name to list of (handle, monorepo_path)
143143+ pairs.
141144142145 This allows finding which verse users have a particular repo. *)
+8-17
lib/verse_config.ml
···11let app_name = "monopam"
2233(* Simplified config: just root and handle. Paths are hardcoded. *)
44-type t = {
55- root : Fpath.t;
66- handle : string;
77-}
44+type t = { root : Fpath.t; handle : string }
8596let root t = t.root
107let handle t = t.handle
···2017let xdg_config_home () =
2118 match Sys.getenv_opt "XDG_CONFIG_HOME" with
2219 | Some dir when dir <> "" -> Fpath.v dir
2323- | _ ->
2020+ | _ -> (
2421 match Sys.getenv_opt "HOME" with
2522 | Some home -> Fpath.(v home / ".config")
2626- | None -> Fpath.v "/tmp"
2323+ | None -> Fpath.v "/tmp")
27242825let xdg_data_home () =
2926 match Sys.getenv_opt "XDG_DATA_HOME" with
3027 | Some dir when dir <> "" -> Fpath.v dir
3131- | _ ->
2828+ | _ -> (
3229 match Sys.getenv_opt "HOME" with
3330 | Some home -> Fpath.(v home / ".local" / "share")
3434- | None -> Fpath.v "/tmp"
3131+ | None -> Fpath.v "/tmp")
35323633let config_dir () = Fpath.(xdg_config_home () / app_name)
3734let data_dir () = Fpath.(xdg_data_home () / app_name)
3835let config_file () = Fpath.(config_dir () / "opamverse.toml")
3936let registry_path () = Fpath.(data_dir () / "opamverse-registry")
4040-4137let create ~root ~handle () = { root; handle }
42384339let expand_tilde s =
···9490let load ~fs () =
9591 let path = config_file () in
9692 let path_str = Fpath.to_string path in
9797- try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str)
9898- with
9393+ try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str) with
9994 | Eio.Io _ as e -> Error (Printexc.to_string e)
10095 | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
10196···111106 with Eio.Io _ as e -> Error (Printexc.to_string e)
112107113108let 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
109109+ Fmt.pf ppf "@[<v>workspace:@, root: %a@,identity:@, handle: %s@]" Fpath.pp
110110+ t.root t.handle
+6-4
lib/verse_config.mli
···33 Configuration is stored in the XDG config directory at
44 [~/.config/monopam/opamverse.toml].
5566- The config stores just the workspace root and user's handle.
77- All paths are derived from the root:
66+ The config stores just the workspace root and user's handle. All paths are
77+ derived from the root:
88 - [mono/] - user's monorepo
99 - [src/] - git checkouts for subtrees
1010 - [opam-repo/] - opam overlay repository
···3535(** [src_path t] returns the path to git checkouts ([root/src/]). *)
36363737val opam_repo_path : t -> Fpath.t
3838-(** [opam_repo_path t] returns the path to the opam overlay ([root/opam-repo/]). *)
3838+(** [opam_repo_path t] returns the path to the opam overlay ([root/opam-repo/]).
3939+*)
39404041val verse_path : t -> Fpath.t
4141-(** [verse_path t] returns the path to tracked members' monorepos ([root/verse/]). *)
4242+(** [verse_path t] returns the path to tracked members' monorepos
4343+ ([root/verse/]). *)
42444345(** {1 XDG Paths} *)
4446
+17-13
lib/verse_registry.ml
···77 Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle m.monorepo m.opamrepo
8899let pp ppf t =
1010- Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]"
1111- t.name Fmt.(list ~sep:cut pp_member) t.members
1010+ Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]" t.name
1111+ Fmt.(list ~sep:cut pp_member)
1212+ t.members
12131314(* TOML structure:
1415 [registry]
···4546 { name = registry.r_name; members = Option.value ~default:[] members })
4647 |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name })
4748 |> opt_mem "members" (list member_codec) ~enc:(fun t ->
4848- match t.members with [] -> None | ms -> Some ms)
4949+ match t.members with [] -> None | ms -> Some ms)
4950 |> finish))
50515152let empty_registry = { name = "opamverse"; members = [] }
···5556 Logs.info (fun m -> m "Loading registry from path: %s" path_str);
5657 try
5758 let registry = Tomlt_eio.decode_path_exn codec ~fs path_str in
5858- Logs.info (fun m -> m "Registry loaded: %d members" (List.length registry.members));
5959+ Logs.info (fun m ->
6060+ m "Registry loaded: %d members" (List.length registry.members));
5961 Ok registry
6062 with
6163 | Eio.Io _ as e ->
···6567 Logs.err (fun m -> m "Registry parse error: %s" msg);
6668 Error (Fmt.str "Invalid registry: %s" msg)
6769 | exn ->
6868- Logs.err (fun m -> m "Unexpected registry error: %s" (Printexc.to_string exn));
7070+ Logs.err (fun m ->
7171+ m "Unexpected registry error: %s" (Printexc.to_string exn));
6972 Error (Fmt.str "Registry error: %s" (Printexc.to_string exn))
70737174let save ~fs path registry =
···9194 Logs.info (fun m -> m "Registry exists, pulling updates...");
9295 (* Pull updates, but don't fail if pull fails *)
9396 (match Git.pull ~proc ~fs registry_path with
9494- | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
9595- | Error e -> Logs.warn (fun m -> m "Registry pull failed: %a (using cached)" Git.pp_error e));
9797+ | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
9898+ | Error e ->
9999+ Logs.warn (fun m ->
100100+ m "Registry pull failed: %a (using cached)" Git.pp_error e));
96101 Logs.info (fun m -> m "Loading registry from %a" Fpath.pp registry_toml);
97102 load ~fs registry_toml
98103 end
···117122 (try Eio.Path.mkdirs ~perm:0o755 registry_eio with Eio.Io _ -> ());
118123 (* Initialize as git repo *)
119124 (match Git.init ~proc ~fs registry_path with
120120- | Ok () -> ()
121121- | Error _ -> ());
125125+ | Ok () -> ()
126126+ | Error _ -> ());
122127 (* Create empty registry file *)
123128 (match save ~fs registry_toml empty_registry with
124124- | Ok () -> ()
125125- | Error _ -> ());
129129+ | Ok () -> ()
130130+ | Error _ -> ());
126131 Ok empty_registry
127132 end
128133129129-let find_member t ~handle =
130130- List.find_opt (fun m -> m.handle = handle) t.members
134134+let find_member t ~handle = List.find_opt (fun m -> m.handle = handle) t.members
131135132136let find_members t ~handles =
133137 List.filter (fun m -> List.mem m.handle handles) t.members
+2-2
lib/verse_registry.mli
···2929 config:Verse_config.t ->
3030 unit ->
3131 (t, string) result
3232-(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present,
3333- or pulls updates if it exists. Returns the parsed registry contents.
3232+(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present, or
3333+ pulls updates if it exists. Returns the parsed registry contents.
34343535 The registry is cloned to [config.registry_path].
3636