···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
···704780 `P
705781 "The opamverse system enables federated collaboration across multiple \
706782 developers' monorepos. Each developer maintains their own monorepo \
707707- (managed by standard monopam commands), and can track other developers' \
708708- monorepos for code browsing, learning, and collaboration.";
783783+ (managed by standard monopam commands), and can track other \
784784+ developers' monorepos for code browsing, learning, and collaboration.";
709785 `P
710786 "Members are identified by tangled handles - decentralized identities \
711787 from the AT Protocol network (the same system used by Bluesky).";
712788 `S "QUICK START FOR NEW USERS";
713789 `P "Run these commands in order to get started:";
714714- `Pre "# Step 1: Authenticate with tangled (one-time setup)\n\
715715- tangled auth login\n\n\
716716- # Step 2: Create and initialize your workspace\n\
717717- mkdir ~/tangled && cd ~/tangled\n\
718718- monopam verse init --handle yourname.bsky.social\n\n\
719719- # Step 3: Sync all community members\n\
720720- monopam verse pull\n\n\
721721- # Step 4: Browse their code\n\
722722- ls verse/\n\
723723- cd verse/alice.bsky.social && dune build\n\n\
724724- # Step 5: Keep everything updated (run daily/weekly)\n\
725725- monopam verse sync";
790790+ `Pre
791791+ "# Step 1: Authenticate with tangled (one-time setup)\n\
792792+ tangled auth login\n\n\
793793+ # Step 2: Create and initialize your workspace\n\
794794+ mkdir ~/tangled && cd ~/tangled\n\
795795+ monopam verse init --handle yourname.bsky.social\n\n\
796796+ # Step 3: Sync all community members\n\
797797+ monopam verse pull\n\n\
798798+ # Step 4: Browse their code\n\
799799+ ls verse/\n\
800800+ cd verse/alice.bsky.social && dune build\n\n\
801801+ # Step 5: Keep everything updated (run daily/weekly)\n\
802802+ monopam verse sync";
726803 `S "KEY CONCEPTS";
727727- `I ("Workspace", "A directory containing your monorepo plus all registry members' repos");
728728- `I ("Registry", "A git repository listing community members and their repo URLs");
729729- `I ("Handle", "A tangled identity like 'alice.bsky.social' validated via AT Protocol");
804804+ `I
805805+ ( "Workspace",
806806+ "A directory containing your monorepo plus all registry members' \
807807+ repos" );
808808+ `I
809809+ ( "Registry",
810810+ "A git repository listing community members and their repo URLs" );
811811+ `I
812812+ ( "Handle",
813813+ "A tangled identity like 'alice.bsky.social' validated via AT \
814814+ Protocol" );
730815 `S "WORKSPACE STRUCTURE";
731816 `P "An opamverse workspace has this layout:";
732732- `Pre "~/tangled/ # workspace root\n\
733733- ├── mono/ # YOUR monorepo\n\
734734- ├── src/ # YOUR fork checkouts\n\
735735- ├── opam-repo/ # YOUR opam overlay\n\
736736- └── verse/\n\
737737- \ ├── alice.bsky.social/ # Alice's monorepo\n\
738738- \ ├── alice.bsky.social-opam/ # Alice's opam overlay\n\
739739- \ ├── bob.example.com/ # Bob's monorepo\n\
740740- \ └── bob.example.com-opam/ # Bob's opam overlay";
817817+ `Pre
818818+ "~/tangled/ # workspace root\n\
819819+ ├── mono/ # YOUR monorepo\n\
820820+ ├── src/ # YOUR fork checkouts\n\
821821+ ├── opam-repo/ # YOUR opam overlay\n\
822822+ └── verse/\n\
823823+ \ ├── alice.bsky.social/ # Alice's monorepo\n\
824824+ \ ├── alice.bsky.social-opam/ # Alice's opam overlay\n\
825825+ \ ├── bob.example.com/ # Bob's monorepo\n\
826826+ \ └── bob.example.com-opam/ # Bob's opam overlay";
741827 `P "Configuration and data are stored in XDG directories:";
742742- `Pre "~/.config/monopam/\n\
743743- └── opamverse.toml # workspace configuration\n\n\
744744- ~/.local/share/monopam/\n\
745745- └── opamverse-registry/ # cloned registry git repo";
828828+ `Pre
829829+ "~/.config/monopam/\n\
830830+ └── opamverse.toml # workspace configuration\n\n\
831831+ ~/.local/share/monopam/\n\
832832+ └── opamverse-registry/ # cloned registry git repo";
746833 `S "COMMAND FLOW";
747834 `P "The expected sequence of commands for typical workflows:";
748835 `P "$(b,First-time setup) (once per machine):";
749749- `Pre "tangled auth login # authenticate\n\
750750- monopam verse init --handle you.bsky.social # create workspace";
836836+ `Pre
837837+ "tangled auth login # authenticate\n\
838838+ monopam verse init --handle you.bsky.social # create workspace";
751839 `P "$(b,Syncing all members):";
752752- `Pre "monopam verse pull # clone/pull all members\n\
753753- monopam verse status # check status";
840840+ `Pre
841841+ "monopam verse pull # clone/pull all \
842842+ members\n\
843843+ monopam verse status # check status";
754844 `P "$(b,Daily maintenance):";
755755- `Pre "monopam verse sync # update everything\n\
756756- monopam verse status # check for changes";
845845+ `Pre
846846+ "monopam verse sync # update everything\n\
847847+ monopam verse status # check for changes";
757848 `P "$(b,Working in your own monorepo):";
758758- `Pre "cd ~/tangled/mono\n\
759759- monopam pull # fetch upstream changes\n\
760760- # ... make edits ...\n\
761761- monopam push # export to checkouts";
849849+ `Pre
850850+ "cd ~/tangled/mono\n\
851851+ monopam pull # fetch upstream \
852852+ changes\n\
853853+ # ... make edits ...\n\
854854+ monopam push # export to checkouts";
762855 `S "INTEGRATION WITH MONOPAM";
763856 `P
764857 "The verse system complements standard monopam commands. Your mono/ \
765858 directory works exactly like a normal monopam-managed monorepo:";
766766- `Pre "# Work in your monorepo\n\
767767- cd ~/tangled/mono\n\
768768- monopam status\n\
769769- monopam pull\n\
770770- # ... make changes ...\n\
771771- monopam push";
859859+ `Pre
860860+ "# Work in your monorepo\n\
861861+ cd ~/tangled/mono\n\
862862+ monopam status\n\
863863+ monopam pull\n\
864864+ # ... make changes ...\n\
865865+ monopam push";
772866 `P
773773- "The verse/ directories are for reading and learning from others' code. \
774774- You generally don't push to them (unless you're a collaborator).";
867867+ "The verse/ directories are for reading and learning from others' \
868868+ code. You generally don't push to them (unless you're a \
869869+ collaborator).";
775870 `S "REGISTRY FORMAT";
776776- `P
777777- "The registry is a git repository containing opamverse.toml:";
778778- `Pre "[registry]\n\
779779- name = \"tangled-community\"\n\n\
780780- [[members]]\n\
781781- handle = \"alice.bsky.social\"\n\
782782- monorepo = \"https://github.com/alice/mono\"";
783783- `P
784784- "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
871871+ `P "The registry is a git repository containing opamverse.toml:";
872872+ `Pre
873873+ "[registry]\n\
874874+ name = \"tangled-community\"\n\n\
875875+ [[members]]\n\
876876+ handle = \"alice.bsky.social\"\n\
877877+ monorepo = \"https://github.com/alice/mono\"";
878878+ `P "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
785879 `S "COMMANDS REFERENCE";
786880 `I ("init", "Create a new workspace with config and directories");
787881 `I ("status", "Show members and their git status");
···815909 [
816910 `S Manpage.s_description;
817911 `P
818818- "Analyzes your workspace health and provides actionable recommendations. \
819819- Uses Claude AI to analyze commits from verse collaborators, categorizing \
820820- them by type, priority, and risk level.";
912912+ "Analyzes your workspace health and provides actionable \
913913+ recommendations. Uses Claude AI to analyze commits from verse \
914914+ collaborators, categorizing them by type, priority, and risk level.";
821915 `S "WHAT IT DOES";
822916 `P "The doctor command:";
823917 `I ("1.", "Syncs the workspace (unless $(b,--no-sync) is specified)");
···826920 `I ("4.", "Analyzes fork relationships with verse members");
827921 `I ("5.", "Uses Claude to categorize and prioritize their commits");
828922 `I ("6.", "Generates actionable recommendations");
829829- `P "The status output from $(b,monopam status) is automatically included \
830830- in the prompt sent to Claude, so Claude doesn't need to run it separately.";
923923+ `P
924924+ "The status output from $(b,monopam status) is automatically included \
925925+ in the prompt sent to Claude, so Claude doesn't need to run it \
926926+ separately.";
831927 `S "OUTPUT FORMATS";
832928 `P "By default, outputs human-readable text with colors.";
833929 `P "Use $(b,--json) for JSON output suitable for tooling.";
···867963 Fmt.pr "Warning: sync failed: %a@." Monopam.pp_error_with_hint e;
868964 Fmt.pr "Continuing with analysis...@."
869965 end;
870870- let report = Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package ~no_sync () in
871871- if json then
872872- print_endline (Monopam.Doctor.to_json report)
873873- else
874874- Fmt.pr "%a@." Monopam.Doctor.pp_report report;
966966+ let report =
967967+ Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package
968968+ ~no_sync ()
969969+ in
970970+ if json then print_endline (Monopam.Doctor.to_json report)
971971+ else Fmt.pr "%a@." Monopam.Doctor.pp_report report;
875972 `Ok ()
876973 in
877877- Cmd.v info Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
974974+ Cmd.v info
975975+ Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
878976879977(* Feature commands *)
880978···10651163 `P "Monopam manages three directory trees:";
10661164 `I
10671165 ( "mono/",
10681068- "The monorepo combining all packages as git subtrees. This is where you \
10691069- make changes." );
11661166+ "The monorepo combining all packages as git subtrees. This is where \
11671167+ you make changes." );
10701168 `I
10711169 ( "src/",
10721170 "Individual git checkouts of each unique repository. Used for review \
···10851183 `I
10861184 ( "4. monopam sync --remote",
10871185 "Sync again, including pushing to upstream git remotes" );
10881088- `P
10891089- "For finer control, use $(b,push) and $(b,pull) separately:";
11861186+ `P "For finer control, use $(b,push) and $(b,pull) separately:";
10901187 `I
10911188 ( "monopam push",
10921189 "Export monorepo changes to checkouts (for manual review/push)" );
10931190 `I
10941191 ( "monopam pull",
10951095- "Pull remote changes into monorepo (when you know there are no local changes)" );
11921192+ "Pull remote changes into monorepo (when you know there are no local \
11931193+ changes)" );
10961194 `S "CHECKING STATUS";
10971195 `P "Run $(b,monopam status) to see the state of all repositories:";
10981196 `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+*)
+107-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
···236236237237let add_remote ~proc ~fs ~name ~url path =
238238 let cwd = path_to_eio ~fs path in
239239- run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ]
240240- |> Result.map ignore
239239+ run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ] |> Result.map ignore
241240242241let remove_remote ~proc ~fs ~name path =
243242 let cwd = path_to_eio ~fs path in
244244- run_git_ok ~proc ~cwd [ "remote"; "remove"; name ]
245245- |> Result.map ignore
243243+ run_git_ok ~proc ~cwd [ "remote"; "remove"; name ] |> Result.map ignore
246244247245let set_remote_url ~proc ~fs ~name ~url path =
248246 let cwd = path_to_eio ~fs path in
249249- run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ]
250250- |> Result.map ignore
247247+ run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ] |> Result.map ignore
251248252249let ensure_remote ~proc ~fs ~name ~url path =
253250 let remotes = list_remotes ~proc ~fs path in
···257254 | Some existing_url when existing_url = url -> Ok ()
258255 | _ -> set_remote_url ~proc ~fs ~name ~url path
259256 end
260260- else
261261- add_remote ~proc ~fs ~name ~url path
257257+ else add_remote ~proc ~fs ~name ~url path
262258263259type log_entry = {
264260 hash : string;
···304300 let args =
305301 match until with Some u -> args @ [ "--until=" ^ u ] | None -> args
306302 in
307307- let args = match filter_path with Some p -> args @ [ "--"; p ] | None -> args in
303303+ let args =
304304+ match filter_path with Some p -> args @ [ "--"; p ] | None -> args
305305+ in
308306 match run_git_ok ~proc ~cwd args with
309307 | Ok output -> Ok (parse_log_entries output)
310308 | Error e -> Error e
···314312 let format_arg = "--format=%H%n%an%n%aI%n%s%n%b%x00" in
315313 let range = Printf.sprintf "%s..%s" base tip in
316314 let args = [ "log"; format_arg; range ] in
317317- let args = match max_count with
315315+ let args =
316316+ match max_count with
318317 | Some n -> args @ [ "-n"; string_of_int n ]
319318 | None -> args
320319 in
···322321 | Ok output -> Ok (parse_log_entries output)
323322 | Error e -> Error e
324323325325-(** Parse a subtree merge/squash commit message to extract the upstream commit range.
326326- Messages look like: "Squashed 'prefix/' changes from abc123..def456"
327327- or "Squashed 'prefix/' content from commit abc123"
328328- Returns the end commit (most recent) if found. *)
324324+(** Parse a subtree merge/squash commit message to extract the upstream commit
325325+ range. Messages look like: "Squashed 'prefix/' changes from abc123..def456"
326326+ or "Squashed 'prefix/' content from commit abc123" Returns the end commit
327327+ (most recent) if found. *)
329328let parse_subtree_message subject =
330329 (* Helper to extract hex commit hash starting at position *)
331330 let extract_hex s start =
332331 let len = String.length s in
333332 let rec find_end i =
334333 if i >= len then i
335335- else match s.[i] with
336336- | '0'..'9' | 'a'..'f' -> find_end (i + 1)
337337- | _ -> i
334334+ else
335335+ match s.[i] with '0' .. '9' | 'a' .. 'f' -> find_end (i + 1) | _ -> i
338336 in
339337 let end_pos = find_end start in
340338 if end_pos > start then Some (String.sub s start (end_pos - start))
···345343 match String.index_opt subject '.' with
346344 | Some i when i + 1 < String.length subject && subject.[i + 1] = '.' ->
347345 extract_hex subject (i + 2)
348348- | _ ->
346346+ | _ -> (
349347 (* Pattern 2: "Squashed 'prefix/' content from commit abc123" *)
350350- (match String.split_on_char ' ' subject |> List.rev with
348348+ match String.split_on_char ' ' subject |> List.rev with
351349 | last :: "commit" :: "from" :: _ -> extract_hex last 0
352352- | _ -> None)
353353- (* Pattern 3: "Add 'prefix/' from commit abc123" *)
350350+ | _ -> None) (* Pattern 3: "Add 'prefix/' from commit abc123" *)
354351 else if String.starts_with ~prefix:"Add '" subject then
355352 match String.split_on_char ' ' subject |> List.rev with
356353 | last :: "commit" :: "from" :: _ -> extract_hex last 0
357354 | _ -> None
358358- else
359359- None
355355+ else None
360356361361-(** Find the last subtree-related commit for a given prefix.
362362- Searches git log for commits with subtree merge/squash messages. *)
357357+(** Find the last subtree-related commit for a given prefix. Searches git log
358358+ for commits with subtree merge/squash messages. *)
363359let subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () =
364360 let cwd = path_to_eio ~fs repo in
365361 (* Search for subtree-related commits - don't use path filter as it can miss merge commits *)
366362 let grep_pattern = Printf.sprintf "^Squashed '%s/'" prefix in
367367- match run_git_ok ~proc ~cwd
368368- [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ] with
363363+ match
364364+ run_git_ok ~proc ~cwd [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ]
365365+ with
369366 | Error _ -> None
370370- | Ok "" ->
367367+ | Ok "" -> (
371368 (* Try alternate pattern: Add 'prefix/' from commit *)
372369 let add_pattern = Printf.sprintf "^Add '%s/'" prefix in
373373- (match run_git_ok ~proc ~cwd
374374- [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ] with
370370+ match
371371+ run_git_ok ~proc ~cwd
372372+ [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ]
373373+ with
375374 | Error _ -> None
376375 | Ok "" -> None
377377- | Ok line ->
376376+ | Ok line -> (
378377 (* line is "abc1234 Add 'prefix/' from commit ..." *)
379378 let hash = String.sub line 0 (min 7 (String.length line)) in
380379 (* Get the full commit message to parse *)
381380 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
382381 | Error _ -> None
383383- | Ok subject -> parse_subtree_message subject)
384384- | Ok line ->
382382+ | Ok subject -> parse_subtree_message subject))
383383+ | Ok line -> (
385384 let hash = String.sub line 0 (min 7 (String.length line)) in
386385 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
387386 | Error _ -> None
388388- | Ok subject -> parse_subtree_message subject
387387+ | Ok subject -> parse_subtree_message subject)
389388390389(** Check if commit1 is an ancestor of commit2. *)
391390let is_ancestor ~proc ~fs ~repo ~commit1 ~commit2 () =
392391 let cwd = path_to_eio ~fs repo in
393393- let result = run_git ~proc ~cwd
394394- [ "merge-base"; "--is-ancestor"; commit1; commit2 ] in
392392+ let result =
393393+ run_git ~proc ~cwd [ "merge-base"; "--is-ancestor"; commit1; commit2 ]
394394+ in
395395 result.exit_code = 0
396396397397(** Find the merge-base (common ancestor) of two commits. *)
···402402(** Count commits between two commits (exclusive of base, inclusive of head). *)
403403let count_commits_between ~proc ~fs ~repo ~base ~head () =
404404 let cwd = path_to_eio ~fs repo in
405405- match run_git_ok ~proc ~cwd
406406- [ "rev-list"; "--count"; base ^ ".." ^ head ] with
405405+ match run_git_ok ~proc ~cwd [ "rev-list"; "--count"; base ^ ".." ^ head ] with
407406 | Error _ -> 0
408408- | Ok s -> try int_of_string (String.trim s) with _ -> 0
407407+ | Ok s -> ( try int_of_string (String.trim s) with _ -> 0)
408408+409409+(** {1 Worktree Operations} *)
409410410411module Worktree = struct
411412 type entry = {
···488489 let worktrees = list ~proc ~fs repo in
489490 List.exists (fun e -> Fpath.equal e.path path) worktrees
490491end
492492+493493+(** {1 Diff Operations} *)
494494+495495+let diff_trees ~proc ~fs ~source ~target =
496496+ (* Use git diff --no-index to compare two directory trees.
497497+ This works even if neither directory is a git repo.
498498+ Exit code 0 = no diff, exit code 1 = diff found, other = error *)
499499+ let cwd = path_to_eio ~fs (Fpath.v ".") in
500500+ let source_str = Fpath.to_string source in
501501+ let target_str = Fpath.to_string target in
502502+ let result =
503503+ run_git ~proc ~cwd
504504+ [
505505+ "diff";
506506+ "--no-index";
507507+ "--binary";
508508+ (* Handle binary files *)
509509+ "--no-color";
510510+ target_str;
511511+ (* old = checkout *)
512512+ source_str (* new = monorepo subtree *);
513513+ ]
514514+ in
515515+ match result.exit_code with
516516+ | 0 ->
517517+ (* No differences *)
518518+ Ok ""
519519+ | 1 ->
520520+ (* Differences found - this is success for diff *)
521521+ Ok result.stdout
522522+ | _ ->
523523+ (* Actual error *)
524524+ Error
525525+ (Command_failed
526526+ (String.concat " " [ "git"; "diff"; "--no-index" ], result))
527527+528528+let apply_diff ~proc ~fs ~cwd ~diff =
529529+ if String.length diff = 0 then Ok ()
530530+ else
531531+ let cwd_eio = path_to_eio ~fs cwd in
532532+ (* Apply the diff using git apply.
533533+ We need to handle the path rewriting since git diff --no-index
534534+ uses absolute or relative paths as prefixes. *)
535535+ let cmd = [ "apply"; "--binary"; "-p1"; "-" ] in
536536+ let buf_stdout = Buffer.create 256 in
537537+ let buf_stderr = Buffer.create 256 in
538538+ Eio.Switch.run @@ fun sw ->
539539+ let child =
540540+ Eio.Process.spawn proc ~sw ~cwd:cwd_eio
541541+ ~stdin:(Eio.Flow.string_source diff)
542542+ ~stdout:(Eio.Flow.buffer_sink buf_stdout)
543543+ ~stderr:(Eio.Flow.buffer_sink buf_stderr)
544544+ ("git" :: cmd)
545545+ in
546546+ let exit_status = Eio.Process.await child in
547547+ match exit_status with
548548+ | `Exited 0 -> Ok ()
549549+ | `Exited n | `Signaled n ->
550550+ Error
551551+ (Command_failed
552552+ ( String.concat " " ("git" :: cmd),
553553+ {
554554+ exit_code = n;
555555+ stdout = Buffer.contents buf_stdout;
556556+ stderr = Buffer.contents buf_stderr;
557557+ } ))
+37-7
lib/git.mli
···293293 ?remote:string ->
294294 Fpath.t ->
295295 string option
296296-(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote,
297297- or [None] if not set or the remote doesn't exist.
296296+(** [get_push_url ~proc ~fs ?remote path] returns the push URL for a remote, or
297297+ [None] if not set or the remote doesn't exist.
298298299299 @param remote Remote name (default: "origin") *)
300300···339339 url:string ->
340340 Fpath.t ->
341341 (unit, error) result
342342-(** [set_remote_url ~proc ~fs ~name ~url path] updates the URL for an existing remote. *)
342342+(** [set_remote_url ~proc ~fs ~name ~url path] updates the URL for an existing
343343+ remote. *)
343344344345val ensure_remote :
345346 proc:_ Eio.Process.mgr ->
···348349 url:string ->
349350 Fpath.t ->
350351 (unit, error) result
351351-(** [ensure_remote ~proc ~fs ~name ~url path] ensures a remote exists with the given URL.
352352- If the remote exists with a different URL, it is updated.
353353- If the remote doesn't exist, it is added. *)
352352+(** [ensure_remote ~proc ~fs ~name ~url path] ensures a remote exists with the
353353+ given URL. If the remote exists with a different URL, it is updated. If the
354354+ remote doesn't exist, it is added. *)
354355355356(** {1 Commit History} *)
356357···386387 ?max_count:int ->
387388 Fpath.t ->
388389 (log_entry list, error) result
389389-(** [log_range ~proc ~fs ~base ~tip ?max_count repo] retrieves commits between refs.
390390+(** [log_range ~proc ~fs ~base ~tip ?max_count repo] retrieves commits between
391391+ refs.
390392391393 Gets commits reachable from [tip] but not from [base] (i.e., [base..tip]).
392394···514516 bool
515517 (** [exists ~proc ~fs ~repo ~path] returns true if a worktree exists at [path]. *)
516518end
519519+520520+(** {1 Diff Operations} *)
521521+522522+val diff_trees :
523523+ proc:_ Eio.Process.mgr ->
524524+ fs:Eio.Fs.dir_ty Eio.Path.t ->
525525+ source:Fpath.t ->
526526+ target:Fpath.t ->
527527+ (string, error) result
528528+(** [diff_trees ~proc ~fs ~source ~target] generates a diff between two
529529+ directory trees using [git diff --no-index].
530530+531531+ Returns [Ok ""] if the trees are identical, [Ok diff] with the diff content
532532+ if they differ, or [Error] if the diff command fails.
533533+534534+ @param source The source directory (typically the monorepo subtree)
535535+ @param target The target directory (typically the checkout) *)
536536+537537+val apply_diff :
538538+ proc:_ Eio.Process.mgr ->
539539+ fs:Eio.Fs.dir_ty Eio.Path.t ->
540540+ cwd:Fpath.t ->
541541+ diff:string ->
542542+ (unit, error) result
543543+(** [apply_diff ~proc ~fs ~cwd ~diff] applies a diff to the directory at [cwd].
544544+545545+ Uses [git apply] to apply the diff. Returns [Ok ()] if the diff was applied
546546+ successfully or was empty, [Error] if the apply failed. *)
+603-322
lib/monopam.ml
···3535 | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name
3636 | Claude_error msg -> Fmt.pf ppf "Claude error: %s" msg
37373838-(** Returns a hint string for the given error, or None if no hint is available. *)
3838+(** Returns a hint string for the given error, or None if no hint is available.
3939+*)
3940let error_hint = function
4041 | Config_error _ ->
4141- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace."
4242+ Some
4343+ "Run 'monopam verse init --handle <your-handle>' to create a workspace."
4244 | Repo_error (Opam_repo.No_dev_repo _) ->
4343- Some "Add a 'dev-repo' field to the package's opam file pointing to a git URL."
4545+ Some
4646+ "Add a 'dev-repo' field to the package's opam file pointing to a git \
4747+ URL."
4448 | Repo_error (Opam_repo.Not_git_remote _) ->
4549 Some "The dev-repo must be a git URL (git+https:// or git://)."
4650 | Repo_error _ -> None
···5458 Some "Check that the remote is configured: git remote -v"
5559 | Git_error (Git.Branch_not_found _) ->
5660 Some "Check available branches: git branch -a"
5757- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git push" cmd ->
6161+ | Git_error (Git.Command_failed (cmd, _))
6262+ when String.starts_with ~prefix:"git push" cmd ->
5863 Some "Check your network connection and git credentials."
5959- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git subtree" cmd ->
6464+ | Git_error (Git.Command_failed (cmd, _))
6565+ when String.starts_with ~prefix:"git subtree" cmd ->
6066 Some "Run 'monopam status' to check repository state."
6167 | Git_error _ -> None
6268 | Dirty_state _ ->
6363- Some "Commit changes in the monorepo first: cd mono && git add -A && git commit"
6969+ Some
7070+ "Commit changes in the monorepo first: cd mono && git add -A && git \
7171+ commit"
6472 | Package_not_found _ ->
6573 Some "Check available packages: ls opam-repo/packages/"
6674 | Claude_error msg when String.starts_with ~prefix:"Failed to decode" msg ->
···132140 (fun pkg ->
133141 let repo = Package.repo_name pkg in
134142 let name = Package.name pkg in
135135- let existing = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
143143+ let existing =
144144+ try Hashtbl.find registered_by_repo repo with Not_found -> []
145145+ in
136146 Hashtbl.replace registered_by_repo repo (name :: existing))
137147 pkgs;
138148 (* Get unique subtree directories *)
···154164 let repo = Package.repo_name pkg in
155165 let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in
156166 let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in
157157- let registered = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
167167+ let registered =
168168+ try Hashtbl.find registered_by_repo repo with Not_found -> []
169169+ in
158170 try
159171 Eio.Path.read_dir eio_path
160172 |> List.filter_map (fun name ->
···241253 else dev_repo
242254 in
243255 let repo_cell =
244244- if i = 0 then Printf.sprintf "[**%s**](%s)" repo display_url
245245- else ""
256256+ if i = 0 then Printf.sprintf "[**%s**](%s)" repo display_url else ""
246257 in
247258 let synopsis = Option.value ~default:"" (Package.synopsis pkg) in
248259 Buffer.add_string buf
249249- (Printf.sprintf "| %s | %s | %s |\n" repo_cell
250250- (Package.name pkg) synopsis))
260260+ (Printf.sprintf "| %s | %s | %s |\n" repo_cell (Package.name pkg)
261261+ synopsis))
251262 pkgs)
252263 grouped;
253264 Buffer.add_string buf "\n---\n\n";
···366377(** Collect all external dependencies by scanning monorepo subtree directories.
367378 This scans all .opam files in each subtree directory to find dependencies,
368379 ensuring we get dependencies from all packages in a directory, not just
369369- those registered in the opam overlay.
370370- Returns a sorted, deduplicated list of package names that are dependencies
371371- but not packages in the repo itself. *)
380380+ those registered in the opam overlay. Returns a sorted, deduplicated list of
381381+ package names that are dependencies but not packages in the repo itself. *)
372382let collect_external_deps ~fs ~config pkgs =
373383 let monorepo = Config.Paths.monorepo config in
374384 (* Get unique repos to avoid scanning the same directory multiple times *)
···412422 (* Filter out packages that are in the repo *)
413423 List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps
414424415415-(** Generate dune-project content for the monorepo root.
416416- Lists all external dependencies as a virtual package. *)
425425+(** Generate dune-project content for the monorepo root. Lists all external
426426+ dependencies as a virtual package. *)
417427let generate_dune_project ~fs ~config pkgs =
418428 let external_deps = collect_external_deps ~fs ~config pkgs in
419429 let buf = Buffer.create 1024 in
···459469 Eio.Switch.run (fun sw ->
460470 let child =
461471 Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
462462- [ "git"; "commit"; "-m"; "Update dune-project with external dependencies" ]
472472+ [
473473+ "git";
474474+ "commit";
475475+ "-m";
476476+ "Update dune-project with external dependencies";
477477+ ]
463478 in
464479 ignore (Eio.Process.await child));
465480 Log.app (fun m ->
···624639 match (scheme, host) with
625640 | Some ("https" | "http"), Some "github.com" ->
626641 (* https://github.com/user/repo.git -> git@github.com:user/repo.git *)
627627- let path = if String.length path > 0 && path.[0] = '/' then
628628- String.sub path 1 (String.length path - 1)
629629- else path in
642642+ let path =
643643+ if String.length path > 0 && path.[0] = '/' then
644644+ String.sub path 1 (String.length path - 1)
645645+ else path
646646+ in
630647 Printf.sprintf "git@github.com:%s" path
631648 | Some ("https" | "http"), Some "tangled.org" ->
632649 (* https://tangled.org/@anil.recoil.org/foo -> git@git.recoil.org:anil.recoil.org/foo *)
633633- let path = if String.length path > 0 && path.[0] = '/' then
634634- String.sub path 1 (String.length path - 1)
635635- else path in
650650+ let path =
651651+ if String.length path > 0 && path.[0] = '/' then
652652+ String.sub path 1 (String.length path - 1)
653653+ else path
654654+ in
636655 (* Strip leading @ from username if present *)
637637- let path = if String.length path > 0 && path.[0] = '@' then
638638- String.sub path 1 (String.length path - 1)
639639- else path in
656656+ let path =
657657+ if String.length path > 0 && path.[0] = '@' then
658658+ String.sub path 1 (String.length path - 1)
659659+ else path
660660+ in
640661 (* Strip .git suffix if present *)
641641- let path = if String.ends_with ~suffix:".git" path then
642642- String.sub path 0 (String.length path - 4)
643643- else path in
662662+ let path =
663663+ if String.ends_with ~suffix:".git" path then
664664+ String.sub path 0 (String.length path - 4)
665665+ else path
666666+ in
644667 Printf.sprintf "git@git.recoil.org:%s" path
645668 | _ ->
646669 (* Return original URL for other cases *)
···743766 else begin
744767 (* Opam repo doesn't exist - clone it if we have a URL *)
745768 match opam_repo_url with
746746- | Some url ->
747747- Log.info (fun m -> m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
769769+ | Some url -> (
770770+ Log.info (fun m ->
771771+ m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
748772 let url = Uri.of_string url in
749773 let branch = Config.default_branch config in
750750- (match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
774774+ match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
751775 | Ok () -> Log.info (fun m -> m "Opam repo cloned successfully")
752752- | Error e -> Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e))
776776+ | Error e ->
777777+ Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e)
778778+ )
753779 | None ->
754754- Log.info (fun m -> m "Opam repo at %a does not exist and no URL provided" Fpath.pp opam_repo)
780780+ Log.info (fun m ->
781781+ m "Opam repo at %a does not exist and no URL provided" Fpath.pp
782782+ opam_repo)
755783 end;
756784 (* Ensure directories exist before computing status *)
757785 ensure_checkouts_dir ~fs:fs_t ~config;
···925953 let prefix = Package.subtree_prefix pkg in
926954 let checkouts_root = Config.Paths.checkouts config in
927955 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
928928- let branch = get_branch ~config pkg in
929929- let sync_branch = "monopam-sync" in
930956 if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then begin
931957 Log.debug (fun m -> m "Subtree %s not in monorepo, skipping" prefix);
932958 Ok ()
···941967 in
942968 let* () =
943969 if needs_clone then begin
944944- Log.info (fun m ->
945945- m "Creating checkout for %s" (Package.repo_name pkg));
970970+ Log.info (fun m -> m "Creating checkout for %s" (Package.repo_name pkg));
946971 ensure_checkout ~proc ~fs:(fs :> _ Eio.Path.t) ~config pkg
947972 end
948973 else Ok ()
949974 in
950950- let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
951951- let checkout_path = Fpath.to_string checkout_dir in
952952- (* Push subtree to a sync branch (avoids "branch is checked out" error) *)
953953- Log.info (fun m -> m "Pushing subtree %s to checkout" prefix);
954954- let* _ =
955955- run_git_in ~proc ~cwd:monorepo_eio
956956- [
957957- "subtree"; "push"; "--prefix"; prefix; checkout_path; sync_branch;
958958- ]
959959- in
960960- (* Merge sync branch into the target branch in checkout *)
961961- Log.debug (fun m -> m "Merging %s into %s" sync_branch branch);
962962- let* _ =
963963- run_git_in ~proc ~cwd:checkout_eio
964964- [ "merge"; "--ff-only"; sync_branch ]
975975+ (* Fast path: use diff-based approach instead of git subtree push *)
976976+ let subtree_path = Fpath.(monorepo / prefix) in
977977+ Log.info (fun m -> m "Comparing %s with checkout" prefix);
978978+ let* diff =
979979+ Git.diff_trees ~proc ~fs ~source:subtree_path ~target:checkout_dir
965980 in
966966- (* Delete the sync branch *)
967967- Log.debug (fun m -> m "Cleaning up %s branch" sync_branch);
968968- ignore
969969- (run_git_in ~proc ~cwd:checkout_eio [ "branch"; "-d"; sync_branch ]);
970970- Ok ()
981981+ if String.length diff = 0 then begin
982982+ Log.debug (fun m -> m "No changes in %s" prefix);
983983+ Ok ()
984984+ end
985985+ else begin
986986+ (* Apply diff to checkout *)
987987+ Log.info (fun m -> m "Applying changes to %s checkout" prefix);
988988+ let* () = Git.apply_diff ~proc ~fs ~cwd:checkout_dir ~diff in
989989+ (* Stage all changes *)
990990+ let* _ = run_git_in ~proc ~cwd:checkout_eio [ "add"; "-A" ] in
991991+ (* Commit with a descriptive message *)
992992+ let repo_name = Package.repo_name pkg in
993993+ let message = Printf.sprintf "Sync %s from monorepo" repo_name in
994994+ let* _ = run_git_in ~proc ~cwd:checkout_eio [ "commit"; "-m"; message ] in
995995+ Ok ()
996996+ end
971997 end
972998973999let push ~proc ~fs ~config ?package ?(upstream = false) () =
···10121038 | Ok pushed_repos ->
10131039 if upstream && pushed_repos <> [] then begin
10141040 Log.info (fun m ->
10151015- m "Pushing %d repos to upstream" (List.length pushed_repos));
10411041+ m "Pushing %d repos to upstream (parallel)"
10421042+ (List.length pushed_repos));
10161043 let checkouts_root = Config.Paths.checkouts config in
10171017- let total = List.length pushed_repos in
10181018- let rec push_upstream i = function
10191019- | [] -> Ok ()
10201020- | pkg :: rest -> (
10441044+ (* Push to remotes in parallel, limited to 2 concurrent pushes *)
10451045+ let push_results =
10461046+ Eio.Fiber.List.map ~max_fibers:2
10471047+ (fun pkg ->
10211048 let checkout_dir =
10221049 Package.checkout_dir ~checkouts_root pkg
10231050 in
10241051 let branch = get_branch ~config pkg in
10251025- (* Configure push URL (rewriting GitHub/tangled URLs to SSH) *)
10261052 let push_url = url_to_push_url (Package.dev_repo pkg) in
10271053 Log.info (fun m ->
10281028- m "[%d/%d] Pushing %s to %s" i total
10291029- (Package.repo_name pkg) push_url);
10541054+ m "Pushing %s to %s" (Package.repo_name pkg) push_url);
10301055 (* Set the push URL for origin *)
10311031- (match Git.set_push_url ~proc ~fs:fs_t ~url:push_url checkout_dir with
10321032- | Ok () -> ()
10331033- | Error e ->
10341034- Log.warn (fun m ->
10351035- m "Failed to set push URL: %a" Git.pp_error e));
10561056+ (match
10571057+ Git.set_push_url ~proc ~fs:fs_t ~url:push_url
10581058+ checkout_dir
10591059+ with
10601060+ | Ok () -> ()
10611061+ | Error e ->
10621062+ Log.warn (fun m ->
10631063+ m "Failed to set push URL: %a" Git.pp_error e));
10361064 match
10371065 Git.push_remote ~proc ~fs:fs_t ~branch checkout_dir
10381066 with
···10401068 Log.app (fun m ->
10411069 m " Pushed %s to %s (%s)" (Package.repo_name pkg)
10421070 push_url branch);
10431043- push_upstream (i + 1) rest
10711071+ Ok ()
10441072 | Error e -> Error (Git_error e))
10731073+ pushed_repos
10451074 in
10461046- push_upstream 1 pushed_repos
10751075+ (* Return first error if any *)
10761076+ match List.find_opt Result.is_error push_results with
10771077+ | Some (Error e) -> Error e
10781078+ | _ -> Ok ()
10471079 end
10481080 else Ok ()
10491081 end
···10741106 | `Push_remote -> Fmt.string ppf "push-remote"
1075110710761108let pp_sync_failure ppf f =
10771077- Fmt.pf ppf "%s (%a): %a" f.repo_name pp_sync_phase f.phase Git.pp_error f.error
11091109+ Fmt.pf ppf "%s (%a): %a" f.repo_name pp_sync_phase f.phase Git.pp_error
11101110+ f.error
1078111110791112let pp_sync_summary ppf s =
10801113 Fmt.pf ppf "Synced: %d, Unchanged: %d, Pulled: %d commits, Pushed: %d commits"
10811114 s.repos_synced s.repos_unchanged s.commits_pulled s.commits_pushed;
10821115 if s.errors <> [] then
10831083- Fmt.pf ppf "@.Errors (%d):@. @[<v>%a@]"
10841084- (List.length s.errors)
10851085- Fmt.(list ~sep:cut pp_sync_failure) s.errors
11161116+ Fmt.pf ppf "@.Errors (%d):@. @[<v>%a@]" (List.length s.errors)
11171117+ Fmt.(list ~sep:cut pp_sync_failure)
11181118+ s.errors
1086111910871120(* Helper to ensure checkout exists, returning whether it was cloned *)
10881121let ensure_checkout_safe ~proc ~fs ~config pkg =
···11011134 Log.info (fun m ->
11021135 m "Cloning %s from %a (branch: %s)" (Package.repo_name pkg) Uri.pp
11031136 (Package.dev_repo pkg) branch);
11041104- match Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir with
11371137+ match
11381138+ Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir
11391139+ with
11051140 | Ok () -> Ok (true, 0)
11061141 | Error e -> Error e
11071142 end
···11471182 Log.info (fun m -> m "Pushing %s to %s" (Package.repo_name pkg) push_url);
11481183 (* Set the push URL for origin *)
11491184 (match Git.set_push_url ~proc ~fs ~url:push_url checkout_dir with
11501150- | Ok () -> ()
11511151- | Error e ->
11521152- Log.warn (fun m -> m "Failed to set push URL: %a" Git.pp_error e));
11851185+ | Ok () -> ()
11861186+ | Error e -> Log.warn (fun m -> m "Failed to set push URL: %a" Git.pp_error e));
11531187 Git.push_remote ~proc ~fs ~branch checkout_dir
1154118811551189(* Sanitize handle for use as git remote name *)
11561190let sanitize_remote_name handle =
11571191 (* Replace @ and . with - for valid git remote names *)
11581158- String.map (function
11591159- | '@' | '.' -> '-'
11601160- | c -> c) handle
11921192+ String.map (function '@' | '.' -> '-' | c -> c) handle
1161119311621194(* Ensure verse remotes for a single repo *)
11631195let ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg =
···11701202 else begin
11711203 (* Get all verse members who have this repo *)
11721204 let members_with_repo =
11731173- Hashtbl.find_opt verse_subtrees repo_name
11741174- |> Option.value ~default:[]
12051205+ Hashtbl.find_opt verse_subtrees repo_name |> Option.value ~default:[]
11751206 in
1176120711771208 (* Get current remotes *)
11781209 let current_remotes = Git.list_remotes ~proc ~fs checkout_dir in
11791210 let verse_remotes =
11801180- List.filter (fun r -> String.starts_with ~prefix:"verse-" r) current_remotes
12111211+ List.filter
12121212+ (fun r -> String.starts_with ~prefix:"verse-" r)
12131213+ current_remotes
11811214 in
1182121511831216 (* Build set of expected verse remotes *)
11841217 let expected_remotes =
11851185- List.map (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle) members_with_repo
12181218+ List.map
12191219+ (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle)
12201220+ members_with_repo
11861221 in
1187122211881223 (* Add/update remotes for verse members *)
11891189- List.iter (fun (handle, verse_mono_path) ->
12241224+ List.iter
12251225+ (fun (handle, verse_mono_path) ->
11901226 let remote_name = "verse-" ^ sanitize_remote_name handle in
11911227 (* Point to their src/ checkout for this repo *)
11921228 let verse_src = Fpath.(parent verse_mono_path / "src" / repo_name) in
11931229 if Sys.file_exists (Fpath.to_string verse_src) then begin
11941230 let url = Fpath.to_string verse_src in
11951195- match Git.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir with
11961196- | Ok () -> Log.debug (fun m -> m "Ensured verse remote %s -> %s" remote_name url)
11971197- | Error e -> Log.warn (fun m -> m "Failed to add verse remote %s: %a" remote_name Git.pp_error e)
12311231+ match
12321232+ Git.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir
12331233+ with
12341234+ | Ok () ->
12351235+ Log.debug (fun m ->
12361236+ m "Ensured verse remote %s -> %s" remote_name url)
12371237+ | Error e ->
12381238+ Log.warn (fun m ->
12391239+ m "Failed to add verse remote %s: %a" remote_name Git.pp_error
12401240+ e)
11981241 end)
11991242 members_with_repo;
1200124312011244 (* Remove outdated verse remotes *)
12021202- List.iter (fun remote_name ->
12451245+ List.iter
12461246+ (fun remote_name ->
12031247 if not (List.mem remote_name expected_remotes) then begin
12041248 Log.debug (fun m -> m "Removing outdated verse remote %s" remote_name);
12051249 match Git.remove_remote ~proc ~fs ~name:remote_name checkout_dir with
12061250 | Ok () -> ()
12071207- | Error e -> Log.warn (fun m -> m "Failed to remove verse remote %s: %a" remote_name Git.pp_error e)
12511251+ | Error e ->
12521252+ Log.warn (fun m ->
12531253+ m "Failed to remove verse remote %s: %a" remote_name
12541254+ Git.pp_error e)
12081255 end)
12091256 verse_remotes
12101257 end
···12121259(* Sync verse remotes for all repos *)
12131260let sync_verse_remotes ~proc ~fs ~config ~verse_config repos =
12141261 Log.app (fun m -> m " Updating verse remotes...");
12151215- let verse_subtrees = Verse.get_verse_subtrees ~proc ~fs ~config:verse_config () in
12161216- List.iter (fun pkg ->
12621262+ let verse_subtrees =
12631263+ Verse.get_verse_subtrees ~proc ~fs ~config:verse_config ()
12641264+ in
12651265+ List.iter
12661266+ (fun pkg ->
12171267 ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg)
12181268 repos
12191269···12221272 let checkouts_root = Config.Paths.checkouts config in
12231273 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
12241274 let remotes = Git.list_remotes ~proc ~fs checkout_dir in
12251225- let verse_remotes = List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes in
12261226- List.iter (fun remote ->
12751275+ let verse_remotes =
12761276+ List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes
12771277+ in
12781278+ List.iter
12791279+ (fun remote ->
12271280 Log.debug (fun m -> m "Fetching from verse remote %s" remote);
12281281 match Git.fetch ~proc ~fs ~remote checkout_dir with
12291282 | Ok () -> ()
12301230- | Error e -> Log.debug (fun m -> m "Failed to fetch from %s: %a" remote Git.pp_error e))
12831283+ | Error e ->
12841284+ Log.debug (fun m ->
12851285+ m "Failed to fetch from %s: %a" remote Git.pp_error e))
12311286 verse_remotes
1232128712331233-let sync ~proc ~fs ~config ?package ?(remote = false) ?(skip_push = false) ?(skip_pull = false) () =
12881288+let sync ~proc ~fs ~config ?package ?(remote = false) ?(skip_push = false)
12891289+ ?(skip_pull = false) () =
12341290 let fs_t = fs_typed fs in
12351291 (* Update the opam repo first - clone if needed *)
12361292 let opam_repo = Config.Paths.opam_repo config in
···12761332 let total = List.length repos in
12771333 Log.app (fun m -> m "Syncing %d repositories..." total);
1278133413351335+ (* Build status lookup for optimization *)
13361336+ let status_by_name =
13371337+ List.map (fun s -> (Package.name s.Status.package, s)) statuses
13381338+ in
13391339+ let sync_needs_push = function
13401340+ | Status.Subtree_ahead _ | Status.Trees_differ -> true
13411341+ | Status.In_sync | Status.Subtree_behind _ | Status.Unknown ->
13421342+ false
13431343+ in
13441344+ let needs_push pkg =
13451345+ List.assoc_opt (Package.name pkg) status_by_name
13461346+ |> Option.fold ~none:true ~some:(fun s ->
13471347+ sync_needs_push s.Status.subtree_sync)
13481348+ in
13491349+12791350 (* Step 2: Push phase - export monorepo changes to checkouts (PARALLEL) *)
12801351 (* git subtree push is read-only on the monorepo, so safe to parallelize *)
13521352+ (* OPTIMIZATION: skip packages already in sync *)
12811353 let push_results =
12821354 if skip_push then begin
12831283- Log.app (fun m -> m " Skipping push to checkouts (--skip-push)");
13551355+ Log.app (fun m ->
13561356+ m " Skipping push to checkouts (--skip-push)");
12841357 List.map (fun pkg -> Ok (Package.repo_name pkg)) repos
12851358 end
12861359 else begin
12871287- Log.app (fun m -> m " Pushing monorepo changes to checkouts (parallel)...");
12881288- Eio.Fiber.List.map ~max_fibers:12 (fun pkg ->
12891289- let repo_name = Package.repo_name pkg in
12901290- Log.info (fun m -> m "Push to checkout: %s" repo_name);
12911291- match push_one ~proc ~fs ~config pkg with
12921292- | Ok () -> Ok repo_name
12931293- | Error (Git_error e) ->
12941294- Error { repo_name; phase = `Push_checkout; error = e }
12951295- | Error _ -> Ok repo_name)
12961296- repos
13601360+ let to_push, to_skip = List.partition needs_push repos in
13611361+ Log.app (fun m ->
13621362+ m " Pushing monorepo changes to checkouts (parallel)...");
13631363+ if to_skip <> [] then
13641364+ Log.app (fun m ->
13651365+ m " Skipping %d already-synced packages"
13661366+ (List.length to_skip));
13671367+ let pushed =
13681368+ Eio.Fiber.List.map ~max_fibers:4
13691369+ (fun pkg ->
13701370+ let repo_name = Package.repo_name pkg in
13711371+ Log.info (fun m -> m "Push to checkout: %s" repo_name);
13721372+ match push_one ~proc ~fs ~config pkg with
13731373+ | Ok () -> Ok repo_name
13741374+ | Error (Git_error e) ->
13751375+ Error
13761376+ { repo_name; phase = `Push_checkout; error = e }
13771377+ | Error _ -> Ok repo_name)
13781378+ to_push
13791379+ in
13801380+ let skipped_ok =
13811381+ List.map (fun pkg -> Ok (Package.repo_name pkg)) to_skip
13821382+ in
13831383+ pushed @ skipped_ok
12971384 end
12981385 in
12991386 let push_errors =
13001300- List.filter_map (function Error e -> Some e | Ok _ -> None) push_results
13871387+ List.filter_map
13881388+ (function Error e -> Some e | Ok _ -> None)
13891389+ push_results
13011390 in
1302139113031392 (* Steps 3-5: Pull phases (fetch, merge, subtree) - skip if --skip-pull *)
13041304- let fetch_errors, unchanged_count, total_commits_pulled, merge_errors, subtree_errors =
13931393+ let ( fetch_errors,
13941394+ unchanged_count,
13951395+ total_commits_pulled,
13961396+ merge_errors,
13971397+ subtree_errors ) =
13051398 if skip_pull then begin
13061306- Log.app (fun m -> m " Skipping pull from remotes (--skip-pull)");
13991399+ Log.app (fun m ->
14001400+ m " Skipping pull from remotes (--skip-pull)");
13071401 ([], List.length repos, 0, ref [], ref [])
13081402 end
13091403 else begin
13101404 (* Step 3: Fetch phase - clone/fetch from remotes (PARALLEL) *)
13111405 Log.app (fun m -> m " Fetching from remotes (parallel)...");
13121312- let fetch_results = Eio.Fiber.List.map ~max_fibers:3 (fun pkg ->
13131313- let repo_name = Package.repo_name pkg in
13141314- (* First ensure checkout exists *)
13151315- match ensure_checkout_safe ~proc ~fs:fs_t ~config pkg with
13161316- | Error e -> Error { repo_name; phase = `Fetch; error = e }
13171317- | Ok (was_cloned, _) ->
13181318- if was_cloned then Ok (repo_name, true, 0)
13191319- else
13201320- match fetch_checkout_safe ~proc ~fs:fs_t ~config pkg with
13211321- | Error e -> Error { repo_name; phase = `Fetch; error = e }
13221322- | Ok commits -> Ok (repo_name, false, commits))
13231323- repos
14061406+ let fetch_results =
14071407+ Eio.Fiber.List.map ~max_fibers:4
14081408+ (fun pkg ->
14091409+ let repo_name = Package.repo_name pkg in
14101410+ (* First ensure checkout exists *)
14111411+ match
14121412+ ensure_checkout_safe ~proc ~fs:fs_t ~config pkg
14131413+ with
14141414+ | Error e ->
14151415+ Error { repo_name; phase = `Fetch; error = e }
14161416+ | Ok (was_cloned, _) -> (
14171417+ if was_cloned then Ok (repo_name, true, 0)
14181418+ else
14191419+ match
14201420+ fetch_checkout_safe ~proc ~fs:fs_t ~config pkg
14211421+ with
14221422+ | Error e ->
14231423+ Error { repo_name; phase = `Fetch; error = e }
14241424+ | Ok commits -> Ok (repo_name, false, commits)))
14251425+ repos
13241426 in
13251427 let fetch_errs, fetch_successes =
13261326- List.partition_map (function
13271327- | Error e -> Left e
13281328- | Ok r -> Right r)
14281428+ List.partition_map
14291429+ (function Error e -> Left e | Ok r -> Right r)
13291430 fetch_results
13301431 in
13311331- let cloned = List.filter (fun (_, c, _) -> c) fetch_successes in
13321332- let updated = List.filter (fun (_, c, commits) -> not c && commits > 0) fetch_successes in
13331333- let unchanged = List.length fetch_successes - List.length cloned - List.length updated in
13341334- let commits_pulled = List.fold_left (fun acc (_, _, c) -> acc + c) 0 fetch_successes in
13351335- Log.app (fun m -> m " Pulled: %d cloned, %d updated, %d unchanged"
13361336- (List.length cloned) (List.length updated) unchanged);
14321432+ let cloned =
14331433+ List.filter (fun (_, c, _) -> c) fetch_successes
14341434+ in
14351435+ let updated =
14361436+ List.filter
14371437+ (fun (_, c, commits) -> (not c) && commits > 0)
14381438+ fetch_successes
14391439+ in
14401440+ let unchanged =
14411441+ List.length fetch_successes
14421442+ - List.length cloned - List.length updated
14431443+ in
14441444+ let commits_pulled =
14451445+ List.fold_left
14461446+ (fun acc (_, _, c) -> acc + c)
14471447+ 0 fetch_successes
14481448+ in
14491449+ Log.app (fun m ->
14501450+ m " Pulled: %d cloned, %d updated, %d unchanged"
14511451+ (List.length cloned) (List.length updated) unchanged);
1337145213381453 (* Step 4: Merge phase - fast-forward merge checkouts (SEQUENTIAL) *)
13391454 Log.app (fun m -> m " Merging checkouts...");
13401455 let merge_errs = ref [] in
13411341- List.iter (fun pkg ->
14561456+ List.iter
14571457+ (fun pkg ->
13421458 match merge_checkout_safe ~proc ~fs:fs_t ~config pkg with
13431459 | Ok () -> ()
13441460 | Error e ->
13451345- merge_errs := { repo_name = Package.repo_name pkg;
13461346- phase = `Merge; error = e } :: !merge_errs)
14611461+ merge_errs :=
14621462+ {
14631463+ repo_name = Package.repo_name pkg;
14641464+ phase = `Merge;
14651465+ error = e;
14661466+ }
14671467+ :: !merge_errs)
13471468 repos;
1348146913491470 (* Step 5: Subtree phase - pull subtrees into monorepo (SEQUENTIAL) *)
···13531474 let subtree_errs = ref [] in
13541475 if monorepo_dirty then begin
13551476 Log.warn (fun m ->
13561356- m "Monorepo has uncommitted changes, skipping subtree pulls");
13571357- Log.app (fun m -> m " Skipping subtree updates (local modifications)...")
14771477+ m
14781478+ "Monorepo has uncommitted changes, skipping subtree \
14791479+ pulls");
14801480+ Log.app (fun m ->
14811481+ m " Skipping subtree updates (local modifications)...")
13581482 end
13591483 else begin
13601484 Log.app (fun m -> m " Updating subtrees...");
13611361- List.iteri (fun i pkg ->
14851485+ List.iteri
14861486+ (fun i pkg ->
13621487 Log.info (fun m ->
13631488 m "[%d/%d] Subtree %s" (i + 1) total
13641489 (Package.subtree_prefix pkg));
13651490 match pull_subtree ~proc ~fs ~config pkg with
13661491 | Ok _ -> ()
13671492 | Error (Git_error e) ->
13681368- subtree_errs := { repo_name = Package.repo_name pkg;
13691369- phase = `Subtree; error = e } :: !subtree_errs
14931493+ subtree_errs :=
14941494+ {
14951495+ repo_name = Package.repo_name pkg;
14961496+ phase = `Subtree;
14971497+ error = e;
14981498+ }
14991499+ :: !subtree_errs
13701500 | Error _ -> ())
13711501 repos
13721502 end;
13731373- (fetch_errs, unchanged, commits_pulled, merge_errs, subtree_errs)
15031503+ ( fetch_errs,
15041504+ unchanged,
15051505+ commits_pulled,
15061506+ merge_errs,
15071507+ subtree_errs )
13741508 end
13751509 in
1376151013771511 (* Step 5.5: Verse remotes - update and fetch from verse members *)
13781512 (match Verse_config.load ~fs:(fs_t :> _ Eio.Path.t) () with
13791379- | Error _ -> () (* No verse config, skip verse remotes *)
13801380- | Ok verse_config ->
13811381- sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config repos;
13821382- (* Fetch from verse remotes in parallel *)
13831383- Log.app (fun m -> m " Fetching from verse remotes...");
13841384- Eio.Fiber.List.iter ~max_fibers:4 (fun pkg ->
13851385- fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
13861386- repos);
15131513+ | Error _ -> () (* No verse config, skip verse remotes *)
15141514+ | Ok verse_config ->
15151515+ sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config repos;
15161516+ (* Fetch from verse remotes in parallel *)
15171517+ Log.app (fun m -> m " Fetching from verse remotes...");
15181518+ Eio.Fiber.List.iter ~max_fibers:4
15191519+ (fun pkg -> fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
15201520+ repos);
1387152113881522 (* Step 6: Finalize - write README.md, CLAUDE.md, and dune-project (SEQUENTIAL) *)
13891389- Log.app (fun m -> m " Writing README.md, CLAUDE.md, and dune-project...");
15231523+ Log.app (fun m ->
15241524+ m " Writing README.md, CLAUDE.md, and dune-project...");
13901525 write_readme ~proc ~fs:fs_t ~config all_pkgs;
13911526 write_claude_md ~proc ~fs:fs_t ~config;
13921527 write_dune_project ~proc ~fs:fs_t ~config all_pkgs;
···13961531 if remote then begin
13971532 Log.app (fun m -> m " Pushing to upstream remotes...");
13981533 (* Limit to 2 concurrent pushes to avoid overwhelming remotes *)
13991399- let push_results = Eio.Fiber.List.map ~max_fibers:2 (fun pkg ->
14001400- let repo_name = Package.repo_name pkg in
14011401- match push_remote_safe ~proc ~fs:fs_t ~config pkg with
14021402- | Error e -> Error { repo_name; phase = `Push_remote; error = e }
14031403- | Ok () ->
14041404- Log.app (fun m -> m " Pushed %s" repo_name);
14051405- Ok repo_name)
14061406- repos
15341534+ let push_results =
15351535+ Eio.Fiber.List.map ~max_fibers:2
15361536+ (fun pkg ->
15371537+ let repo_name = Package.repo_name pkg in
15381538+ match push_remote_safe ~proc ~fs:fs_t ~config pkg with
15391539+ | Error e ->
15401540+ Error { repo_name; phase = `Push_remote; error = e }
15411541+ | Ok () ->
15421542+ Log.app (fun m -> m " Pushed %s" repo_name);
15431543+ Ok repo_name)
15441544+ repos
14071545 in
14081546 let errors, successes =
14091409- List.partition_map (function
14101410- | Error e -> Left e
14111411- | Ok r -> Right r)
15471547+ List.partition_map
15481548+ (function Error e -> Left e | Ok r -> Right r)
14121549 push_results
14131550 in
14141414- Log.app (fun m -> m " Pushed: %d repos to upstream" (List.length successes));
15511551+ Log.app (fun m ->
15521552+ m " Pushed: %d repos to upstream" (List.length successes));
14151553 errors
14161554 end
14171555 else []
···1419155714201558 (* Collect all errors *)
14211559 let all_errors =
14221422- push_errors @ fetch_errors @ !merge_errors @ !subtree_errors @ remote_errors
15601560+ push_errors @ fetch_errors @ !merge_errors @ !subtree_errors
15611561+ @ remote_errors
15621562+ in
15631563+ let summary =
15641564+ {
15651565+ repos_synced = List.length repos - List.length all_errors;
15661566+ repos_unchanged = unchanged_count;
15671567+ commits_pulled = total_commits_pulled;
15681568+ commits_pushed = 0;
15691569+ (* TODO: track this *)
15701570+ errors = all_errors;
15711571+ }
14231572 in
14241424- let summary = {
14251425- repos_synced = List.length repos - List.length all_errors;
14261426- repos_unchanged = unchanged_count;
14271427- commits_pulled = total_commits_pulled;
14281428- commits_pushed = 0; (* TODO: track this *)
14291429- errors = all_errors;
14301430- } in
1431157314321574 (* Print summary *)
14331433- Log.app (fun m -> m "@.Summary: %d synced, %d errors"
14341434- summary.repos_synced (List.length summary.errors));
15751575+ Log.app (fun m ->
15761576+ m "@.Summary: %d synced, %d errors" summary.repos_synced
15771577+ (List.length summary.errors));
14351578 if summary.errors <> [] then
14361436- List.iter (fun e ->
14371437- Log.warn (fun m -> m " %a" pp_sync_failure e))
15791579+ List.iter
15801580+ (fun e -> Log.warn (fun m -> m " %a" pp_sync_failure e))
14381581 summary.errors;
1439158214401583 Ok summary
···14441587(* Opam metadata sync: copy .opam files from monorepo subtrees to opam-repo *)
1445158814461589type opam_sync_result = {
14471447- synced : string list; (* packages that were updated *)
14481448- unchanged : string list; (* packages that were already in sync *)
14491449- missing : string list; (* packages where monorepo has no .opam file *)
14501450- orphaned : string list; (* packages in opam-repo but subtree missing from monorepo *)
15901590+ synced : string list; (* packages that were updated *)
15911591+ unchanged : string list; (* packages that were already in sync *)
15921592+ missing : string list; (* packages where monorepo has no .opam file *)
15931593+ orphaned : string list;
15941594+ (* packages in opam-repo but subtree missing from monorepo *)
14511595}
1452159614531597let pp_opam_sync_result ppf r =
···14561600 (List.length r.orphaned)
1457160114581602(* Read file contents safely, returning None if file doesn't exist *)
14591459-let read_file_opt path =
14601460- try Some (Eio.Path.load path)
14611461- with Eio.Io _ -> None
16031603+let read_file_opt path = try Some (Eio.Path.load path) with Eio.Io _ -> None
1462160414631605(* Sync a single package's opam file from monorepo to opam-repo *)
14641606let sync_opam_file ~proc ~fs ~config pkg =
···14691611 let version = Package.version pkg in
1470161214711613 (* Source: monorepo/<subtree>/<name>.opam *)
14721472- let src_path = Eio.Path.(fs / Fpath.to_string monorepo / subtree_prefix / (name ^ ".opam")) in
16141614+ let src_path =
16151615+ Eio.Path.(fs / Fpath.to_string monorepo / subtree_prefix / (name ^ ".opam"))
16161616+ in
1473161714741618 (* Destination: opam-repo/packages/<name>/<name>.<version>/opam *)
14751475- let pkg_dir = Fpath.(opam_repo / "packages" / name / (name ^ "." ^ version)) in
16191619+ let pkg_dir =
16201620+ Fpath.(opam_repo / "packages" / name / (name ^ "." ^ version))
16211621+ in
14761622 let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in
1477162314781624 match read_file_opt src_path with
···14811627 `Missing name
14821628 | Some src_content ->
14831629 let dst_content = read_file_opt dst_path in
14841484- if Some src_content = dst_content then
14851485- `Unchanged name
16301630+ if Some src_content = dst_content then `Unchanged name
14861631 else begin
14871632 (* Create destination directory if needed *)
14881633 let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in
···14921637 Eio.Path.save ~create:(`Or_truncate 0o644) dst_path src_content;
14931638 (* Stage the change *)
14941639 let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
14951495- let rel_path = Printf.sprintf "packages/%s/%s.%s/opam" name name version in
16401640+ let rel_path =
16411641+ Printf.sprintf "packages/%s/%s.%s/opam" name name version
16421642+ in
14961643 Eio.Switch.run (fun sw ->
14971644 let child =
14981645 Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
···15161663 if pkgs = [] && package <> None then
15171664 Error (Package_not_found (Option.get package))
15181665 else begin
15191519- Log.app (fun m -> m "Syncing opam files for %d packages..." (List.length pkgs));
16661666+ Log.app (fun m ->
16671667+ m "Syncing opam files for %d packages..." (List.length pkgs));
15201668 let synced = ref [] in
15211669 let unchanged = ref [] in
15221670 let missing = ref [] in
15231671 let orphaned = ref [] in
1524167215251673 (* Check each package *)
15261526- List.iter (fun pkg ->
16741674+ List.iter
16751675+ (fun pkg ->
15271676 (* Check if the subtree exists in monorepo *)
15281677 let monorepo = Config.Paths.monorepo config in
15291678 let subtree_prefix = Package.subtree_prefix pkg in
15301530- let subtree_exists = Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subtree_prefix in
16791679+ let subtree_exists =
16801680+ Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subtree_prefix
16811681+ in
1531168215321683 if not subtree_exists then
15331684 (* Subtree doesn't exist - package is orphaned in opam-repo *)
···15391690 | `Missing name -> missing := name :: !missing)
15401691 pkgs;
1541169215421542- let result = {
15431543- synced = List.rev !synced;
15441544- unchanged = List.rev !unchanged;
15451545- missing = List.rev !missing;
15461546- orphaned = List.rev !orphaned;
15471547- } in
16931693+ let result =
16941694+ {
16951695+ synced = List.rev !synced;
16961696+ unchanged = List.rev !unchanged;
16971697+ missing = List.rev !missing;
16981698+ orphaned = List.rev !orphaned;
16991699+ }
17001700+ in
1548170115491702 (* Commit if there were changes *)
15501703 if result.synced <> [] then begin
15511704 let opam_repo = Config.Paths.opam_repo config in
15521705 let opam_repo_eio = Eio.Path.(fs / Fpath.to_string opam_repo) in
15531553- let msg = Printf.sprintf "Sync opam files from monorepo (%d packages)"
15541554- (List.length result.synced) in
17061706+ let msg =
17071707+ Printf.sprintf "Sync opam files from monorepo (%d packages)"
17081708+ (List.length result.synced)
17091709+ in
15551710 Eio.Switch.run (fun sw ->
15561711 let child =
15571712 Eio.Process.spawn proc ~sw ~cwd:opam_repo_eio
···1563171815641719 (* Report orphaned packages *)
15651720 if result.orphaned <> [] then begin
15661566- Log.warn (fun m -> m "Found %d orphaned packages in opam-repo (subtree missing from monorepo):"
15671567- (List.length result.orphaned));
15681568- List.iter (fun name ->
15691569- Log.warn (fun m -> m " %s" name))
17211721+ Log.warn (fun m ->
17221722+ m
17231723+ "Found %d orphaned packages in opam-repo (subtree missing from \
17241724+ monorepo):"
17251725+ (List.length result.orphaned));
17261726+ List.iter
17271727+ (fun name -> Log.warn (fun m -> m " %s" name))
15701728 result.orphaned;
15711571- Log.warn (fun m -> m "To remove, delete from opam-repo/packages/ and commit.")
17291729+ Log.warn (fun m ->
17301730+ m "To remove, delete from opam-repo/packages/ and commit.")
15721731 end;
1573173215741733 Log.app (fun m -> m "%a" pp_opam_sync_result result);
···1605176416061765(* Changes command - generate weekly changelogs using Claude *)
1607176616081608-let changes ~proc ~fs ~config ~clock ?package ?(weeks = 1) ?(history = 12) ?(dry_run = false) () =
17671767+let changes ~proc ~fs ~config ~clock ?package ?(weeks = 1) ?(history = 12)
17681768+ ?(dry_run = false) () =
16091769 let fs_t = fs_typed fs in
16101770 let monorepo = Config.Paths.monorepo config in
1611177116121772 (* Get current time and calculate week boundaries *)
16131773 let now = Eio.Time.now clock in
16141614- let now_ptime = match Ptime.of_float_s now with
16151615- | Some t -> t
16161616- | None -> Ptime.v (0, 0L) (* fallback to epoch *)
17741774+ let now_ptime =
17751775+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
17761776+ (* fallback to epoch *)
16171777 in
1618177816191779 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
16201780 | Error e -> Error e
16211781 | Ok all_pkgs ->
16221782 let repos = unique_repos all_pkgs in
16231623- let repos = match package with
17831783+ let repos =
17841784+ match package with
16241785 | None -> repos
16251786 | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
16261787 in
16271788 if repos = [] && package <> None then
16281789 Error (Package_not_found (Option.get package))
16291790 else begin
16301630- Log.info (fun m -> m "Processing changelogs for %d repositories" (List.length repos));
17911791+ Log.info (fun m ->
17921792+ m "Processing changelogs for %d repositories" (List.length repos));
1631179316321794 (* Process each repository *)
16331795 let all_changes_files = ref [] in
16341796 let rec process_repos = function
16351797 | [] -> Ok ()
16361636- | pkg :: rest ->
17981798+ | pkg :: rest -> (
16371799 let repo_name = Package.repo_name pkg in
1638180016391801 Log.info (fun m -> m "Processing %s" repo_name);
···16411803 (* Load existing changes from .changes/<repo>.json *)
16421804 match Changes.load ~fs:fs_t ~monorepo repo_name with
16431805 | Error e -> Error (Claude_error e)
16441644- | Ok changes_file ->
18061806+ | Ok changes_file -> (
16451807 (* Process each week *)
16461808 let rec process_weeks week_offset updated_cf =
16471809 if week_offset >= weeks then Ok updated_cf
16481810 else begin
16491811 (* Calculate week boundaries *)
16501650- let offset_seconds = float_of_int (week_offset * 7 * 24 * 60 * 60) in
16511651- let week_time = match Ptime.of_float_s (now -. offset_seconds) with
18121812+ let offset_seconds =
18131813+ float_of_int (week_offset * 7 * 24 * 60 * 60)
18141814+ in
18151815+ let week_time =
18161816+ match Ptime.of_float_s (now -. offset_seconds) with
16521817 | Some t -> t
16531818 | None -> now_ptime
16541819 in
16551655- let week_start, week_end = Changes.week_of_ptime week_time in
18201820+ let week_start, week_end =
18211821+ Changes.week_of_ptime week_time
18221822+ in
1656182316571824 (* Skip if week already has an entry *)
16581825 if Changes.has_week updated_cf ~week_start then begin
16591659- Log.info (fun m -> m " Week %s already has entry, skipping" week_start);
18261826+ Log.info (fun m ->
18271827+ m " Week %s already has entry, skipping" week_start);
16601828 process_weeks (week_offset + 1) updated_cf
16611829 end
16621830 else begin
16631831 (* Get commits for this week *)
16641832 let since = week_start ^ " 00:00:00" in
16651833 let until = week_end ^ " 23:59:59" in
16661666- match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
18341834+ match
18351835+ Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name
18361836+ monorepo
18371837+ with
16671838 | Error e -> Error (Git_error e)
16681839 | Ok commits ->
16691840 if commits = [] then begin
16701670- Log.info (fun m -> m " No commits for week %s" week_start);
18411841+ Log.info (fun m ->
18421842+ m " No commits for week %s" week_start);
16711843 process_weeks (week_offset + 1) updated_cf
16721844 end
16731845 else begin
16741674- Log.info (fun m -> m " Found %d commits for week %s" (List.length commits) week_start);
18461846+ Log.info (fun m ->
18471847+ m " Found %d commits for week %s"
18481848+ (List.length commits) week_start);
1675184916761850 if dry_run then begin
16771677- Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s week %s"
16781678- (List.length commits) repo_name week_start);
18511851+ Log.app (fun m ->
18521852+ m
18531853+ " [DRY RUN] Would analyze %d commits \
18541854+ for %s week %s"
18551855+ (List.length commits) repo_name week_start);
16791856 process_weeks (week_offset + 1) updated_cf
16801857 end
16811858 else begin
16821859 (* Analyze commits with Claude *)
16831860 Eio.Switch.run @@ fun sw ->
16841684- match Changes.analyze_commits ~sw ~process_mgr:proc ~clock
16851685- ~repository:repo_name ~week_start ~week_end commits with
18611861+ match
18621862+ Changes.analyze_commits ~sw ~process_mgr:proc
18631863+ ~clock ~repository:repo_name ~week_start
18641864+ ~week_end commits
18651865+ with
16861866 | Error e -> Error (Claude_error e)
16871867 | Ok None ->
16881688- Log.info (fun m -> m " No user-facing changes for week %s" week_start);
18681868+ Log.info (fun m ->
18691869+ m " No user-facing changes for week %s"
18701870+ week_start);
16891871 process_weeks (week_offset + 1) updated_cf
16901872 | Ok (Some response) ->
16911691- Log.app (fun m -> m " Generated changelog for %s week %s" repo_name week_start);
18731873+ Log.app (fun m ->
18741874+ m " Generated changelog for %s week %s"
18751875+ repo_name week_start);
16921876 (* Create new entry *)
16931693- let first_hash = (List.hd commits).Git.hash in
16941694- let last_hash = (List.hd (List.rev commits)).Git.hash in
16951695- let entry : Changes.weekly_entry = {
16961696- week_start;
16971697- week_end;
16981698- summary = response.Changes.summary;
16991699- changes = response.Changes.changes;
17001700- commit_range = {
17011701- from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
17021702- to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
17031703- count = List.length commits;
17041704- };
17051705- } in
18771877+ let first_hash =
18781878+ (List.hd commits).Git.hash
18791879+ in
18801880+ let last_hash =
18811881+ (List.hd (List.rev commits)).Git.hash
18821882+ in
18831883+ let entry : Changes.weekly_entry =
18841884+ {
18851885+ week_start;
18861886+ week_end;
18871887+ summary = response.Changes.summary;
18881888+ changes = response.Changes.changes;
18891889+ commit_range =
18901890+ {
18911891+ from_hash =
18921892+ String.sub first_hash 0
18931893+ (min 7
18941894+ (String.length first_hash));
18951895+ to_hash =
18961896+ String.sub last_hash 0
18971897+ (min 7 (String.length last_hash));
18981898+ count = List.length commits;
18991899+ };
19001900+ }
19011901+ in
17061902 (* Add entry (sorted by date descending) *)
17071903 let new_entries =
17081904 entry :: updated_cf.Changes.entries
17091905 |> List.sort (fun e1 e2 ->
17101710- String.compare e2.Changes.week_start e1.Changes.week_start)
19061906+ String.compare e2.Changes.week_start
19071907+ e1.Changes.week_start)
17111908 in
17121909 process_weeks (week_offset + 1)
17131910 { updated_cf with entries = new_entries }
···17181915 in
17191916 match process_weeks 0 changes_file with
17201917 | Error e -> Error e
17211721- | Ok updated_cf ->
19181918+ | Ok updated_cf -> (
17221919 (* Save if changed and not dry run *)
17231920 let save_result =
17241724- if not dry_run && updated_cf.entries <> changes_file.entries then
19211921+ if
19221922+ (not dry_run)
19231923+ && updated_cf.entries <> changes_file.entries
19241924+ then (
17251925 match Changes.save ~fs:fs_t ~monorepo updated_cf with
17261926 | Error e -> Error (Claude_error e)
17271927 | Ok () ->
17281728- Log.app (fun m -> m "Saved .changes/%s.json" repo_name);
17291729- Ok ()
19281928+ Log.app (fun m ->
19291929+ m "Saved .changes/%s.json" repo_name);
19301930+ Ok ())
17301931 else Ok ()
17311932 in
17321933 match save_result with
17331934 | Error e -> Error e
17341935 | Ok () ->
17351936 all_changes_files := updated_cf :: !all_changes_files;
17361736- process_repos rest
19371937+ process_repos rest)))
17371938 in
17381939 match process_repos repos with
17391940 | Error e -> Error e
17401941 | Ok () ->
17411942 (* Generate aggregated CHANGES.md *)
17421742- if not dry_run && !all_changes_files <> [] then begin
19431943+ if (not dry_run) && !all_changes_files <> [] then begin
17431944 let markdown = Changes.aggregate ~history !all_changes_files in
17441744- let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "CHANGES.md") in
17451745- Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
19451945+ let changes_md_path =
19461946+ Eio.Path.(fs_t / Fpath.to_string monorepo / "CHANGES.md")
19471947+ in
19481948+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path
19491949+ markdown;
17461950 Log.app (fun m -> m "Generated CHANGES.md at monorepo root")
17471951 end;
17481952 Ok ()
···1750195417511955(* Daily changes command - generate daily changelogs using Claude *)
1752195617531753-let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30) ?(dry_run = false) ?(aggregate = false) () =
19571957+let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30)
19581958+ ?(dry_run = false) ?(aggregate = false) () =
17541959 let fs_t = fs_typed fs in
17551960 let monorepo = Config.Paths.monorepo config in
1756196117571962 (* Get current time *)
17581963 let now = Eio.Time.now clock in
17591759- let now_ptime = match Ptime.of_float_s now with
17601760- | Some t -> t
17611761- | None -> Ptime.v (0, 0L) (* fallback to epoch *)
19641964+ let now_ptime =
19651965+ match Ptime.of_float_s now with Some t -> t | None -> Ptime.v (0, 0L)
19661966+ (* fallback to epoch *)
17621967 in
1763196817641969 match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
17651970 | Error e -> Error e
17661971 | Ok all_pkgs ->
17671972 let repos = unique_repos all_pkgs in
17681768- let repos = match package with
19731973+ let repos =
19741974+ match package with
17691975 | None -> repos
17701976 | Some name -> List.filter (fun p -> Package.repo_name p = name) repos
17711977 in
17721978 if repos = [] && package <> None then
17731979 Error (Package_not_found (Option.get package))
17741980 else begin
17751775- Log.info (fun m -> m "Processing daily changelogs for %d repositories" (List.length repos));
19811981+ Log.info (fun m ->
19821982+ m "Processing daily changelogs for %d repositories"
19831983+ (List.length repos));
1776198417771985 (* Process each repository *)
17781986 let all_changes_files = ref [] in
17791987 let rec process_repos = function
17801988 | [] -> Ok ()
17811781- | pkg :: rest ->
19891989+ | pkg :: rest -> (
17821990 let repo_name = Package.repo_name pkg in
1783199117841992 Log.info (fun m -> m "Processing %s" repo_name);
···17881996 if day_offset >= days then Ok ()
17891997 else begin
17901998 (* Calculate day boundaries *)
17911791- let offset_seconds = float_of_int (day_offset * 24 * 60 * 60) in
17921792- let day_time = match Ptime.of_float_s (now -. offset_seconds) with
19991999+ let offset_seconds =
20002000+ float_of_int (day_offset * 24 * 60 * 60)
20012001+ in
20022002+ let day_time =
20032003+ match Ptime.of_float_s (now -. offset_seconds) with
17932004 | Some t -> t
17942005 | None -> now_ptime
17952006 in
···18002011 (* For today, skip only if file has entries (may need to catch new commits) *)
18012012 let should_skip =
18022013 if is_today then
18031803- Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name &&
18041804- (match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
18051805- | Ok cf -> Changes.has_day cf ~date
18061806- | Error _ -> false)
18071807- else
18082014 Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name
20152015+ &&
20162016+ match
20172017+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
20182018+ with
20192019+ | Ok cf -> Changes.has_day cf ~date
20202020+ | Error _ -> false
20212021+ else Changes.daily_exists ~fs:fs_t ~monorepo ~date repo_name
18092022 in
18102023 if should_skip then begin
18111811- Log.info (fun m -> m " Day %s already processed, skipping" date);
18121812- (match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
18131813- | Ok cf -> all_changes_files := cf :: !all_changes_files
18141814- | Error _ -> ());
20242024+ Log.info (fun m ->
20252025+ m " Day %s already processed, skipping" date);
20262026+ (match
20272027+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
20282028+ with
20292029+ | Ok cf -> all_changes_files := cf :: !all_changes_files
20302030+ | Error _ -> ());
18152031 process_days (day_offset + 1)
18162032 end
18172033 else
18182034 (* Load existing daily changes from .changes/<repo>-<date>.json *)
18191819- match Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name with
20352035+ match
20362036+ Changes.load_daily ~fs:fs_t ~monorepo ~date repo_name
20372037+ with
18202038 | Error e -> Error (Claude_error e)
18211821- | Ok changes_file ->
20392039+ | Ok changes_file -> (
18222040 (* Get commits for this day *)
18232041 let since = date ^ " 00:00:00" in
18242042 let until = date ^ " 23:59:59" in
18251825- match Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name monorepo with
20432043+ match
20442044+ Git.log ~proc ~fs:fs_t ~since ~until ~path:repo_name
20452045+ monorepo
20462046+ with
18262047 | Error e -> Error (Git_error e)
18272048 | Ok commits ->
18282049 if commits = [] then begin
18291829- Log.info (fun m -> m " No commits for day %s" date);
20502050+ Log.info (fun m ->
20512051+ m " No commits for day %s" date);
18302052 process_days (day_offset + 1)
18312053 end
18322054 else begin
18331833- Log.info (fun m -> m " Found %d commits for day %s" (List.length commits) date);
20552055+ Log.info (fun m ->
20562056+ m " Found %d commits for day %s"
20572057+ (List.length commits) date);
1834205818352059 if dry_run then begin
18361836- Log.app (fun m -> m " [DRY RUN] Would analyze %d commits for %s on %s"
18371837- (List.length commits) repo_name date);
20602060+ Log.app (fun m ->
20612061+ m
20622062+ " [DRY RUN] Would analyze %d commits \
20632063+ for %s on %s"
20642064+ (List.length commits) repo_name date);
18382065 process_days (day_offset + 1)
18392066 end
18402067 else begin
18412068 (* Analyze commits with Claude *)
18422069 Eio.Switch.run @@ fun sw ->
18431843- match Changes.analyze_commits_daily ~sw ~process_mgr:proc ~clock
18441844- ~repository:repo_name ~date commits with
20702070+ match
20712071+ Changes.analyze_commits_daily ~sw
20722072+ ~process_mgr:proc ~clock
20732073+ ~repository:repo_name ~date commits
20742074+ with
18452075 | Error e -> Error (Claude_error e)
18462076 | Ok None ->
18471847- Log.info (fun m -> m " No user-facing changes for day %s" date);
20772077+ Log.info (fun m ->
20782078+ m " No user-facing changes for day %s"
20792079+ date);
18482080 process_days (day_offset + 1)
18491849- | Ok (Some response) ->
18501850- Log.app (fun m -> m " Generated changelog for %s on %s" repo_name date);
20812081+ | Ok (Some response) -> (
20822082+ Log.app (fun m ->
20832083+ m " Generated changelog for %s on %s"
20842084+ repo_name date);
18512085 (* Extract unique contributors from commits *)
18522086 let contributors =
18532087 commits
18541854- |> List.map (fun (c : Git.log_entry) -> c.author)
20882088+ |> List.map (fun (c : Git.log_entry) ->
20892089+ c.author)
18552090 |> List.sort_uniq String.compare
18562091 in
18572092 (* Get repo URL from package dev_repo *)
···18592094 let uri = Package.dev_repo pkg in
18602095 let url = Uri.to_string uri in
18612096 (* Strip git+ prefix if present for display *)
18621862- if String.starts_with ~prefix:"git+" url then
18631863- Some (String.sub url 4 (String.length url - 4))
18641864- else
18651865- Some url
20972097+ if String.starts_with ~prefix:"git+" url
20982098+ then
20992099+ Some
21002100+ (String.sub url 4
21012101+ (String.length url - 4))
21022102+ else Some url
18662103 in
18672104 (* Create new entry with hour and timestamp *)
18681868- let first_hash = (List.hd commits).Git.hash in
18691869- let last_hash = (List.hd (List.rev commits)).Git.hash in
18701870- let (_, ((hour, _, _), _)) = Ptime.to_date_time now_ptime in
18711871- let entry : Changes.daily_entry = {
18721872- date;
18731873- hour;
18741874- timestamp = now_ptime;
18751875- summary = response.Changes.summary;
18761876- changes = response.Changes.changes;
18771877- commit_range = {
18781878- from_hash = String.sub first_hash 0 (min 7 (String.length first_hash));
18791879- to_hash = String.sub last_hash 0 (min 7 (String.length last_hash));
18801880- count = List.length commits;
18811881- };
18821882- contributors;
18831883- repo_url;
18841884- } in
21052105+ let first_hash =
21062106+ (List.hd commits).Git.hash
21072107+ in
21082108+ let last_hash =
21092109+ (List.hd (List.rev commits)).Git.hash
21102110+ in
21112111+ let _, ((hour, _, _), _) =
21122112+ Ptime.to_date_time now_ptime
21132113+ in
21142114+ let entry : Changes.daily_entry =
21152115+ {
21162116+ date;
21172117+ hour;
21182118+ timestamp = now_ptime;
21192119+ summary = response.Changes.summary;
21202120+ changes = response.Changes.changes;
21212121+ commit_range =
21222122+ {
21232123+ from_hash =
21242124+ String.sub first_hash 0
21252125+ (min 7
21262126+ (String.length first_hash));
21272127+ to_hash =
21282128+ String.sub last_hash 0
21292129+ (min 7 (String.length last_hash));
21302130+ count = List.length commits;
21312131+ };
21322132+ contributors;
21332133+ repo_url;
21342134+ }
21352135+ in
18852136 (* Add entry (sorted by timestamp descending) *)
18862137 let new_entries =
18872138 entry :: changes_file.Changes.entries
18882139 |> List.sort (fun e1 e2 ->
18891889- Ptime.compare e2.Changes.timestamp e1.Changes.timestamp)
21402140+ Ptime.compare e2.Changes.timestamp
21412141+ e1.Changes.timestamp)
18902142 in
18911891- let updated_cf = { changes_file with Changes.entries = new_entries } in
21432143+ let updated_cf =
21442144+ {
21452145+ changes_file with
21462146+ Changes.entries = new_entries;
21472147+ }
21482148+ in
18922149 (* Save the per-day file *)
18931893- match Changes.save_daily ~fs:fs_t ~monorepo ~date updated_cf with
21502150+ match
21512151+ Changes.save_daily ~fs:fs_t ~monorepo
21522152+ ~date updated_cf
21532153+ with
18942154 | Error e -> Error (Claude_error e)
18952155 | Ok () ->
18961896- Log.app (fun m -> m "Saved .changes/%s-%s.json" repo_name date);
18971897- all_changes_files := updated_cf :: !all_changes_files;
18981898- process_days (day_offset + 1)
21562156+ Log.app (fun m ->
21572157+ m "Saved .changes/%s-%s.json"
21582158+ repo_name date);
21592159+ all_changes_files :=
21602160+ updated_cf :: !all_changes_files;
21612161+ process_days (day_offset + 1))
18992162 end
19001900- end
21632163+ end)
19012164 end
19022165 in
19032166 match process_days 0 with
19042167 | Error e -> Error e
19051905- | Ok () -> process_repos rest
21682168+ | Ok () -> process_repos rest)
19062169 in
19072170 match process_repos repos with
19082171 | Error e -> Error e
19092172 | Ok () ->
19102173 (* Generate aggregated DAILY-CHANGES.md *)
19111911- if not dry_run && !all_changes_files <> [] then begin
19121912- let raw_markdown = Changes.aggregate_daily ~history !all_changes_files in
21742174+ if (not dry_run) && !all_changes_files <> [] then begin
21752175+ let raw_markdown =
21762176+ Changes.aggregate_daily ~history !all_changes_files
21772177+ in
19132178 (* Refine the markdown through Claude for better narrative *)
19142179 Log.info (fun m -> m "Refining daily changelog with Claude...");
19151915- let markdown = Eio.Switch.run @@ fun sw ->
19161916- match Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock raw_markdown with
21802180+ let markdown =
21812181+ Eio.Switch.run @@ fun sw ->
21822182+ match
21832183+ Changes.refine_daily_changelog ~sw ~process_mgr:proc ~clock
21842184+ raw_markdown
21852185+ with
19172186 | Ok refined ->
19181918- Log.app (fun m -> m "Refined daily changelog for readability");
21872187+ Log.app (fun m ->
21882188+ m "Refined daily changelog for readability");
19192189 refined
19202190 | Error e ->
19211921- Log.warn (fun m -> m "Failed to refine changelog: %s (using raw version)" e);
21912191+ Log.warn (fun m ->
21922192+ m "Failed to refine changelog: %s (using raw version)" e);
19222193 raw_markdown
19232194 in
19241924- let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md") in
19251925- Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown;
21952195+ let changes_md_path =
21962196+ Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md")
21972197+ in
21982198+ Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path
21992199+ markdown;
19262200 Log.app (fun m -> m "Generated DAILY-CHANGES.md at monorepo root")
19272201 end;
19282202 (* Generate aggregated JSON file if requested *)
19291929- if not dry_run && aggregate then begin
22032203+ if (not dry_run) && aggregate then begin
19302204 let today = Changes.date_of_ptime now_ptime in
19312205 let git_head =
19322206 match Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD" monorepo with
19332207 | Ok hash -> String.sub hash 0 (min 7 (String.length hash))
19342208 | Error _ -> "unknown"
19352209 in
19361936- match Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today ~git_head ~now:now_ptime with
19371937- | Ok () -> Log.app (fun m -> m "Generated aggregated file .changes/%s.json"
19381938- (String.concat "" (String.split_on_char '-' today)))
19391939- | Error e -> Log.warn (fun m -> m "Failed to generate aggregated file: %s" e)
22102210+ match
22112211+ Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today
22122212+ ~git_head ~now:now_ptime
22132213+ with
22142214+ | Ok () ->
22152215+ Log.app (fun m ->
22162216+ m "Generated aggregated file .changes/%s.json"
22172217+ (String.concat "" (String.split_on_char '-' today)))
22182218+ | Error e ->
22192219+ Log.warn (fun m ->
22202220+ m "Failed to generate aggregated file: %s" e)
19402221 end;
19412222 Ok ()
19422223 end
+39-38
lib/monopam.mli
···5252(** [pp_error] formats errors. *)
53535454val pp_error_with_hint : error Fmt.t
5555-(** [pp_error_with_hint] formats errors with a helpful hint for resolving them. *)
5555+(** [pp_error_with_hint] formats errors with a helpful hint for resolving them.
5656+*)
56575758val error_hint : error -> string option
5859(** [error_hint e] returns a hint string for the given error, if available. *)
···8283 ?opam_repo_url:string ->
8384 unit ->
8485 (unit, error) result
8585-(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from remotes.
8686+(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from
8787+ remotes.
86888789 For each package (or the specified package): 1. Clones or fetches the
8890 individual checkout 2. Adds or pulls the subtree in the monorepo
···9698 @param fs Eio filesystem
9799 @param config Monopam configuration
98100 @param package Optional specific package to pull
9999- @param opam_repo_url Optional URL to clone opam-repo from if it doesn't exist *)
101101+ @param opam_repo_url
102102+ Optional URL to clone opam-repo from if it doesn't exist *)
100103101104(** {2 Push} *)
102105···128131129132(** {2 Sync} *)
130133131131-(** Phase where a sync failure occurred. *)
132134type sync_phase = [ `Push_checkout | `Fetch | `Merge | `Subtree | `Push_remote ]
135135+(** Phase where a sync failure occurred. *)
133136134134-(** A failure during sync for a specific repository. *)
135137type sync_failure = {
136138 repo_name : string;
137139 phase : sync_phase;
138140 error : Git.error;
139141}
142142+(** A failure during sync for a specific repository. *)
140143141141-(** Summary of a sync operation. *)
142144type sync_summary = {
143145 repos_synced : int;
144146 repos_unchanged : int;
···146148 commits_pushed : int;
147149 errors : sync_failure list;
148150}
151151+(** Summary of a sync operation. *)
149152150153val pp_sync_phase : sync_phase Fmt.t
151154(** [pp_sync_phase] formats a sync phase. *)
···169172(** [sync ~proc ~fs ~config ?package ?remote ?skip_push ?skip_pull ()]
170173 synchronizes the monorepo with upstream repositories.
171174172172- This is the primary command for all sync operations. It performs both
173173- push and pull operations in the correct order:
174174- 1. Validate: check for dirty state (abort if dirty)
175175- 2. Push phase: export monorepo changes to checkouts (parallel)
176176- 3. Fetch phase: clone/fetch from remotes (parallel)
177177- 4. Merge phase: fast-forward merge checkouts (sequential)
178178- 5. Subtree phase: pull subtrees into monorepo (sequential)
179179- 6. Finalize: write README.md and dune-project (sequential)
180180- 7. Remote phase: push to upstream remotes if [~remote:true] (parallel)
175175+ This is the primary command for all sync operations. It performs both push
176176+ and pull operations in the correct order: 1. Validate: check for dirty state
177177+ (abort if dirty) 2. Push phase: export monorepo changes to checkouts
178178+ (parallel) 3. Fetch phase: clone/fetch from remotes (parallel) 4. Merge
179179+ phase: fast-forward merge checkouts (sequential) 5. Subtree phase: pull
180180+ subtrees into monorepo (sequential) 6. Finalize: write README.md and
181181+ dune-project (sequential) 7. Remote phase: push to upstream remotes if
182182+ [~remote:true] (parallel)
181183182184 The fetch and remote push phases run concurrently for improved performance.
183185···191193192194(** {2 Opam Metadata Sync} *)
193195194194-(** Result of syncing opam files from monorepo to opam-repo. *)
195196type opam_sync_result = {
196197 synced : string list; (** Packages that were updated *)
197198 unchanged : string list; (** Packages that were already in sync *)
198199 missing : string list; (** Packages where monorepo has no .opam file *)
199199- orphaned : string list; (** Packages in opam-repo but subtree missing from monorepo *)
200200+ orphaned : string list;
201201+ (** Packages in opam-repo but subtree missing from monorepo *)
200202}
203203+(** Result of syncing opam files from monorepo to opam-repo. *)
201204202205val pp_opam_sync_result : opam_sync_result Fmt.t
203206(** [pp_opam_sync_result] formats an opam sync result. *)
···212215(** [sync_opam_files ~proc ~fs ~config ?package ()] synchronizes .opam files
213216 from monorepo subtrees to the opam-repo overlay.
214217215215- For each package (or the specified package):
216216- 1. Checks if the subtree exists in the monorepo
217217- 2. If subtree missing, reports as orphaned (needs manual removal)
218218- 3. Reads the .opam file from the monorepo subtree
219219- 4. Compares with the opam-repo version
220220- 5. If different, copies monorepo → opam-repo (local always wins)
221221- 6. Stages and commits changes in opam-repo
218218+ For each package (or the specified package): 1. Checks if the subtree exists
219219+ in the monorepo 2. If subtree missing, reports as orphaned (needs manual
220220+ removal) 3. Reads the .opam file from the monorepo subtree 4. Compares with
221221+ the opam-repo version 5. If different, copies monorepo → opam-repo (local
222222+ always wins) 6. Stages and commits changes in opam-repo
222223223224 Orphaned packages (in opam-repo but subtree missing from monorepo) are
224225 reported with a warning suggesting manual removal.
···318319(** [changes ~proc ~fs ~config ~clock ?package ?weeks ?history ?dry_run ()]
319320 generates weekly changelog entries using Claude AI.
320321321321- For each repository (or the specified package's repository):
322322- 1. Loads or creates .changes/<repo>.json
323323- 2. For each week that doesn't have an entry, retrieves git commits
324324- 3. Sends commits to Claude for analysis
325325- 4. Saves changelog entries back to .changes/<repo>.json
322322+ For each repository (or the specified package's repository): 1. Loads or
323323+ creates .changes/<repo>.json 2. For each week that doesn't have an entry,
324324+ retrieves git commits 3. Sends commits to Claude for analysis 4. Saves
325325+ changelog entries back to .changes/<repo>.json
326326327327 Also generates an aggregated CHANGES.md at the monorepo root.
328328···347347 ?aggregate:bool ->
348348 unit ->
349349 (unit, error) result
350350-(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run ?aggregate ()]
351351- generates daily changelog entries using Claude AI.
350350+(** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run
351351+ ?aggregate ()] generates daily changelog entries using Claude AI.
352352353353- For each repository (or the specified package's repository):
354354- 1. Loads or creates .changes/<repo>-daily.json
355355- 2. For each day that doesn't have an entry, retrieves git commits
356356- 3. Sends commits to Claude for analysis
357357- 4. Saves changelog entries back to .changes/<repo>-daily.json
353353+ For each repository (or the specified package's repository): 1. Loads or
354354+ creates .changes/<repo>-daily.json 2. For each day that doesn't have an
355355+ entry, retrieves git commits 3. Sends commits to Claude for analysis 4.
356356+ Saves changelog entries back to .changes/<repo>-daily.json
358357359358 Also generates an aggregated DAILY-CHANGES.md at the monorepo root.
360359 Repositories with no user-facing changes will have blank entries.
···368367 @param clock Eio clock for time operations
369368 @param package Optional specific repository to process
370369 @param days Number of past days to analyze (default: 1)
371371- @param history Number of recent days to include in DAILY-CHANGES.md (default: 30)
370370+ @param history
371371+ Number of recent days to include in DAILY-CHANGES.md (default: 30)
372372 @param dry_run If true, preview changes without writing files
373373- @param aggregate If true, also generate .changes/YYYYMMDD.json aggregated file *)
373373+ @param aggregate
374374+ 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 _ -> [])
+2-2
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
+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
+98-66
lib/verse.ml
···25252626let error_hint = function
2727 | Config_error _ ->
2828- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace."
2828+ Some
2929+ "Run 'monopam verse init --handle <your-handle>' to create a workspace."
2930 | Git_error (Git.Dirty_worktree _) ->
3031 Some "Commit or stash your changes first: git status"
3131- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git clone" cmd ->
3232+ | Git_error (Git.Command_failed (cmd, _))
3333+ when String.starts_with ~prefix:"git clone" cmd ->
3234 Some "Check the URL is correct and you have network access."
3333- | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git pull" cmd ->
3535+ | Git_error (Git.Command_failed (cmd, _))
3636+ when String.starts_with ~prefix:"git pull" cmd ->
3437 Some "Check your network connection. Try: git fetch origin"
3538 | Git_error _ -> None
3639 | Registry_error _ ->
3740 Some "The registry may be temporarily unavailable. Try again later."
3841 | Member_not_found h ->
3939- Some (Fmt.str "Check available members: monopam verse members (looking for '%s')" h)
4242+ Some
4343+ (Fmt.str
4444+ "Check available members: monopam verse members (looking for '%s')" h)
4045 | Workspace_exists _ ->
4146 Some "Use a different directory, or remove the existing workspace."
4247 | Not_a_workspace _ ->
···84898590let pp_status ppf s =
8691 Fmt.pf ppf "@[<v>Workspace: %a@,Registry: %s@,Members:@, @[<v>%a@]@]"
8787- Fpath.pp (Verse_config.root s.config)
9292+ Fpath.pp
9393+ (Verse_config.root s.config)
8894 s.registry.name
8995 Fmt.(list ~sep:cut pp_member_status)
9096 s.tracked_members
···118124 let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in
119125 try
120126 Eio.Path.read_dir eio_path
121121- |> List.filter (fun name ->
122122- is_directory ~fs Fpath.(verse_path / name))
127127+ |> List.filter (fun name -> is_directory ~fs Fpath.(verse_path / name))
123128 with Eio.Io _ -> []
124129125130let init ~proc ~fs ~root ~handle () =
···141146 (* Ensure the directory exists first so realpath works *)
142147 (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ());
143148 match Unix.realpath root_str with
144144- | abs_str -> (match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
149149+ | abs_str -> (
150150+ match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
145151 | exception _ -> root
146152 in
147153 Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root);
···153159 | Error msg ->
154160 Logs.err (fun m -> m "Registry clone failed: %s" msg);
155161 Error (Registry_error msg)
156156- | Ok registry ->
162162+ | Ok registry -> (
157163 Logs.info (fun m -> m "Registry loaded");
158164 (* Look up user in registry - this validates the handle *)
159165 match Verse_registry.find_member registry ~handle with
160166 | None ->
161167 Logs.err (fun m -> m "Handle %s not found in registry" handle);
162168 Error (Member_not_found handle)
163163- | Some member ->
164164- Logs.info (fun m -> m "Found member: mono=%s opam=%s" member.monorepo member.opamrepo);
165165- (* Create workspace directories *)
166166- Logs.info (fun m -> m "Creating workspace directories...");
167167- ensure_dir ~fs root;
168168- ensure_dir ~fs (Verse_config.src_path config);
169169- ensure_dir ~fs (Verse_config.verse_path config);
170170- (* Clone user's monorepo *)
171171- let mono_path = Verse_config.mono_path config in
172172- Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
173173- let mono_url = Uri.of_string member.monorepo in
174174- (match Git.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch mono_path with
169169+ | Some member -> (
170170+ Logs.info (fun m ->
171171+ m "Found member: mono=%s opam=%s" member.monorepo
172172+ member.opamrepo);
173173+ (* Create workspace directories *)
174174+ Logs.info (fun m -> m "Creating workspace directories...");
175175+ ensure_dir ~fs root;
176176+ ensure_dir ~fs (Verse_config.src_path config);
177177+ ensure_dir ~fs (Verse_config.verse_path config);
178178+ (* Clone user's monorepo *)
179179+ let mono_path = Verse_config.mono_path config in
180180+ Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
181181+ let mono_url = Uri.of_string member.monorepo in
182182+ match
183183+ Git.clone ~proc ~fs ~url:mono_url
184184+ ~branch:Verse_config.default_branch mono_path
185185+ with
186186+ | Error e ->
187187+ Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
188188+ Error (Git_error e)
189189+ | Ok () -> (
190190+ Logs.info (fun m -> m "Monorepo cloned");
191191+ (* Clone user's opam repo *)
192192+ let opam_path = Verse_config.opam_repo_path config in
193193+ Logs.info (fun m ->
194194+ m "Cloning opam repo to %a" Fpath.pp opam_path);
195195+ let opam_url = Uri.of_string member.opamrepo in
196196+ match
197197+ Git.clone ~proc ~fs ~url:opam_url
198198+ ~branch:Verse_config.default_branch opam_path
199199+ with
175200 | Error e ->
176176- Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
201201+ Logs.err (fun m ->
202202+ m "Opam repo clone failed: %a" Git.pp_error e);
177203 Error (Git_error e)
178178- | Ok () ->
179179- Logs.info (fun m -> m "Monorepo cloned");
180180- (* Clone user's opam repo *)
181181- let opam_path = Verse_config.opam_repo_path config in
182182- Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path);
183183- let opam_url = Uri.of_string member.opamrepo in
184184- (match Git.clone ~proc ~fs ~url:opam_url ~branch:Verse_config.default_branch opam_path with
185185- | Error e ->
186186- Logs.err (fun m -> m "Opam repo clone failed: %a" Git.pp_error e);
187187- Error (Git_error e)
204204+ | Ok () -> (
205205+ Logs.info (fun m -> m "Opam repo cloned");
206206+ (* Save config to XDG *)
207207+ Logs.info (fun m ->
208208+ m "Saving config to %a" Fpath.pp config_file);
209209+ match Verse_config.save ~fs config with
210210+ | Error msg ->
211211+ Logs.err (fun m -> m "Failed to save config: %s" msg);
212212+ Error (Config_error msg)
188213 | Ok () ->
189189- Logs.info (fun m -> m "Opam repo cloned");
190190- (* Save config to XDG *)
191191- Logs.info (fun m -> m "Saving config to %a" Fpath.pp config_file);
192192- (match Verse_config.save ~fs config with
193193- | Error msg ->
194194- Logs.err (fun m -> m "Failed to save config: %s" msg);
195195- Error (Config_error msg)
196196- | Ok () ->
197197- Logs.info (fun m -> m "Workspace initialized successfully");
198198- Ok ())))
214214+ Logs.info (fun m ->
215215+ m "Workspace initialized successfully");
216216+ Ok ()))))
199217200218let status ~proc ~fs ~config () =
201219 (* Load registry *)
···212230 match Verse_registry.find_member registry ~handle with
213231 | None ->
214232 (* Member not in registry but locally tracked - show anyway *)
215215- let local_path = Fpath.(Verse_config.verse_path config / handle) in
233233+ let local_path =
234234+ Fpath.(Verse_config.verse_path config / handle)
235235+ in
216236 let cloned = is_directory ~fs local_path in
217237 Some
218238 {
···257277 | Error msg -> Error (Registry_error msg)
258278 | Ok registry -> Ok registry.members
259279260260-261280(** Clone or fetch+reset a single git repo. Returns Ok true if cloned, Ok false if reset.
262281 Uses fetch+reset instead of pull since verse repos should not have local changes. *)
263282let clone_or_reset_repo ~proc ~fs ~url ~branch path =
···278297 match Verse_registry.clone_or_pull ~proc ~fs ~config () with
279298 | Error msg -> Error (Registry_error msg)
280299 | Ok registry ->
281281- let members = match handle with
282282- | Some h ->
283283- (match Verse_registry.find_member registry ~handle:h with
284284- | Some m -> [m]
300300+ let members =
301301+ match handle with
302302+ | Some h -> (
303303+ match Verse_registry.find_member registry ~handle:h with
304304+ | Some m -> [ m ]
285305 | None -> [])
286306 | None -> registry.members
287307 in
···306326 clone_or_reset_repo ~proc ~fs ~url:member.monorepo
307327 ~branch:mono_branch mono_path
308328 in
309309- let mono_err = match mono_result with
310310- | Ok true -> Logs.info (fun m -> m " Cloned %s monorepo" h); None
311311- | Ok false -> Logs.info (fun m -> m " Reset %s monorepo" h); None
329329+ let mono_err =
330330+ match mono_result with
331331+ | Ok true ->
332332+ Logs.info (fun m -> m " Cloned %s monorepo" h);
333333+ None
334334+ | Ok false ->
335335+ Logs.info (fun m -> m " Reset %s monorepo" h);
336336+ None
312337 | Error e ->
313313- Logs.warn (fun m -> m " Failed %s monorepo: %a" h Git.pp_error e);
338338+ Logs.warn (fun m ->
339339+ m " Failed %s monorepo: %a" h Git.pp_error e);
314340 Some (Fmt.str "%s monorepo: %a" h Git.pp_error e)
315341 in
316342 (* Clone or fetch+reset opam repo *)
···322348 clone_or_reset_repo ~proc ~fs ~url:member.opamrepo
323349 ~branch:opam_branch opam_path
324350 in
325325- let opam_err = match opam_result with
326326- | Ok true -> Logs.info (fun m -> m " Cloned %s opam repo" h); None
327327- | Ok false -> Logs.info (fun m -> m " Reset %s opam repo" h); None
351351+ let opam_err =
352352+ match opam_result with
353353+ | Ok true ->
354354+ Logs.info (fun m -> m " Cloned %s opam repo" h);
355355+ None
356356+ | Ok false ->
357357+ Logs.info (fun m -> m " Reset %s opam repo" h);
358358+ None
328359 | Error e ->
329329- Logs.warn (fun m -> m " Failed %s opam repo: %a" h Git.pp_error e);
360360+ Logs.warn (fun m ->
361361+ m " Failed %s opam repo: %a" h Git.pp_error e);
330362 Some (Fmt.str "%s opam: %a" h Git.pp_error e)
331363 in
332364 match (mono_err, opam_err) with
···343375 (* pull already updates registry and syncs all members *)
344376 pull ~proc ~fs ~config ()
345377346346-(** Scan a monorepo for subtree directories.
347347- Returns a list of directory names that look like subtrees (have commits). *)
378378+(** Scan a monorepo for subtree directories. Returns a list of directory names
379379+ that look like subtrees (have commits). *)
348380let scan_subtrees ~proc ~fs monorepo_path =
349381 if not (Git.is_repo ~proc ~fs monorepo_path) then []
350382 else
···352384 try
353385 Eio.Path.read_dir eio_path
354386 |> List.filter (fun name ->
355355- (* Skip hidden dirs and common non-subtree dirs *)
356356- not (String.starts_with ~prefix:"." name)
357357- && name <> "_build"
358358- && name <> "node_modules"
359359- && is_directory ~fs Fpath.(monorepo_path / name))
387387+ (* Skip hidden dirs and common non-subtree dirs *)
388388+ (not (String.starts_with ~prefix:"." name))
389389+ && name <> "_build" && name <> "node_modules"
390390+ && is_directory ~fs Fpath.(monorepo_path / name))
360391 with Eio.Io _ -> []
361392362362-(** Get subtrees from all tracked verse members.
363363- Returns a map from subtree name to list of (handle, monorepo_path) pairs. *)
393393+(** Get subtrees from all tracked verse members. Returns a map from subtree name
394394+ to list of (handle, monorepo_path) pairs. *)
364395let get_verse_subtrees ~proc ~fs ~config () =
365396 let verse_path = Verse_config.verse_path config in
366397 let tracked_handles = get_tracked_handles ~fs config in
···376407 let existing =
377408 try Hashtbl.find subtree_map subtree with Not_found -> []
378409 in
379379- Hashtbl.replace subtree_map subtree ((handle, member_mono) :: existing))
410410+ Hashtbl.replace subtree_map subtree
411411+ ((handle, member_mono) :: existing))
380412 subtrees
381413 end)
382414 tracked_handles;
+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···2020(** [pp_error] formats errors. *)
21212222val pp_error_with_hint : error Fmt.t
2323-(** [pp_error_with_hint] formats errors with a helpful hint for resolving them. *)
2323+(** [pp_error_with_hint] formats errors with a helpful hint for resolving them.
2424+*)
24252526val error_hint : error -> string option
2627(** [error_hint e] returns a hint string for the given error, if available. *)
···3334 local_path : Fpath.t; (** Local path under verse/ *)
3435 cloned : bool; (** Whether the monorepo is cloned locally *)
3536 clean : bool option; (** Whether the clone is clean (None if not cloned) *)
3636- ahead_behind : Git.ahead_behind option; (** Ahead/behind status (None if not cloned) *)
3737+ ahead_behind : Git.ahead_behind option;
3838+ (** Ahead/behind status (None if not cloned) *)
3739}
3840(** Status of a member's monorepo in the workspace. *)
3941···104106 (unit, error) result
105107(** [pull ~proc ~fs ~config ?handle ()] syncs all registry members.
106108107107- For each member in the registry, clones or pulls both their monorepo
108108- (to [verse/<handle>/]) and their opam repo (to [verse/<handle>-opam/]).
109109+ For each member in the registry, clones or pulls both their monorepo (to
110110+ [verse/<handle>/]) and their opam repo (to [verse/<handle>-opam/]).
109111110112 If [handle] is specified, only syncs that specific member.
111113···140142 unit ->
141143 (string, (string * Fpath.t) list) Hashtbl.t
142144(** [get_verse_subtrees ~proc ~fs ~config ()] scans all tracked verse members
143143- and returns a map from subtree name to list of (handle, monorepo_path) pairs.
145145+ and returns a map from subtree name to list of (handle, monorepo_path)
146146+ pairs.
144147145148 This allows finding which verse users have a particular repo. *)
146149
+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
···3030 Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle mono_str opam_str
31313232let pp ppf t =
3333- Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]"
3434- t.name Fmt.(list ~sep:cut pp_member) t.members
3333+ Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]" t.name
3434+ Fmt.(list ~sep:cut pp_member)
3535+ t.members
35363637(* TOML structure:
3738 [registry]
···7172 { name = registry.r_name; members = Option.value ~default:[] members })
7273 |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name })
7374 |> opt_mem "members" (list member_codec) ~enc:(fun t ->
7474- match t.members with [] -> None | ms -> Some ms)
7575+ match t.members with [] -> None | ms -> Some ms)
7576 |> finish))
76777778let empty_registry = { name = "opamverse"; members = [] }
···8182 Logs.info (fun m -> m "Loading registry from path: %s" path_str);
8283 try
8384 let registry = Tomlt_eio.decode_path_exn codec ~fs path_str in
8484- Logs.info (fun m -> m "Registry loaded: %d members" (List.length registry.members));
8585+ Logs.info (fun m ->
8686+ m "Registry loaded: %d members" (List.length registry.members));
8587 Ok registry
8688 with
8789 | Eio.Io _ as e ->
···9193 Logs.err (fun m -> m "Registry parse error: %s" msg);
9294 Error (Fmt.str "Invalid registry: %s" msg)
9395 | exn ->
9494- Logs.err (fun m -> m "Unexpected registry error: %s" (Printexc.to_string exn));
9696+ Logs.err (fun m ->
9797+ m "Unexpected registry error: %s" (Printexc.to_string exn));
9598 Error (Fmt.str "Registry error: %s" (Printexc.to_string exn))
969997100let save ~fs path registry =
···117120 Logs.info (fun m -> m "Registry exists, pulling updates...");
118121 (* Pull updates, but don't fail if pull fails *)
119122 (match Git.pull ~proc ~fs registry_path with
120120- | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
121121- | Error e -> Logs.warn (fun m -> m "Registry pull failed: %a (using cached)" Git.pp_error e));
123123+ | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
124124+ | Error e ->
125125+ Logs.warn (fun m ->
126126+ m "Registry pull failed: %a (using cached)" Git.pp_error e));
122127 Logs.info (fun m -> m "Loading registry from %a" Fpath.pp registry_toml);
123128 load ~fs registry_toml
124129 end
···143148 (try Eio.Path.mkdirs ~perm:0o755 registry_eio with Eio.Io _ -> ());
144149 (* Initialize as git repo *)
145150 (match Git.init ~proc ~fs registry_path with
146146- | Ok () -> ()
147147- | Error _ -> ());
151151+ | Ok () -> ()
152152+ | Error _ -> ());
148153 (* Create empty registry file *)
149154 (match save ~fs registry_toml empty_registry with
150150- | Ok () -> ()
151151- | Error _ -> ());
155155+ | Ok () -> ()
156156+ | Error _ -> ());
152157 Ok empty_registry
153158 end
154159155155-let find_member t ~handle =
156156- List.find_opt (fun m -> m.handle = handle) t.members
160160+let find_member t ~handle = List.find_opt (fun m -> m.handle = handle) t.members
157161158162let find_members t ~handles =
159163 List.filter (fun m -> List.mem m.handle handles) t.members
+2-2
lib/verse_registry.mli
···3434 config:Verse_config.t ->
3535 unit ->
3636 (t, string) result
3737-(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present,
3838- or pulls updates if it exists. Returns the parsed registry contents.
3737+(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present, or
3838+ pulls updates if it exists. Returns the parsed registry contents.
39394040 The registry is cloned to [config.registry_path].
4141