forked from
anil.recoil.org/monopam
Monorepo management for opam overlays
1open Cmdliner
2
3let setup_logging style_renderer level verbose_http =
4 Fmt_tty.setup_std_outputs ?style_renderer ();
5 Logs.set_reporter (Logs_fmt.reporter ());
6 (* Set global log level for monopam's own logs *)
7 Logs.set_level level;
8 (* Use Requests.Cmd.setup_log_sources to configure HTTP logging separately.
9 This allows -v to show app logs without HTTP protocol details,
10 while --verbose-http enables full HTTP tracing. *)
11 Requests.Cmd.setup_log_sources ~verbose_http level
12
13let logging_term =
14 let verbose_http_term =
15 Term.(
16 const (fun ws -> ws.Requests.Cmd.value)
17 $ Requests.Cmd.verbose_http_term "monopam")
18 in
19 Term.(
20 const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ()
21 $ verbose_http_term)
22
23let package_arg =
24 let doc = "Package name. If not specified, operates on all packages." in
25 Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
26
27(* Load config from opamverse.toml *)
28let load_config env =
29 let fs = Eio.Stdenv.fs env in
30 Monopam.Config.load ~fs ()
31
32let with_config env f =
33 match load_config env with
34 | Ok config -> f config
35 | Error msg ->
36 Fmt.epr "Error loading config: %s@." msg;
37 Fmt.epr "Run 'monopam init' first to create a workspace.@.";
38 `Error (false, "configuration error")
39
40(* Status command *)
41
42let status_cmd =
43 let doc = "Show synchronization status of all packages" in
44 let man =
45 [
46 `S Manpage.s_description;
47 `P
48 "Displays package status showing both local sync state (monorepo vs \
49 checkout) and remote sync state (checkout vs upstream).";
50 `S "STATUS COLUMNS";
51 `P "Each repository shows two sync indicators:";
52 `I ("local:", "Sync between your monorepo (mono/) and checkout (src/)");
53 `I ("remote:", "Sync between your checkout (src/) and upstream git remote");
54 `S "LOCAL SYNC INDICATORS";
55 `I ("local:=", "Monorepo and checkout are in sync");
56 `I
57 ( "local:+N",
58 "Monorepo has N commits not yet in checkout (run $(b,monopam sync))"
59 );
60 `I
61 ( "local:-N",
62 "Checkout has N commits not yet in monorepo (run $(b,monopam sync))"
63 );
64 `I ("local:sync", "Trees differ, needs sync (run $(b,monopam sync))");
65 `S "REMOTE SYNC INDICATORS";
66 `I ("remote:=", "Checkout and upstream remote are in sync");
67 `I
68 ( "remote:+N",
69 "Checkout has N commits to push (run $(b,monopam sync --remote))" );
70 `I ("remote:-N", "Upstream has N commits to pull (run $(b,monopam sync))");
71 `I ("remote:+N/-M", "Diverged: checkout +N ahead, upstream +M ahead");
72 `S "FORK ANALYSIS";
73 `P "If tracking other members via verse, shows fork comparison:";
74 `I ("+N", "They have N commits you don't have");
75 `I ("-N", "You have N commits they don't have");
76 `I ("=", "Same commit or same URL");
77 `I ("~", "Not in your workspace (use --all to list)");
78 `S "NEXT STEPS";
79 `P "Based on the status output:";
80 `I ("local:+N or local:-N", "Run $(b,monopam sync) to synchronize");
81 `I ("remote:-N", "Run $(b,monopam sync) to pull upstream changes");
82 `I ("remote:+N", "Run $(b,monopam sync --remote) to push to upstream");
83 ]
84 in
85 let info = Cmd.info "status" ~doc ~man in
86 let all_arg =
87 let doc = "Show all repos including those not in your workspace." in
88 Arg.(value & flag & info [ "all"; "a" ] ~doc)
89 in
90 let run show_all () =
91 Eio_main.run @@ fun env ->
92 with_config env @@ fun config ->
93 let fs = Eio.Stdenv.fs env in
94 let proc = Eio.Stdenv.process_mgr env in
95 match Monopam.status ~proc ~fs ~config () with
96 | Ok statuses ->
97 (* Load sources.toml for origin indicators *)
98 let sources =
99 let mono_path = Monopam.Config.Paths.monorepo config in
100 let sources_path = Fpath.(mono_path / "sources.toml") in
101 match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
102 | Ok s -> Some s
103 | Error _ -> None
104 in
105 Fmt.pr "%a" (Monopam.Status.pp_summary ?sources) statuses;
106 (* Check for unregistered opam files *)
107 (match Monopam.discover_packages ~fs ~config () with
108 | Ok pkgs ->
109 let unregistered =
110 Monopam.find_unregistered_opam_files ~fs ~config pkgs
111 in
112 if unregistered <> [] then begin
113 (* Get local handle abbreviation *)
114 let handle_abbrev =
115 match Monopam.Verse_config.load ~fs () with
116 | Ok vc -> (
117 let h = Monopam.Verse_config.handle vc in
118 match String.split_on_char '.' h with
119 | first :: _ ->
120 if String.length first <= 4 then first
121 else String.sub first 0 3
122 | [] -> h)
123 | Error _ -> "local"
124 in
125 Fmt.pr "%a %a\n"
126 Fmt.(styled `Bold string)
127 "Unregistered:"
128 Fmt.(styled `Faint int)
129 (List.length unregistered);
130 List.iter
131 (fun (_r, p) ->
132 Fmt.pr " %-22s %a\n" p
133 Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
134 handle_abbrev)
135 unregistered
136 end
137 | Error _ -> ());
138 (* Fork analysis *)
139 (match Monopam.Verse_config.load ~fs () with
140 | Error _ -> ()
141 | Ok verse_config ->
142 let forks =
143 Monopam.Forks.compute ~proc ~fs ~verse_config
144 ~monopam_config:config ()
145 in
146 if forks.repos <> [] then
147 Fmt.pr "%a" (Monopam.Forks.pp_summary' ~show_all) forks);
148 `Ok ()
149 | Error e ->
150 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
151 `Error (false, "status failed")
152 in
153 Cmd.v info Term.(ret (const run $ all_arg $ logging_term))
154
155(* Sync command *)
156
157let sync_cmd =
158 let doc = "Synchronize monorepo with upstream repositories" in
159 let man =
160 [
161 `S Manpage.s_description;
162 `P
163 "$(b,This is the primary command for all workflows.) It performs both \
164 push and pull operations in the correct order to fully synchronize \
165 your monorepo with upstream repositories.";
166 `S "COMMON USAGE";
167 `I ("monopam sync", "Full sync: push local changes + pull remote changes");
168 `I ("monopam sync --remote", "Full sync + push to upstream git remotes");
169 `I ("monopam sync eio", "Sync only the eio repository");
170 `I ("monopam sync --skip-push", "Pull only: skip exporting local changes");
171 `I ("monopam sync --skip-pull", "Push only: skip fetching remote changes");
172 `S Manpage.s_examples;
173 `P "After making changes:";
174 `Pre
175 "cd mono\n\
176 # ... edit files ...\n\
177 git add -A && git commit -m \"Add feature\"\n\
178 monopam sync --remote # sync and push upstream";
179 `P "Pull latest from all upstreams (no local changes to export):";
180 `Pre "monopam sync --skip-push";
181 `P "Export local changes for review without pulling:";
182 `Pre "monopam sync --skip-pull";
183 `S "PHASES";
184 `P "The sync command executes these phases in order:";
185 `I ("1. Validate", "Abort if the monorepo has uncommitted changes");
186 `I
187 ( "2. Push",
188 "Export monorepo changes to checkouts (parallel) [--skip-push skips]"
189 );
190 `I ("3. Fetch", "Clone/fetch from remotes (parallel) [--skip-pull skips]");
191 `I ("4. Merge", "Fast-forward merge checkouts [--skip-pull skips]");
192 `I ("5. Subtree", "Pull subtrees into monorepo [--skip-pull skips]");
193 `I ("6. Finalize", "Update README.md, CLAUDE.md, and dune-project");
194 `I ("7. Remote", "Push to upstream remotes if --remote (parallel)");
195 `S "SKIP OPTIONS";
196 `I
197 ( "--skip-push",
198 "Skip exporting monorepo changes to checkouts. Use when you know you \
199 have no local changes to export." );
200 `I
201 ( "--skip-pull",
202 "Skip fetching and pulling from remotes. Use when you only want to \
203 export local changes without pulling remote updates." );
204 `S "PREREQUISITES";
205 `P "Before running sync:";
206 `I
207 ( "-",
208 "Commit all changes in the monorepo: $(b,git add -A && git commit)" );
209 `I ("-", "For --remote: ensure git credentials/SSH keys are configured");
210 ]
211 in
212 let info = Cmd.info "sync" ~doc ~man in
213 let remote_arg =
214 let doc =
215 "Also push each checkout to its upstream git remote after syncing."
216 in
217 Arg.(value & flag & info [ "remote" ] ~doc)
218 in
219 let skip_push_arg =
220 let doc = "Skip exporting monorepo changes to checkouts." in
221 Arg.(value & flag & info [ "skip-push" ] ~doc)
222 in
223 let skip_pull_arg =
224 let doc = "Skip fetching and pulling from remotes." in
225 Arg.(value & flag & info [ "skip-pull" ] ~doc)
226 in
227 let run package remote skip_push skip_pull () =
228 Eio_main.run @@ fun env ->
229 with_config env @@ fun config ->
230 let fs = Eio.Stdenv.fs env in
231 let proc = Eio.Stdenv.process_mgr env in
232 match
233 Monopam.sync ~proc ~fs ~config ?package ~remote ~skip_push ~skip_pull ()
234 with
235 | Ok summary ->
236 if summary.errors = [] then `Ok ()
237 else begin
238 Fmt.epr "Sync completed with %d errors.@."
239 (List.length summary.errors);
240 `Ok ()
241 end
242 | Error e ->
243 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
244 `Error (false, "sync failed")
245 in
246 Cmd.v info
247 Term.(
248 ret
249 (const run $ package_arg $ remote_arg $ skip_push_arg $ skip_pull_arg
250 $ logging_term))
251
252(* Changes command *)
253
254let changes_cmd =
255 let doc = "Generate changelog entries using Claude AI" in
256 let man =
257 [
258 `S Manpage.s_description;
259 `P "Analyzes git commit history and generates user-facing changelogs.";
260 `P
261 "By default, generates weekly entries. Use --daily to generate daily \
262 entries instead.";
263 `P "Changes are stored in the .changes directory at the monorepo root:";
264 `I (".changes/<repo>.json", "Weekly changelog entries");
265 `I (".changes/<repo>-daily.json", "Daily changelog entries");
266 `I
267 ( ".changes/YYYYMMDD.json",
268 "Aggregated daily entries (default with --daily)" );
269 `P "Also generates aggregated markdown files at the monorepo root:";
270 `I ("CHANGES.md", "Aggregated weekly changelog");
271 `I ("DAILY-CHANGES.md", "Aggregated daily changelog");
272 `P "Each entry includes:";
273 `I ("summary", "A one-line summary of the most important change");
274 `I ("changes", "Up to 5 bullet points describing user-facing changes");
275 `I ("commit_range", "The range of commits included in the entry");
276 `P
277 "Claude AI analyzes commits and generates changelog text focused on \
278 user-facing changes. Internal refactoring, CI tweaks, and typo fixes \
279 are automatically filtered out.";
280 `P
281 "Repositories with no user-facing changes will have blank entries \
282 (empty summary and changes) rather than 'no changes' text.";
283 `P
284 "When using --daily, an aggregated JSON file is generated by default \
285 for the poe Zulip bot broadcasting system. Use --no-aggregate to \
286 skip.";
287 `P
288 "If a per-repo-per-day JSON file already exists for a past day, that \
289 repo is skipped for that day to avoid redundant Claude API calls.";
290 ]
291 in
292 let info = Cmd.info "changes" ~doc ~man in
293 let daily =
294 let doc = "Generate daily changelog entries instead of weekly" in
295 Arg.(value & flag & info [ "daily"; "d" ] ~doc)
296 in
297 let weeks =
298 let doc =
299 "Number of past weeks to analyze (default: 1, current week only). \
300 Ignored if --daily is set."
301 in
302 Arg.(value & opt int 1 & info [ "w"; "weeks" ] ~doc)
303 in
304 let days =
305 let doc =
306 "Number of past days to analyze when using --daily (default: 1, today \
307 only)"
308 in
309 Arg.(value & opt int 1 & info [ "days" ] ~doc)
310 in
311 let history =
312 let doc =
313 "Number of recent entries to include in aggregated markdown (default: 12 \
314 for weekly, 30 for daily)"
315 in
316 Arg.(value & opt int 12 & info [ "history" ] ~doc)
317 in
318 let dry_run =
319 let doc = "Preview changes without writing files" in
320 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
321 in
322 let no_aggregate =
323 let doc =
324 "Skip generating .changes/YYYYMMDD.json aggregated file (--daily \
325 generates it by default)"
326 in
327 Arg.(value & flag & info [ "no-aggregate" ] ~doc)
328 in
329 let run package daily weeks days history dry_run no_aggregate () =
330 Eio_main.run @@ fun env ->
331 with_config env @@ fun config ->
332 let fs = Eio.Stdenv.fs env in
333 let proc = Eio.Stdenv.process_mgr env in
334 let clock = Eio.Stdenv.clock env in
335 let result =
336 if daily then begin
337 (* Use 30 as default history for daily if not explicitly set *)
338 let history = if history = 12 then 30 else history in
339 (* Aggregate by default for daily, unless --no-aggregate is passed *)
340 let aggregate = not no_aggregate in
341 Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history
342 ~dry_run ~aggregate ()
343 end
344 else
345 Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history
346 ~dry_run ()
347 in
348 match result with
349 | Ok () ->
350 if dry_run then Fmt.pr "Dry run complete.@."
351 else if daily then Fmt.pr "Daily changelog updated.@."
352 else Fmt.pr "Weekly changelog updated.@.";
353 `Ok ()
354 | Error e ->
355 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
356 `Error (false, "changes failed")
357 in
358 Cmd.v info
359 Term.(
360 ret
361 (const run $ package_arg $ daily $ weeks $ days $ history $ dry_run
362 $ no_aggregate $ logging_term))
363
364(* Opam commands *)
365
366let opam_sync_cmd =
367 let doc = "Sync opam files from monorepo to opam-repo" in
368 let man =
369 [
370 `S Manpage.s_description;
371 `P
372 "Copies .opam files from monorepo subtrees to the opam-repo overlay. \
373 This ensures your opam overlay reflects any changes you made to .opam \
374 files in the monorepo.";
375 `S "HOW IT WORKS";
376 `P "For each package in your opam overlay:";
377 `I
378 ( "1.",
379 "Reads the .opam file from the monorepo subtree (e.g., \
380 mono/eio/eio.opam)" );
381 `I
382 ( "2.",
383 "Compares with the opam-repo version (e.g., \
384 opam-repo/packages/eio/eio.dev/opam)" );
385 `I ("3.", "If different, copies monorepo → opam-repo");
386 `I ("4.", "Stages and commits changes in opam-repo");
387 `S "PRECEDENCE";
388 `P "Local always wins: the monorepo version is the source of truth.";
389 `S Manpage.s_examples;
390 `P "Sync all packages:";
391 `Pre "monopam opam sync";
392 `P "Sync a specific package:";
393 `Pre "monopam opam sync eio";
394 ]
395 in
396 let info = Cmd.info "sync" ~doc ~man in
397 let run package () =
398 Eio_main.run @@ fun env ->
399 with_config env @@ fun config ->
400 let fs = Eio.Stdenv.fs env in
401 let proc = Eio.Stdenv.process_mgr env in
402 match Monopam.sync_opam_files ~proc ~fs ~config ?package () with
403 | Ok result ->
404 if result.synced = [] then Fmt.pr "All opam files already in sync.@."
405 else Fmt.pr "Synced %d opam files.@." (List.length result.synced);
406 `Ok ()
407 | Error e ->
408 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
409 `Error (false, "opam sync failed")
410 in
411 Cmd.v info Term.(ret (const run $ package_arg $ logging_term))
412
413let opam_cmd =
414 let doc = "Opam metadata management" in
415 let man =
416 [
417 `S Manpage.s_description;
418 `P
419 "Commands for managing opam metadata between your monorepo and the \
420 opam-repo overlay.";
421 `S "COMMANDS";
422 `I ("sync", "Sync .opam files from monorepo subtrees to opam-repo");
423 ]
424 in
425 let info = Cmd.info "opam" ~doc ~man in
426 Cmd.group info [ opam_sync_cmd ]
427
428(* Init command - initialize a new monopam workspace *)
429
430let init_root_arg =
431 let doc =
432 "Path to workspace root directory. Defaults to current directory."
433 in
434 Arg.(
435 value
436 & opt (some (conv (Fpath.of_string, Fpath.pp))) None
437 & info [ "root" ] ~docv:"PATH" ~doc)
438
439let init_handle_arg =
440 let doc = "Your handle (e.g., alice.bsky.social)" in
441 Arg.(
442 required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
443
444let init_cmd =
445 let doc = "Initialize a new monopam workspace" in
446 let man =
447 [
448 `S Manpage.s_description;
449 `P
450 "Creates a new monopam workspace for monorepo development. The workspace \
451 lets you manage your own monorepo and optionally browse and track other \
452 developers' monorepos.";
453 `S "WORKSPACE STRUCTURE";
454 `P
455 "The init command creates the following directory structure at the \
456 workspace root:";
457 `I ("mono/", "Your monorepo - use with standard monopam commands");
458 `I ("src/", "Your source checkouts - individual git repos");
459 `I ("verse/", "Other users' monorepos, organized by handle");
460 `P "Configuration and data are stored in XDG directories:";
461 `I ("~/.config/monopam/opamverse.toml", "Workspace configuration");
462 `I
463 ( "~/.local/share/monopam/opamverse-registry/",
464 "Git clone of the community registry" );
465 `S "CONFIGURATION FILE";
466 `P "The opamverse.toml file has the following structure:";
467 `Pre
468 "[workspace]\n\
469 root = \"/path/to/workspace\"\n\
470 default_branch = \"main\"\n\n\
471 [paths]\n\
472 mono = \"mono\"\n\
473 src = \"src\"\n\
474 verse = \"verse\"\n\n\
475 [identity]\n\
476 handle = \"yourname.bsky.social\"";
477 `S "HANDLE VALIDATION";
478 `P
479 "The handle you provide identifies you in the community. \
480 It should be a valid domain name (e.g., yourname.bsky.social or \
481 your-domain.com).";
482 `S "REGISTRY";
483 `P
484 "The registry is a git repository containing an opamverse.toml file \
485 that lists community members and their monorepo URLs. The default \
486 registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
487 `S Manpage.s_examples;
488 `P "Initialize a workspace in the current directory:";
489 `Pre "monopam init --handle alice.bsky.social";
490 `P "Initialize with explicit root path:";
491 `Pre "monopam init --root ~/my-workspace --handle alice.bsky.social";
492 ]
493 in
494 let info = Cmd.info "init" ~doc ~man in
495 let run root handle () =
496 Eio_main.run @@ fun env ->
497 let fs = Eio.Stdenv.fs env in
498 let proc = Eio.Stdenv.process_mgr env in
499 let root =
500 match root with
501 | Some r -> r
502 | None -> (
503 let cwd_path = Eio.Stdenv.cwd env in
504 let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
505 match Fpath.of_string cwd_str with
506 | Ok p -> p
507 | Error (`Msg _) -> Fpath.v ".")
508 in
509 match Monopam.Verse.init ~proc ~fs ~root ~handle () with
510 | Ok () ->
511 Fmt.pr "Workspace initialized at %a@." Fpath.pp root;
512 `Ok ()
513 | Error e ->
514 Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
515 `Error (false, "init failed")
516 in
517 Cmd.v info
518 Term.(ret (const run $ init_root_arg $ init_handle_arg $ logging_term))
519
520(* Verse commands *)
521
522(* Helper to load verse config from XDG *)
523let with_verse_config env f =
524 let fs = Eio.Stdenv.fs env in
525 match Monopam.Verse_config.load ~fs () with
526 | Ok config -> f config
527 | Error msg ->
528 Fmt.epr "Error loading opamverse config: %s@." msg;
529 Fmt.epr "Run 'monopam init' to create a workspace.@.";
530 `Error (false, "configuration error")
531
532let verse_members_cmd =
533 let doc = "List registry members" in
534 let man =
535 [
536 `S Manpage.s_description;
537 `P
538 "Lists all members registered in the opamverse community registry. \
539 This shows everyone who has published their monorepo for \
540 collaboration.";
541 `P
542 "The registry is automatically pulled (git pull) when running this \
543 command to ensure you see the latest members.";
544 `S "REGISTRY FORMAT";
545 `P
546 "The registry is a git repository containing an opamverse.toml file \
547 with the following structure:";
548 `Pre
549 "[registry]\n\
550 name = \"tangled-community\"\n\n\
551 [[members]]\n\
552 handle = \"alice.bsky.social\"\n\
553 monorepo = \"https://github.com/alice/mono\"\n\n\
554 [[members]]\n\
555 handle = \"bob.example.com\"\n\
556 monorepo = \"https://github.com/bob/mono\"";
557 `S "OUTPUT";
558 `P "Each line shows a member's handle and their monorepo git URL:";
559 `Pre
560 "alice.bsky.social -> https://github.com/alice/mono\n\
561 bob.example.com -> https://github.com/bob/mono";
562 `S "ADDING YOURSELF";
563 `P
564 "To add yourself to the registry, submit a pull request to the \
565 registry repository adding your entry to opamverse.toml.";
566 ]
567 in
568 let info = Cmd.info "members" ~doc ~man in
569 let run () =
570 Eio_main.run @@ fun env ->
571 with_verse_config env @@ fun config ->
572 let fs = Eio.Stdenv.fs env in
573 let proc = Eio.Stdenv.process_mgr env in
574 match Monopam.Verse.members ~proc ~fs ~config () with
575 | Ok members ->
576 Fmt.pr "@[<v>%a@]@."
577 Fmt.(list ~sep:cut Monopam.Verse_registry.pp_member)
578 members;
579 `Ok ()
580 | Error e ->
581 Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
582 `Error (false, "members failed")
583 in
584 Cmd.v info Term.(ret (const run $ logging_term))
585
586let verse_fork_cmd =
587 let doc = "Fork a package from a verse member's repository" in
588 let man =
589 [
590 `S Manpage.s_description;
591 `P
592 "Fork a package from a verse member's opam repository into your workspace. \
593 This creates entries in your opam-repo with your fork URL as the dev-repo.";
594 `P
595 "The command finds all packages sharing the same git repository and forks \
596 them together. For example, if you fork 'cohttp', it will also fork \
597 cohttp-eio, cohttp-lwt, etc.";
598 `S "WHAT IT DOES";
599 `P "For the specified package:";
600 `I ("1.", "Looks up the package in <handle>'s opam-repo (verse/<handle>-opam/)");
601 `I ("2.", "Finds all packages from the same git repository");
602 `I ("3.", "Creates entries in your opam-repo with your fork URL");
603 `P "After forking:";
604 `I ("1.", "Commit the new opam files: $(b,cd opam-repo && git add -A && git commit)");
605 `I ("2.", "Run $(b,monopam sync) to pull the fork into your monorepo");
606 `S "PREREQUISITES";
607 `P "Before forking:";
608 `I ("-", "Run $(b,monopam verse pull <handle>) to sync the member's opam-repo");
609 `I ("-", "Create a fork of the repository on GitHub/GitLab/etc.");
610 `S Manpage.s_examples;
611 `P "Fork a package from a verse member:";
612 `Pre "monopam fork http2 --from sadiq.bsky.social --url git@github.com:me/http2.git";
613 `P "Preview what would be forked (multi-package repos):";
614 `Pre "monopam fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git --dry-run\n\
615 Would fork 5 packages from cohttp repository:\n\
616 \ cohttp\n\
617 \ cohttp-eio\n\
618 \ cohttp-lwt\n\
619 \ cohttp-async\n\
620 \ cohttp-mirage";
621 `P "After forking, commit and sync:";
622 `Pre "cd opam-repo && git add -A && git commit -m \"Fork cohttp\"\n\
623 monopam sync";
624 `S "ERRORS";
625 `P
626 "The command will fail if any package from the source repo already exists \
627 in your opam-repo. Remove conflicting packages first with:";
628 `Pre "rm -rf opam-repo/packages/<package-name>";
629 ]
630 in
631 let info = Cmd.info "fork" ~doc ~man in
632 let package_arg =
633 let doc = "Package name to fork (e.g., 'cohttp', 'eio')" in
634 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
635 in
636 let from_arg =
637 let doc = "Verse member handle to fork from (e.g., 'avsm.bsky.social')" in
638 Arg.(required & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc)
639 in
640 let url_arg =
641 let doc = "Git URL of your fork (e.g., 'git@github.com:you/repo.git')" in
642 Arg.(required & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc)
643 in
644 let dry_run_arg =
645 let doc = "Show what would be forked without making changes" in
646 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
647 in
648 let run package handle fork_url dry_run () =
649 Eio_main.run @@ fun env ->
650 with_verse_config env @@ fun config ->
651 let fs = Eio.Stdenv.fs env in
652 let proc = Eio.Stdenv.process_mgr env in
653 match Monopam.Verse.fork ~proc ~fs ~config ~handle ~package ~fork_url ~dry_run () with
654 | Ok result ->
655 if dry_run then begin
656 Fmt.pr "Would fork %d package(s) from %s:@."
657 (List.length result.packages_forked) result.source_handle;
658 List.iter (fun p -> Fmt.pr " %s@." p) result.packages_forked
659 end else begin
660 (* Update sources.toml with fork information *)
661 let mono_path = Monopam.Verse_config.mono_path config in
662 let sources_path = Fpath.(mono_path / "sources.toml") in
663 let sources =
664 match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
665 | Ok s -> s
666 | Error _ -> Monopam.Sources_registry.empty
667 in
668 let entry = Monopam.Sources_registry.{
669 url = result.fork_url;
670 upstream = Some result.upstream_url;
671 branch = None;
672 reason = Some (Fmt.str "Forked from %s" result.source_handle);
673 origin = Some Join; (* Forked from verse = joined *)
674 } in
675 let sources = Monopam.Sources_registry.add sources ~subtree:result.subtree_name entry in
676 (match Monopam.Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
677 | Ok () ->
678 Fmt.pr "Updated sources.toml with fork entry for %s@." result.subtree_name
679 | Error msg ->
680 Fmt.epr "Warning: Failed to update sources.toml: %s@." msg);
681 Fmt.pr "Forked %d package(s): %a@."
682 (List.length result.packages_forked)
683 Fmt.(list ~sep:(any ", ") string) result.packages_forked;
684 Fmt.pr "@.Next steps:@.";
685 Fmt.pr " 1. cd opam-repo && git add -A && git commit -m \"Fork ...\"@.";
686 Fmt.pr " 2. monopam sync@."
687 end;
688 `Ok ()
689 | Error e ->
690 Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
691 `Error (false, "fork failed")
692 in
693 Cmd.v info Term.(ret (const run $ package_arg $ from_arg $ url_arg $ dry_run_arg $ logging_term))
694
695let verse_cmd =
696 let doc = "Verse member operations" in
697 let man =
698 [
699 `S Manpage.s_description;
700 `P
701 "Commands for working with verse community members. The verse system \
702 enables federated collaboration across multiple developers' monorepos.";
703 `P
704 "Members are identified by handles - typically domain names like \
705 'yourname.bsky.social' or 'your-domain.com'.";
706 `S "NOTE";
707 `P
708 "The $(b,monopam init) command creates your workspace and \
709 $(b,monopam sync) automatically syncs verse members. These commands \
710 are for additional verse-specific operations.";
711 `S "COMMANDS";
712 `I ("members", "List all members in the community registry");
713 `I ("fork <pkg> --from <handle> --url <url>", "Fork a package from a verse member");
714 `S Manpage.s_examples;
715 `P "List all community members:";
716 `Pre "monopam verse members";
717 `P "Fork a package from another member:";
718 `Pre "monopam verse fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
719 ]
720 in
721 let info = Cmd.info "verse" ~doc ~man in
722 Cmd.group info
723 [
724 verse_members_cmd;
725 verse_fork_cmd;
726 ]
727
728(* Diff command *)
729
730let diff_cmd =
731 let doc = "Show diffs from verse members for repos needing attention" in
732 let man =
733 [
734 `S Manpage.s_description;
735 `P
736 "Shows commit diffs from verse members for repositories where they have \
737 commits you don't have. This helps you see what changes are available \
738 from collaborators.";
739 `S "OUTPUT";
740 `P "First shows the verse status summary, then for each repository where \
741 a verse member is ahead:";
742 `I ("Repository name", "With the handle and relationship");
743 `I ("Commits", "List of commits they have that you don't (max 20)");
744 `S "RELATIONSHIPS";
745 `I ("+N", "They have N commits you don't have");
746 `I ("+N/-M", "Diverged: they have N new commits, you have M new commits");
747 `S "CACHING";
748 `P "Remote fetches are cached for 1 hour to improve performance. \
749 Use $(b,--refresh) to force fresh fetches from all remotes.";
750 `S Manpage.s_examples;
751 `P "Show diffs for all repos needing attention (uses cache):";
752 `Pre "monopam diff";
753 `P "Show diff for a specific repository:";
754 `Pre "monopam diff ocaml-eio";
755 `P "Show patches for all commits:";
756 `Pre "monopam diff -p";
757 `P "Show patch for a specific commit (from diff output):";
758 `Pre "monopam diff abc1234";
759 `P "Force fresh fetches from all remotes:";
760 `Pre "monopam diff --refresh";
761 ]
762 in
763 let info = Cmd.info "diff" ~doc ~man in
764 let arg =
765 let doc = "Repository name or commit SHA. If a 7+ character hex string, shows \
766 the patch for that commit. Otherwise filters to that repository. \
767 If not specified, shows diffs for all repos needing attention." in
768 Arg.(value & pos 0 (some string) None & info [] ~docv:"REPO|SHA" ~doc)
769 in
770 let refresh_arg =
771 let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in
772 Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
773 in
774 let patch_arg =
775 let doc = "Show full patch content for each commit." in
776 Arg.(value & flag & info [ "patch"; "p" ] ~doc)
777 in
778 let run arg refresh patch () =
779 Eio_main.run @@ fun env ->
780 with_config env @@ fun config ->
781 with_verse_config env @@ fun verse_config ->
782 let fs = Eio.Stdenv.fs env in
783 let proc = Eio.Stdenv.process_mgr env in
784 (* Check if arg looks like a commit SHA *)
785 match arg with
786 | Some sha when Monopam.is_commit_sha sha ->
787 (* Show patch for specific commit *)
788 (match Monopam.diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh () with
789 | Some info ->
790 let short_hash = String.sub info.commit_hash 0 (min 7 (String.length info.commit_hash)) in
791 Fmt.pr "%a %s (%s/%s)@.@.%s@."
792 Fmt.(styled `Yellow string) short_hash
793 info.commit_subject
794 info.commit_repo info.commit_handle
795 info.commit_patch;
796 `Ok ()
797 | None ->
798 Fmt.epr "Commit %s not found in any verse diff@." sha;
799 `Error (false, "commit not found"))
800 | repo ->
801 let result = Monopam.diff ~proc ~fs ~config ~verse_config ?repo ~refresh ~patch () in
802 Fmt.pr "%a" (Monopam.pp_diff_result ~show_patch:patch) result;
803 `Ok ()
804 in
805 Cmd.v info Term.(ret (const run $ arg $ refresh_arg $ patch_arg $ logging_term))
806
807(* Pull command - pull from verse members *)
808
809let pull_cmd =
810 let doc = "Pull commits from a verse member's forks" in
811 let man =
812 [
813 `S Manpage.s_description;
814 `P
815 "Pulls commits from a verse member's forks into your local checkouts. \
816 This merges their changes into your checkout branches, making them \
817 ready to be synced to the monorepo via $(b,monopam sync).";
818 `S "WORKFLOW";
819 `P "The typical workflow for incorporating changes from collaborators:";
820 `I ("1.", "$(b,monopam diff) - See what changes are available");
821 `I ("2.", "$(b,monopam pull <handle>) - Pull changes from a collaborator");
822 `I ("3.", "$(b,monopam sync) - Sync changes into your monorepo");
823 `S "MERGING BEHAVIOR";
824 `P "When you're behind (they have commits you don't):";
825 `I ("Fast-forward", "If your branch has no new commits, a fast-forward merge is used.");
826 `P "When branches have diverged (both have new commits):";
827 `I ("Merge commit", "A merge commit is created to combine the histories.");
828 `S Manpage.s_examples;
829 `P "Pull all changes from a verse member:";
830 `Pre "monopam pull avsm.bsky.social";
831 `P "Pull changes for a specific repository:";
832 `Pre "monopam pull avsm.bsky.social eio";
833 `P "Force fresh fetches before pulling:";
834 `Pre "monopam pull --refresh avsm.bsky.social";
835 ]
836 in
837 let info = Cmd.info "pull" ~doc ~man in
838 let handle_arg =
839 let doc = "The verse member handle to pull from (e.g., avsm.bsky.social)." in
840 Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
841 in
842 let repo_arg =
843 let doc = "Optional repository to pull from. If not specified, pulls from all \
844 repositories where the handle has commits you don't have." in
845 Arg.(value & pos 1 (some string) None & info [] ~docv:"REPO" ~doc)
846 in
847 let refresh_arg =
848 let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in
849 Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
850 in
851 let run handle repo refresh () =
852 Eio_main.run @@ fun env ->
853 with_config env @@ fun config ->
854 with_verse_config env @@ fun verse_config ->
855 let fs = Eio.Stdenv.fs env in
856 let proc = Eio.Stdenv.process_mgr env in
857 match Monopam.pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ~refresh () with
858 | Ok result ->
859 Fmt.pr "%a" Monopam.pp_handle_pull_result result;
860 if result.repos_failed <> [] then
861 `Error (false, "some repos failed to pull")
862 else if result.repos_pulled = [] then begin
863 Fmt.pr "Nothing to pull from %s@." handle;
864 `Ok ()
865 end
866 else begin
867 Fmt.pr "@.Run $(b,monopam sync) to merge changes into your monorepo.@.";
868 `Ok ()
869 end
870 | Error e ->
871 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
872 `Error (false, "pull failed")
873 in
874 Cmd.v info Term.(ret (const run $ handle_arg $ repo_arg $ refresh_arg $ logging_term))
875
876(* Cherrypick command *)
877
878let cherrypick_cmd =
879 let doc = "Cherry-pick a specific commit from a verse member's fork" in
880 let man =
881 [
882 `S Manpage.s_description;
883 `P
884 "Applies a specific commit from a verse member's fork to your local checkout. \
885 Use $(b,monopam diff) to see available commits and their hashes.";
886 `S "WORKFLOW";
887 `P "The typical workflow for cherry-picking specific commits:";
888 `I ("1.", "$(b,monopam diff) - See available commits with their hashes");
889 `I ("2.", "$(b,monopam diff <sha>) - View the full patch for a commit");
890 `I ("3.", "$(b,monopam cherrypick <sha>) - Apply that commit");
891 `I ("4.", "$(b,monopam sync) - Sync changes into your monorepo");
892 `S Manpage.s_examples;
893 `P "Cherry-pick a commit:";
894 `Pre "monopam cherrypick abc1234";
895 `P "View a commit's patch first, then cherry-pick:";
896 `Pre "monopam diff abc1234";
897 `Pre "monopam cherrypick abc1234";
898 ]
899 in
900 let info = Cmd.info "cherrypick" ~doc ~man in
901 let sha_arg =
902 let doc = "The commit SHA (or prefix) to cherry-pick. Must be at least 7 characters." in
903 Arg.(required & pos 0 (some string) None & info [] ~docv:"SHA" ~doc)
904 in
905 let refresh_arg =
906 let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in
907 Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
908 in
909 let run sha refresh () =
910 Eio_main.run @@ fun env ->
911 with_config env @@ fun config ->
912 with_verse_config env @@ fun verse_config ->
913 let fs = Eio.Stdenv.fs env in
914 let proc = Eio.Stdenv.process_mgr env in
915 match Monopam.cherrypick ~proc ~fs ~config ~verse_config ~sha ~refresh () with
916 | Ok result ->
917 Fmt.pr "%a" Monopam.pp_cherrypick_result result;
918 Fmt.pr "Run $(b,monopam sync) to merge changes into your monorepo.@.";
919 `Ok ()
920 | Error e ->
921 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
922 `Error (false, "cherrypick failed")
923 in
924 Cmd.v info Term.(ret (const run $ sha_arg $ refresh_arg $ logging_term))
925
926(* Doctor command *)
927
928let doctor_cmd =
929 let doc = "Claude-powered workspace health analysis" in
930 let man =
931 [
932 `S Manpage.s_description;
933 `P
934 "Analyzes your workspace health and provides actionable \
935 recommendations. Uses Claude AI to analyze commits from verse \
936 collaborators, categorizing them by type, priority, and risk level.";
937 `S "WHAT IT DOES";
938 `P "The doctor command:";
939 `I ("1.", "Syncs the workspace (unless $(b,--no-sync) is specified)");
940 `I ("2.", "Checks local sync status (monorepo vs checkouts)");
941 `I ("3.", "Checks remote sync status (checkouts vs upstream)");
942 `I ("4.", "Analyzes fork relationships with verse members");
943 `I ("5.", "Uses Claude to categorize and prioritize their commits");
944 `I ("6.", "Generates actionable recommendations");
945 `P
946 "The status output from $(b,monopam status) is automatically included \
947 in the prompt sent to Claude, so Claude doesn't need to run it \
948 separately.";
949 `S "OUTPUT FORMATS";
950 `P "By default, outputs human-readable text with colors.";
951 `P "Use $(b,--json) for JSON output suitable for tooling.";
952 `S Manpage.s_examples;
953 `P "Run full analysis (syncs first):";
954 `Pre "monopam doctor";
955 `P "Run analysis without syncing first:";
956 `Pre "monopam doctor --no-sync";
957 `P "Analyze a specific repo:";
958 `Pre "monopam doctor eio";
959 `P "Output as JSON:";
960 `Pre "monopam doctor --json";
961 ]
962 in
963 let info = Cmd.info "doctor" ~doc ~man in
964 let json_arg =
965 let doc = "Output as JSON instead of formatted text." in
966 Arg.(value & flag & info [ "json" ] ~doc)
967 in
968 let no_sync_arg =
969 let doc = "Skip running sync before analysis." in
970 Arg.(value & flag & info [ "no-sync" ] ~doc)
971 in
972 let run package json no_sync () =
973 Eio_main.run @@ fun env ->
974 with_config env @@ fun config ->
975 with_verse_config env @@ fun verse_config ->
976 let fs = Eio.Stdenv.fs env in
977 let proc = Eio.Stdenv.process_mgr env in
978 let clock = Eio.Stdenv.clock env in
979 (* Run sync before analysis unless --no-sync is specified *)
980 if not no_sync then begin
981 Fmt.pr "Syncing workspace before analysis...@.";
982 match Monopam.sync ~proc ~fs ~config ?package () with
983 | Ok _summary -> ()
984 | Error e ->
985 Fmt.pr "Warning: sync failed: %a@." Monopam.pp_error_with_hint e;
986 Fmt.pr "Continuing with analysis...@."
987 end;
988 let report =
989 Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package
990 ~no_sync ()
991 in
992 if json then print_endline (Monopam.Doctor.to_json report)
993 else Fmt.pr "%a@." Monopam.Doctor.pp_report report;
994 `Ok ()
995 in
996 Cmd.v info
997 Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term))
998
999(* Feature commands *)
1000
1001let feature_name_arg =
1002 let doc = "Feature name (used for both worktree directory and branch)" in
1003 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
1004
1005let feature_add_cmd =
1006 let doc = "Create a new feature worktree for parallel development" in
1007 let man =
1008 [
1009 `S Manpage.s_description;
1010 `P
1011 "Creates a git worktree at $(b,root/work/<name>) with a new branch named \
1012 $(b,<name>). This allows parallel development on separate branches, \
1013 useful for having multiple Claude instances working on different features.";
1014 `S "HOW IT WORKS";
1015 `P "The command:";
1016 `I ("1.", "Creates the $(b,work/) directory if it doesn't exist");
1017 `I ("2.", "Creates a git worktree at $(b,work/<name>)");
1018 `I ("3.", "Checks out a new branch named $(b,<name>)");
1019 `S Manpage.s_examples;
1020 `P "Create a feature worktree:";
1021 `Pre "monopam feature add my-feature\n\
1022 cd work/my-feature\n\
1023 # Now you can work here independently";
1024 `P "Have multiple Claudes work in parallel:";
1025 `Pre "# Terminal 1\n\
1026 monopam feature add auth-system\n\
1027 cd work/auth-system && claude\n\n\
1028 # Terminal 2\n\
1029 monopam feature add api-refactor\n\
1030 cd work/api-refactor && claude";
1031 ]
1032 in
1033 let info = Cmd.info "add" ~doc ~man in
1034 let run name () =
1035 Eio_main.run @@ fun env ->
1036 with_verse_config env @@ fun verse_config ->
1037 let fs = Eio.Stdenv.fs env in
1038 let proc = Eio.Stdenv.process_mgr env in
1039 match Monopam.Feature.add ~proc ~fs ~config:verse_config ~name () with
1040 | Ok entry ->
1041 Fmt.pr "Created feature worktree '%s' at %a@." entry.name Fpath.pp entry.path;
1042 Fmt.pr "@.To start working:@.";
1043 Fmt.pr " cd %a@." Fpath.pp entry.path;
1044 `Ok ()
1045 | Error e ->
1046 Fmt.epr "Error: %a@." Monopam.Feature.pp_error_with_hint e;
1047 `Error (false, "feature add failed")
1048 in
1049 Cmd.v info Term.(ret (const run $ feature_name_arg $ logging_term))
1050
1051let feature_remove_cmd =
1052 let doc = "Remove a feature worktree" in
1053 let man =
1054 [
1055 `S Manpage.s_description;
1056 `P
1057 "Removes the git worktree at $(b,root/work/<name>). The branch is not \
1058 deleted, so you can recreate the worktree later if needed.";
1059 `S "OPTIONS";
1060 `I ("--force", "Remove even if there are uncommitted changes");
1061 `S Manpage.s_examples;
1062 `P "Remove a completed feature worktree:";
1063 `Pre "monopam feature remove my-feature";
1064 `P "Force remove with uncommitted changes:";
1065 `Pre "monopam feature remove my-feature --force";
1066 ]
1067 in
1068 let info = Cmd.info "remove" ~doc ~man in
1069 let force_arg =
1070 let doc = "Remove even if there are uncommitted changes." in
1071 Arg.(value & flag & info [ "force"; "f" ] ~doc)
1072 in
1073 let run name force () =
1074 Eio_main.run @@ fun env ->
1075 with_verse_config env @@ fun verse_config ->
1076 let fs = Eio.Stdenv.fs env in
1077 let proc = Eio.Stdenv.process_mgr env in
1078 match Monopam.Feature.remove ~proc ~fs ~config:verse_config ~name ~force () with
1079 | Ok () ->
1080 Fmt.pr "Removed feature worktree '%s'.@." name;
1081 `Ok ()
1082 | Error e ->
1083 Fmt.epr "Error: %a@." Monopam.Feature.pp_error_with_hint e;
1084 `Error (false, "feature remove failed")
1085 in
1086 Cmd.v info Term.(ret (const run $ feature_name_arg $ force_arg $ logging_term))
1087
1088let feature_list_cmd =
1089 let doc = "List all feature worktrees" in
1090 let man =
1091 [
1092 `S Manpage.s_description;
1093 `P "Lists all git worktrees in the $(b,root/work/) directory.";
1094 `S Manpage.s_examples;
1095 `Pre "monopam feature list";
1096 ]
1097 in
1098 let info = Cmd.info "list" ~doc ~man in
1099 let run () =
1100 Eio_main.run @@ fun env ->
1101 with_verse_config env @@ fun verse_config ->
1102 let fs = Eio.Stdenv.fs env in
1103 let proc = Eio.Stdenv.process_mgr env in
1104 let entries = Monopam.Feature.list ~proc ~fs ~config:verse_config () in
1105 if entries = [] then
1106 Fmt.pr "No feature worktrees found.@."
1107 else begin
1108 Fmt.pr "Feature worktrees:@.";
1109 List.iter (fun entry ->
1110 Fmt.pr " %s -> %a (branch: %s)@."
1111 entry.Monopam.Feature.name
1112 Fpath.pp entry.Monopam.Feature.path
1113 entry.Monopam.Feature.branch
1114 ) entries
1115 end;
1116 `Ok ()
1117 in
1118 Cmd.v info Term.(ret (const run $ logging_term))
1119
1120let feature_cmd =
1121 let doc = "Manage feature worktrees for parallel development" in
1122 let man =
1123 [
1124 `S Manpage.s_description;
1125 `P
1126 "Feature worktrees allow parallel development on separate branches of \
1127 the monorepo. This is useful for having multiple Claude instances \
1128 working on different features simultaneously.";
1129 `S "WORKSPACE STRUCTURE";
1130 `P "Feature worktrees are created in the $(b,work/) directory:";
1131 `Pre "root/\n\
1132 ├── mono/ # Main monorepo\n\
1133 ├── work/\n\
1134 │ ├── feature-a/ # Worktree on branch 'feature-a'\n\
1135 │ └── feature-b/ # Worktree on branch 'feature-b'\n\
1136 └── ...";
1137 `S "COMMANDS";
1138 `I ("add <name>", "Create a new feature worktree");
1139 `I ("remove <name>", "Remove a feature worktree");
1140 `I ("list", "List all feature worktrees");
1141 `S "WORKFLOW";
1142 `P "Typical workflow for parallel development:";
1143 `Pre "# Create feature worktrees\n\
1144 monopam feature add auth-system\n\
1145 monopam feature add api-cleanup\n\n\
1146 # Work in each worktree independently\n\
1147 cd work/auth-system && claude\n\
1148 cd work/api-cleanup && claude\n\n\
1149 # When done, merge branches back to main\n\
1150 cd mono\n\
1151 git merge auth-system\n\
1152 git merge api-cleanup\n\n\
1153 # Clean up worktrees\n\
1154 monopam feature remove auth-system\n\
1155 monopam feature remove api-cleanup";
1156 ]
1157 in
1158 let info = Cmd.info "feature" ~doc ~man in
1159 Cmd.group info [ feature_add_cmd; feature_remove_cmd; feature_list_cmd ]
1160
1161(* Devcontainer command *)
1162
1163let default_devcontainer_url =
1164 "https://raw.githubusercontent.com/avsm/claude-ocaml-devcontainer/refs/heads/main/.devcontainer/devcontainer.json"
1165
1166let devcontainer_cmd =
1167 let doc = "Setup and enter a devcontainer environment" in
1168 let man =
1169 [
1170 `S Manpage.s_description;
1171 `P
1172 "Creates and enters a devcontainer environment for OCaml development \
1173 with monopam and Claude. If the target directory doesn't have a \
1174 .devcontainer configuration, it will be created automatically.";
1175 `P
1176 "This is the recommended way to get started with monopam. The \
1177 devcontainer provides a consistent environment with OCaml, opam, \
1178 and all required tools pre-installed.";
1179 `S "WHAT IT DOES";
1180 `P "For a new directory (no .devcontainer/):";
1181 `I ("1.", "Creates the target directory if needed");
1182 `I ("2.", "Creates .devcontainer/ subdirectory");
1183 `I ("3.", "Downloads devcontainer.json from the template repository");
1184 `I ("4.", "Builds and starts the devcontainer");
1185 `I ("5.", "Opens an interactive shell inside the container");
1186 `P "For an existing directory with .devcontainer/:";
1187 `I ("1.", "Starts the devcontainer if not running");
1188 `I ("2.", "Opens an interactive shell inside the container");
1189 `S Manpage.s_options;
1190 `P "Use $(b,--url) to specify a custom devcontainer.json URL if you want \
1191 to use a different base configuration.";
1192 `S Manpage.s_examples;
1193 `P "Create a new devcontainer workspace:";
1194 `Pre "monopam devcontainer ~/my-ocaml-project";
1195 `P "Enter an existing devcontainer:";
1196 `Pre "monopam devcontainer ~/my-ocaml-project";
1197 `P "Use a custom devcontainer.json:";
1198 `Pre "monopam devcontainer --url https://example.com/devcontainer.json ~/project";
1199 ]
1200 in
1201 let info = Cmd.info "devcontainer" ~doc ~man in
1202 let path_arg =
1203 let doc = "Target directory for the devcontainer workspace." in
1204 Arg.(required & pos 0 (some string) None & info [] ~docv:"PATH" ~doc)
1205 in
1206 let url_arg =
1207 let doc = "URL to fetch devcontainer.json from. Defaults to the claude-ocaml-devcontainer template." in
1208 Arg.(value & opt string default_devcontainer_url & info ["url"] ~docv:"URL" ~doc)
1209 in
1210 let run path url () =
1211 (* Resolve to absolute path *)
1212 let abs_path =
1213 if Filename.is_relative path then
1214 Filename.concat (Sys.getcwd ()) path
1215 else path
1216 in
1217 let devcontainer_dir = Filename.concat abs_path ".devcontainer" in
1218 let devcontainer_json = Filename.concat devcontainer_dir "devcontainer.json" in
1219 (* Check if .devcontainer exists *)
1220 let needs_init = not (Sys.file_exists devcontainer_dir && Sys.is_directory devcontainer_dir) in
1221 if needs_init then begin
1222 Fmt.pr "Initializing devcontainer in %s...@." abs_path;
1223 (* Create directories *)
1224 (try Unix.mkdir abs_path 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
1225 (try Unix.mkdir devcontainer_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
1226 (* Fetch devcontainer.json using curl *)
1227 Fmt.pr "Fetching devcontainer.json from %s...@." url;
1228 let curl_cmd = Printf.sprintf "curl -fsSL '%s' -o '%s'" url devcontainer_json in
1229 let ret = Sys.command curl_cmd in
1230 if ret <> 0 then begin
1231 Fmt.epr "Error: Failed to fetch devcontainer.json (curl exit code %d)@." ret;
1232 exit 1
1233 end;
1234 Fmt.pr "Created %s@." devcontainer_json;
1235 (* Build and start the devcontainer *)
1236 Fmt.pr "Building devcontainer (this may take a while on first run)...@.";
1237 let up_cmd = Printf.sprintf "npx @devcontainers/cli up --workspace-folder '%s' --remove-existing-container" abs_path in
1238 let ret = Sys.command up_cmd in
1239 if ret <> 0 then begin
1240 Fmt.epr "Error: Failed to start devcontainer (exit code %d)@." ret;
1241 exit 1
1242 end
1243 end;
1244 (* Exec into the devcontainer *)
1245 Fmt.pr "Entering devcontainer...@.";
1246 let exec_cmd = Printf.sprintf "npx @devcontainers/cli exec --workspace-folder '%s' bash -l" abs_path in
1247 let ret = Sys.command exec_cmd in
1248 if ret <> 0 then
1249 `Error (false, Printf.sprintf "devcontainer exec failed with code %d" ret)
1250 else
1251 `Ok ()
1252 in
1253 Cmd.v info Term.(ret (const run $ path_arg $ url_arg $ logging_term))
1254
1255(* Confirmation prompt *)
1256let confirm prompt =
1257 Printf.printf "%s [y/N] %!" prompt;
1258 match In_channel.(input_line stdin) with
1259 | Some s -> String.lowercase_ascii (String.trim s) = "y"
1260 | None -> false
1261
1262(* Prompt for optional string input *)
1263let prompt_string prompt =
1264 Printf.printf "%s %!" prompt;
1265 match In_channel.(input_line stdin) with
1266 | Some s ->
1267 let s = String.trim s in
1268 if s = "" then None else Some s
1269 | None -> None
1270
1271(* Fork command *)
1272
1273let fork_cmd =
1274 let doc = "Fork a monorepo subtree into its own repository" in
1275 let man =
1276 [
1277 `S Manpage.s_description;
1278 `P
1279 "Splits a monorepo subdirectory into its own git repository and \
1280 establishes a proper subtree relationship. This creates src/<name>/ \
1281 with the extracted history, then re-adds mono/<name>/ as a subtree.";
1282 `S "FORK MODES";
1283 `P "The fork command handles two scenarios:";
1284 `I ("Subtree with history", "For subtrees added via $(b,git subtree add) or \
1285 $(b,monopam join), the command uses $(b,git subtree split) to extract \
1286 the full commit history into the new repository.");
1287 `I ("Fresh package", "For packages created directly in mono/ without subtree \
1288 history, the command copies the files and creates an initial commit. \
1289 This is useful for new packages you've developed locally.");
1290 `S "WHAT IT DOES";
1291 `P "The fork command performs a complete workflow in one step:";
1292 `I ("1.", "Analyzes mono/<name>/ to detect fork mode");
1293 `I ("2.", "Builds an action plan and shows discovery details");
1294 `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
1295 `I ("4.", "Creates a new git repo at src/<name>/");
1296 `I ("5.", "Extracts history (subtree split) or copies files (fresh package)");
1297 `I ("6.", "Removes mono/<name>/ from git and commits");
1298 `I ("7.", "Re-adds mono/<name>/ as a proper subtree from src/<name>/");
1299 `I ("8.", "Updates sources.toml with $(b,origin = \"fork\")");
1300 `S "AFTER FORKING";
1301 `P "After forking, the subtree relationship is fully established:";
1302 `I ("-", "mono/<name>/ is now a proper git subtree of src/<name>/");
1303 `I ("-", "$(b,monopam sync) will push/pull changes correctly");
1304 `I ("-", "No need for manual $(b,git rm) or $(b,monopam rejoin)");
1305 `P "To push to a remote:";
1306 `Pre "cd src/<name> && git push -u origin main";
1307 `S Manpage.s_examples;
1308 `P "Fork a subtree with local-only repo:";
1309 `Pre "monopam fork my-lib";
1310 `P "Fork with a remote push URL:";
1311 `Pre "monopam fork my-lib git@github.com:me/my-lib.git";
1312 `P "Preview what would be done:";
1313 `Pre "monopam fork my-lib --dry-run";
1314 `P "Fork without confirmation:";
1315 `Pre "monopam fork my-lib --yes";
1316 ]
1317 in
1318 let info = Cmd.info "fork" ~doc ~man in
1319 let name_arg =
1320 let doc = "Name of the subtree to fork (directory name under mono/)" in
1321 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
1322 in
1323 let url_arg =
1324 let doc = "Optional remote URL to add as 'origin' for pushing" in
1325 Arg.(value & pos 1 (some string) None & info [] ~docv:"URL" ~doc)
1326 in
1327 let dry_run_arg =
1328 let doc = "Show what would be done without making changes" in
1329 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
1330 in
1331 let yes_arg =
1332 let doc = "Assume yes to all prompts (for automation)" in
1333 Arg.(value & flag & info [ "yes"; "y" ] ~doc)
1334 in
1335 let run name url dry_run yes () =
1336 Eio_main.run @@ fun env ->
1337 with_verse_config env @@ fun config ->
1338 let fs = Eio.Stdenv.fs env in
1339 let proc = Eio.Stdenv.process_mgr env in
1340 (* Get URL: use provided, or try to derive from dune-project, or prompt *)
1341 let url =
1342 match url with
1343 | Some _ -> url
1344 | None ->
1345 (* Try to get default from dune-project *)
1346 let mono_path = Monopam.Config.mono_path config in
1347 let subtree_path = Fpath.(mono_path / name) in
1348 let knot = Monopam.Config.knot config in
1349 let suggested = Monopam.Fork_join.suggest_push_url ~fs ~knot subtree_path in
1350 if yes || dry_run then
1351 suggested (* Use suggested or None without prompting *)
1352 else begin
1353 match suggested with
1354 | Some default_url ->
1355 Fmt.pr "Remote push URL [%s]: %!" default_url;
1356 (match prompt_string "" with
1357 | None -> Some default_url (* User pressed enter, use default *)
1358 | Some entered -> Some entered)
1359 | None ->
1360 Fmt.pr "Remote push URL (leave empty to skip): %!";
1361 prompt_string ""
1362 end
1363 in
1364 (* Build the plan *)
1365 match Monopam.Fork_join.plan_fork ~proc ~fs ~config ~name ?push_url:url ~dry_run () with
1366 | Error e ->
1367 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1368 `Error (false, "fork failed")
1369 | Ok plan ->
1370 (* Print discovery and actions *)
1371 Fmt.pr "Analyzing fork request for '%s'...@.@." name;
1372 Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery;
1373 (match url with
1374 | Some u -> Fmt.pr " Remote URL: %s@." u
1375 | None -> ());
1376 Fmt.pr "@.Actions to perform:@.";
1377 List.iteri (fun i action ->
1378 Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
1379 ) plan.actions;
1380 Fmt.pr "@.";
1381 (* Prompt for confirmation unless --yes or --dry-run *)
1382 let proceed =
1383 if dry_run then begin
1384 Fmt.pr "(dry-run mode - no changes will be made)@.";
1385 true
1386 end else if yes then
1387 true
1388 else
1389 confirm "Proceed?"
1390 in
1391 if not proceed then begin
1392 Fmt.pr "Cancelled.@.";
1393 `Ok ()
1394 end else begin
1395 (* Execute the plan *)
1396 match Monopam.Fork_join.execute_fork_plan ~proc ~fs plan with
1397 | Ok result ->
1398 if not dry_run then begin
1399 Fmt.pr "%a@." Monopam.Fork_join.pp_fork_result result;
1400 Fmt.pr "@.Next steps:@.";
1401 Fmt.pr " 1. Review the new repo: cd src/%s@." result.name;
1402 match url with
1403 | Some _ -> Fmt.pr " 2. Push to remote: git push -u origin main@."
1404 | None -> Fmt.pr " 2. Add a remote: git remote add origin <url>@."
1405 end;
1406 `Ok ()
1407 | Error e ->
1408 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1409 `Error (false, "fork failed")
1410 end
1411 in
1412 Cmd.v info Term.(ret (const run $ name_arg $ url_arg $ dry_run_arg $ yes_arg $ logging_term))
1413
1414(* Join command *)
1415
1416let join_cmd =
1417 let doc = "Bring an external repository into the monorepo" in
1418 let man =
1419 [
1420 `S Manpage.s_description;
1421 `P
1422 "Clones an external git repository and adds it as a subtree in the \
1423 monorepo. This is the inverse of $(b,monopam fork).";
1424 `S "JOIN MODES";
1425 `P "The join command handles multiple scenarios:";
1426 `I ("URL join", "Clone from a git URL and add as subtree (default).");
1427 `I ("Local directory join", "Import from a local filesystem path. If the \
1428 path is a git repo, uses it directly. If not, initializes a new repo.");
1429 `I ("Verse join", "Join from a verse member's repository using $(b,--from).");
1430 `S "WHAT IT DOES";
1431 `P "The join command:";
1432 `I ("1.", "Analyzes the source (URL or local path)");
1433 `I ("2.", "Builds an action plan and shows discovery details");
1434 `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
1435 `I ("4.", "Clones/copies the repository to src/<name>/");
1436 `I ("5.", "Uses $(b,git subtree add) to bring into monorepo");
1437 `I ("6.", "Updates sources.toml with $(b,origin = \"join\")");
1438 `S "JOINING FROM VERSE";
1439 `P "To join a package from a verse member, use $(b,--from):";
1440 `Pre "monopam join --from avsm.bsky.social --url git@github.com:me/cohttp.git cohttp";
1441 `P "This will:";
1442 `I ("-", "Look up the package in their opam-repo");
1443 `I ("-", "Find all packages from the same git repository");
1444 `I ("-", "Create opam entries pointing to your fork");
1445 `I ("-", "Clone and add the subtree");
1446 `S "AFTER JOINING";
1447 `P "After joining, work with the subtree normally:";
1448 `I ("1.", "Make changes in mono/<name>/");
1449 `I ("2.", "Commit in mono/");
1450 `I ("3.", "Run $(b,monopam sync --remote) to push upstream");
1451 `S Manpage.s_examples;
1452 `P "Join a repository:";
1453 `Pre "monopam join https://github.com/someone/some-lib";
1454 `P "Join from a local directory:";
1455 `Pre "monopam join /path/to/local/repo --as my-lib";
1456 `P "Join with explicit name using --url:";
1457 `Pre "monopam join --url https://tangled.org/handle/sortal sortal";
1458 `P "Join with a custom name using --as:";
1459 `Pre "monopam join https://github.com/someone/some-lib --as my-lib";
1460 `P "Join with upstream tracking (for forks):";
1461 `Pre "monopam join https://github.com/me/cohttp --upstream https://github.com/mirage/cohttp";
1462 `P "Join from a verse member:";
1463 `Pre "monopam join cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
1464 `P "Preview what would be done:";
1465 `Pre "monopam join https://github.com/someone/lib --dry-run";
1466 `P "Join without confirmation:";
1467 `Pre "monopam join https://github.com/someone/lib --yes";
1468 ]
1469 in
1470 let info = Cmd.info "join" ~doc ~man in
1471 let url_or_pkg_arg =
1472 let doc = "Git URL, local path, or subtree name (when using --url)" in
1473 Arg.(required & pos 0 (some string) None & info [] ~docv:"SOURCE" ~doc)
1474 in
1475 let as_arg =
1476 let doc = "Override subtree directory name" in
1477 Arg.(value & opt (some string) None & info [ "as" ] ~docv:"NAME" ~doc)
1478 in
1479 let upstream_arg =
1480 let doc = "Original upstream URL (for tracking forks)" in
1481 Arg.(value & opt (some string) None & info [ "upstream" ] ~docv:"URL" ~doc)
1482 in
1483 let from_arg =
1484 let doc = "Verse member handle to join from (requires --url)" in
1485 Arg.(value & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc)
1486 in
1487 let fork_url_arg =
1488 let doc = "Git URL to clone from (makes positional arg the subtree name)" in
1489 Arg.(value & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc)
1490 in
1491 let dry_run_arg =
1492 let doc = "Show what would be done without making changes" in
1493 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
1494 in
1495 let yes_arg =
1496 let doc = "Assume yes to all prompts (for automation)" in
1497 Arg.(value & flag & info [ "yes"; "y" ] ~doc)
1498 in
1499 let run url_or_pkg as_name upstream from fork_url dry_run yes () =
1500 Eio_main.run @@ fun env ->
1501 with_verse_config env @@ fun config ->
1502 let fs = Eio.Stdenv.fs env in
1503 let proc = Eio.Stdenv.process_mgr env in
1504 match from with
1505 | Some handle ->
1506 (* Join from verse member - requires --url for your fork *)
1507 (* Uses legacy API as it involves verse-specific operations *)
1508 (match fork_url with
1509 | None ->
1510 Fmt.epr "Error: --url is required when using --from@.";
1511 `Error (false, "--url required")
1512 | Some fork_url ->
1513 match Monopam.Fork_join.join_from_verse ~proc ~fs ~config ~verse_config:config
1514 ~package:url_or_pkg ~handle ~fork_url ~dry_run () with
1515 | Ok result ->
1516 if dry_run then begin
1517 Fmt.pr "Would join '%s' from %s:@." result.name (Option.value ~default:"verse" result.from_handle);
1518 Fmt.pr " Source: %s@." result.source_url;
1519 Option.iter (fun u -> Fmt.pr " Upstream: %s@." u) result.upstream_url;
1520 Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_added
1521 end else begin
1522 Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
1523 Fmt.pr "@.Next steps:@.";
1524 Fmt.pr " 1. Commit the opam changes: cd opam-repo && git add -A && git commit@.";
1525 Fmt.pr " 2. Run $(b,monopam sync) to synchronize@."
1526 end;
1527 `Ok ()
1528 | Error e ->
1529 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1530 `Error (false, "join failed"))
1531 | None ->
1532 (* Normal join from URL or local path - use plan-based workflow *)
1533 let source = match fork_url with Some u -> u | None -> url_or_pkg in
1534 let name = match fork_url with Some _ -> Some url_or_pkg | None -> as_name in
1535 (* Build the plan *)
1536 match Monopam.Fork_join.plan_join ~proc ~fs ~config ~source ?name ?upstream ~dry_run () with
1537 | Error e ->
1538 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1539 `Error (false, "join failed")
1540 | Ok plan ->
1541 (* Print discovery and actions *)
1542 let is_local = Monopam.Fork_join.is_local_path source in
1543 Fmt.pr "Analyzing join request...@.@.";
1544 Fmt.pr "Discovery:@.";
1545 Fmt.pr " Source: %s (%s)@." source
1546 (if is_local then "local directory" else "remote URL");
1547 Fmt.pr "%a" Monopam.Fork_join.pp_discovery plan.discovery;
1548 Fmt.pr "@.Actions to perform:@.";
1549 List.iteri (fun i action ->
1550 Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
1551 ) plan.actions;
1552 Fmt.pr "@.";
1553 (* Prompt for confirmation unless --yes or --dry-run *)
1554 let proceed =
1555 if dry_run then begin
1556 Fmt.pr "(dry-run mode - no changes will be made)@.";
1557 true
1558 end else if yes then
1559 true
1560 else
1561 confirm "Proceed?"
1562 in
1563 if not proceed then begin
1564 Fmt.pr "Cancelled.@.";
1565 `Ok ()
1566 end else begin
1567 (* Execute the plan *)
1568 match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with
1569 | Ok result ->
1570 if not dry_run then begin
1571 Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
1572 Fmt.pr "@.Next steps:@.";
1573 Fmt.pr " 1. Run $(b,monopam sync) to synchronize@."
1574 end;
1575 `Ok ()
1576 | Error e ->
1577 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1578 `Error (false, "join failed")
1579 end
1580 in
1581 Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ yes_arg $ logging_term))
1582
1583(* Rejoin command *)
1584
1585let rejoin_cmd =
1586 let doc = "Add a source checkout back into the monorepo as a subtree" in
1587 let man =
1588 [
1589 `S Manpage.s_description;
1590 `P
1591 "Adds an existing src/<name>/ repository back into mono/<name>/ as a \
1592 subtree. This is useful after forking a package and removing it from \
1593 the monorepo with $(b,git rm).";
1594 `S "WORKFLOW";
1595 `P "Typical workflow for removing and re-adding a package:";
1596 `I ("1.", "Fork the package: $(b,monopam fork my-lib)");
1597 `I ("2.", "Remove from monorepo: $(b,git rm -r mono/my-lib && git commit)");
1598 `I ("3.", "Work on it in src/my-lib/");
1599 `I ("4.", "Re-add to monorepo: $(b,monopam rejoin my-lib)");
1600 `S "REQUIREMENTS";
1601 `P "For rejoin to work:";
1602 `I ("-", "src/<name>/ must exist and be a git repository");
1603 `I ("-", "mono/<name>/ must NOT exist (was removed)");
1604 `S "WHAT IT DOES";
1605 `P "The rejoin command:";
1606 `I ("1.", "Verifies src/<name>/ exists and is a git repo");
1607 `I ("2.", "Verifies mono/<name>/ does not exist");
1608 `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)");
1609 `I ("4.", "Uses $(b,git subtree add) to bring src/<name>/ into mono/<name>/");
1610 `S Manpage.s_examples;
1611 `P "Re-add a package from src/:";
1612 `Pre "monopam rejoin my-lib";
1613 `P "Preview what would be done:";
1614 `Pre "monopam rejoin my-lib --dry-run";
1615 `P "Rejoin without confirmation:";
1616 `Pre "monopam rejoin my-lib --yes";
1617 ]
1618 in
1619 let info = Cmd.info "rejoin" ~doc ~man in
1620 let name_arg =
1621 let doc = "Name of the subtree to rejoin (directory name under src/)" in
1622 Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
1623 in
1624 let dry_run_arg =
1625 let doc = "Show what would be done without making changes" in
1626 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
1627 in
1628 let yes_arg =
1629 let doc = "Assume yes to all prompts (for automation)" in
1630 Arg.(value & flag & info [ "yes"; "y" ] ~doc)
1631 in
1632 let run name dry_run yes () =
1633 Eio_main.run @@ fun env ->
1634 with_verse_config env @@ fun config ->
1635 let fs = Eio.Stdenv.fs env in
1636 let proc = Eio.Stdenv.process_mgr env in
1637 (* Build the plan *)
1638 match Monopam.Fork_join.plan_rejoin ~proc ~fs ~config ~name ~dry_run () with
1639 | Error e ->
1640 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1641 `Error (false, "rejoin failed")
1642 | Ok plan ->
1643 (* Print discovery and actions *)
1644 Fmt.pr "Analyzing rejoin request for '%s'...@.@." name;
1645 Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery;
1646 Fmt.pr "@.Actions to perform:@.";
1647 List.iteri (fun i action ->
1648 Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action
1649 ) plan.actions;
1650 Fmt.pr "@.";
1651 (* Prompt for confirmation unless --yes or --dry-run *)
1652 let proceed =
1653 if dry_run then begin
1654 Fmt.pr "(dry-run mode - no changes will be made)@.";
1655 true
1656 end else if yes then
1657 true
1658 else
1659 confirm "Proceed?"
1660 in
1661 if not proceed then begin
1662 Fmt.pr "Cancelled.@.";
1663 `Ok ()
1664 end else begin
1665 (* Execute the plan *)
1666 match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with
1667 | Ok result ->
1668 if not dry_run then begin
1669 Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
1670 Fmt.pr "@.Next steps:@.";
1671 Fmt.pr " 1. Commit the changes: git add -A && git commit@.";
1672 Fmt.pr " 2. Run $(b,monopam sync) to synchronize@."
1673 end;
1674 `Ok ()
1675 | Error e ->
1676 Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
1677 `Error (false, "rejoin failed")
1678 end
1679 in
1680 Cmd.v info Term.(ret (const run $ name_arg $ dry_run_arg $ yes_arg $ logging_term))
1681
1682(* Site command *)
1683
1684let site_cmd =
1685 let doc = "Generate a static HTML site representing the monoverse map" in
1686 let man =
1687 [
1688 `S Manpage.s_description;
1689 `P
1690 "Generates a static index.html file that maps the monoverse, showing all \
1691 verse members, their packages, and the relationships between them.";
1692 `S "OUTPUT";
1693 `P "The generated site includes:";
1694 `I ("Members", "All verse members with links to their monorepo and opam repos");
1695 `I ("Summary", "Overview of common libraries and member-specific packages");
1696 `I ("Repository Details", "Each shared repo with packages and fork status");
1697 `S "FORK STATUS";
1698 `P "Use $(b,--status) to include fork relationship information:";
1699 `I ("+N", "You are N commits ahead of them");
1700 `I ("-N", "They are N commits ahead of you");
1701 `I ("+N/-M", "Diverged: you have N new, they have M new");
1702 `I ("sync", "Same commit");
1703 `S "DESIGN";
1704 `P "The HTML is designed to be:";
1705 `I ("-", "Simple and clean with a 10pt font");
1706 `I ("-", "Responsive and compact");
1707 `I ("-", "External links marked with icon and teal color");
1708 `S Manpage.s_examples;
1709 `P "Generate site to default location (mono/index.html):";
1710 `Pre "monopam site";
1711 `P "Generate site with fork status (slower, fetches remotes):";
1712 `Pre "monopam site --status";
1713 `P "Generate site to custom location:";
1714 `Pre "monopam site -o /var/www/monoverse/index.html";
1715 `P "Print HTML to stdout:";
1716 `Pre "monopam site --stdout";
1717 ]
1718 in
1719 let info = Cmd.info "site" ~doc ~man in
1720 let output_arg =
1721 let doc = "Output file path. Defaults to mono/index.html." in
1722 Arg.(value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc)
1723 in
1724 let stdout_arg =
1725 let doc = "Print HTML to stdout instead of writing to file." in
1726 Arg.(value & flag & info [ "stdout" ] ~doc)
1727 in
1728 let status_arg =
1729 let doc = "Include fork status (ahead/behind) for each repository. \
1730 This fetches from remotes and may be slower." in
1731 Arg.(value & flag & info [ "status"; "s" ] ~doc)
1732 in
1733 let run output to_stdout with_status () =
1734 Eio_main.run @@ fun env ->
1735 with_config env @@ fun monopam_config ->
1736 with_verse_config env @@ fun verse_config ->
1737 let fs = Eio.Stdenv.fs env in
1738 let proc = Eio.Stdenv.process_mgr env in
1739 (* Pull/clone registry to get latest metadata *)
1740 Fmt.pr "Syncing registry...@.";
1741 let registry =
1742 match Monopam.Verse_registry.clone_or_pull ~proc ~fs:(fs :> _ Eio.Path.t) ~config:verse_config () with
1743 | Ok r -> r
1744 | Error msg ->
1745 Fmt.epr "Warning: Could not sync registry: %s@." msg;
1746 Monopam.Verse_registry.{ name = "opamverse"; description = None; members = [] }
1747 in
1748 (* Compute forks if --status is requested *)
1749 let forks =
1750 if with_status then begin
1751 Fmt.pr "Computing fork status...@.";
1752 Some (Monopam.Forks.compute ~proc ~fs:(fs :> _ Eio.Path.t)
1753 ~verse_config ~monopam_config ())
1754 end else None
1755 in
1756 if to_stdout then begin
1757 let html = Monopam.Site.generate ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry () in
1758 print_string html;
1759 `Ok ()
1760 end else begin
1761 let output_path =
1762 match output with
1763 | Some p -> (
1764 match Fpath.of_string p with
1765 | Ok fp -> fp
1766 | Error (`Msg _) -> Fpath.v p)
1767 | None -> Fpath.(Monopam.Verse_config.mono_path verse_config / "index.html")
1768 in
1769 match Monopam.Site.write ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry ~output_path () with
1770 | Ok () ->
1771 Fmt.pr "Site generated: %a@." Fpath.pp output_path;
1772 `Ok ()
1773 | Error msg ->
1774 Fmt.epr "Error: %s@." msg;
1775 `Error (false, "site generation failed")
1776 end
1777 in
1778 Cmd.v info Term.(ret (const run $ output_arg $ stdout_arg $ status_arg $ logging_term))
1779
1780(* Main command group *)
1781
1782let main_cmd =
1783 let doc = "Manage opam overlay with git subtree monorepo" in
1784 let man =
1785 [
1786 `S Manpage.s_description;
1787 `P
1788 "Monopam synchronizes packages between an opam overlay repository, \
1789 individual git checkouts, and a monorepo using git subtrees.";
1790 `P
1791 "Monopam is designed to run inside a devcontainer that provides a \
1792 consistent OCaml development environment with all required tools \
1793 pre-installed.";
1794 `S "QUICK START";
1795 `P "Start by creating a devcontainer workspace:";
1796 `Pre
1797 "monopam devcontainer ~/tangled";
1798 `P "Inside the devcontainer, initialize your workspace:";
1799 `Pre
1800 "cd ~/tangled\n\
1801 monopam init --handle yourname.bsky.social\n\
1802 cd mono";
1803 `P "Daily workflow:";
1804 `Pre
1805 "cd ~/tangled/mono\n\
1806 monopam sync # sync local and remote (most common)\n\
1807 # ... make edits ...\n\
1808 git add -A && git commit # commit your changes\n\
1809 monopam sync --remote # sync and push to upstream";
1810 `S "DIRECTORY STRUCTURE";
1811 `P "Monopam manages three directory trees:";
1812 `I
1813 ( "mono/",
1814 "The monorepo combining all packages as git subtrees. This is where \
1815 you make changes." );
1816 `I
1817 ( "src/",
1818 "Individual git checkouts of each unique repository. Used for review \
1819 and manual operations." );
1820 `I
1821 ( "opam-repo/",
1822 "The opam overlay repository containing package metadata." );
1823 `S "WORKFLOW";
1824 `P "The recommended workflow uses $(b,sync) as the primary command:";
1825 `I
1826 ( "1. monopam sync",
1827 "Synchronize your monorepo with all upstream repos. This both \
1828 exports your local changes to checkouts AND pulls remote changes." );
1829 `I ("2. Edit code", "Make changes in the mono/ directory");
1830 `I ("3. git commit", "Commit your changes in mono/");
1831 `I
1832 ( "4. monopam sync --remote",
1833 "Sync again, including pushing to upstream git remotes" );
1834 `P "For finer control over the sync phases:";
1835 `I
1836 ( "monopam sync --skip-pull",
1837 "Export monorepo changes to checkouts only (skip fetching remotes)" );
1838 `I
1839 ( "monopam sync --skip-push",
1840 "Pull remote changes only (skip exporting local changes)" );
1841 `S "CHECKING STATUS";
1842 `P "Run $(b,monopam status) to see the state of all repositories:";
1843 `I ("local:+N", "Your monorepo is N commits ahead of the checkout");
1844 `I ("local:-N", "The checkout is N commits ahead of your monorepo");
1845 `I ("local:sync", "Trees differ but need syncing (run $(b,monopam sync))");
1846 `I ("remote:+N", "Your checkout is N commits ahead of upstream");
1847 `I ("remote:-N", "Upstream is N commits ahead (run $(b,monopam sync))");
1848 `S "COMMON TASKS";
1849 `I ("Start fresh", "monopam init --handle you.bsky.social");
1850 `I ("Check status", "monopam status");
1851 `I ("Sync everything", "monopam sync");
1852 `I ("Sync and push upstream", "monopam sync --remote");
1853 `I ("Sync one package", "monopam sync <package-name>");
1854 `S "CONFIGURATION";
1855 `P
1856 "Run $(b,monopam init --handle <handle>) to create a workspace. \
1857 Configuration is stored in ~/.config/monopam/opamverse.toml.";
1858 `P "Workspace structure:";
1859 `Pre
1860 "root/\n\
1861 ├── mono/ # Your monorepo (work here)\n\
1862 ├── src/ # Git checkouts (for review)\n\
1863 ├── opam-repo/ # Opam overlay\n\
1864 └── verse/ # Other members' monorepos";
1865 `S "TROUBLESHOOTING";
1866 `I
1867 ( "\"Dirty packages\" error",
1868 "You have uncommitted changes. Run: cd mono && git status" );
1869 `I
1870 ( "\"local:sync\" in status",
1871 "The monorepo and checkout are out of sync. Run: monopam sync" );
1872 `I
1873 ( "Merge conflicts",
1874 "Resolve conflicts in mono/, commit, then run: monopam sync" );
1875 `S Manpage.s_commands;
1876 `P "Use $(b,monopam COMMAND --help) for help on a specific command.";
1877 ]
1878 in
1879 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in
1880 Cmd.group info
1881 [ init_cmd; status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; fork_cmd; join_cmd; rejoin_cmd; devcontainer_cmd; site_cmd ]
1882
1883let () = exit (Cmd.eval main_cmd)