···11open Cmdliner
2233-let setup_logging style_renderer level =
33+let setup_logging style_renderer level verbose_http =
44 Fmt_tty.setup_std_outputs ?style_renderer ();
55+ Logs.set_reporter (Logs_fmt.reporter ());
66+ (* Set global log level for monopam's own logs *)
57 Logs.set_level level;
66- Logs.set_reporter (Logs_fmt.reporter ())
88+ (* Use Requests.Cmd.setup_log_sources to configure HTTP logging separately.
99+ This allows -v to show app logs without HTTP protocol details,
1010+ while --verbose-http enables full HTTP tracing. *)
1111+ Requests.Cmd.setup_log_sources ~verbose_http level
712813let logging_term =
99- Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
1010-1111-let config_file_arg =
1212- let doc =
1313- "Path to config file. If not specified, searches current directory then \
1414- XDG locations."
1414+ let verbose_http_term =
1515+ Term.(const (fun ws -> ws.Requests.Cmd.value) $ Requests.Cmd.verbose_http_term "monopam")
1516 in
1616- Arg.(
1717- value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
1717+ Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level () $ verbose_http_term)
18181919let package_arg =
2020 let doc = "Package name. If not specified, operates on all packages." in
2121 Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
22222323-let load_config env config_file =
2323+(* Load config from opamverse.toml and convert to Monopam.Config *)
2424+let load_config env =
2425 let fs = Eio.Stdenv.fs env in
2525- let cwd = Eio.Stdenv.cwd env in
2626- match config_file with
2727- | Some path -> (
2828- (* If absolute, use fs; if relative, use cwd *)
2929- let load_path = Fpath.v path in
3030- if Fpath.is_abs load_path then
3131- Monopam.Config.load ~fs ~root_fs:fs load_path
3232- else
3333- match
3434- Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs load_path
3535- with
3636- | Ok c -> Ok c
3737- | Error msg -> Error msg)
3838- | None -> (
3939- (* Try current directory first *)
4040- let cwd_config = Fpath.v "monopam.toml" in
4141- match
4242- Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs cwd_config
4343- with
4444- | Ok c -> Ok c
4545- | Error _ -> (
4646- (* Try XDG *)
4747- let xdg = Xdge.create fs "monopam" in
4848- match Monopam.Config.load_xdg ~xdg () with
4949- | Ok c -> Ok c
5050- | Error msg -> Error msg))
2626+ match Monopam.Verse_config.load ~fs () with
2727+ | Error msg -> Error msg
2828+ | Ok verse_config ->
2929+ (* Convert Verse_config to Monopam.Config *)
3030+ let opam_repo = Monopam.Verse_config.opam_repo_path verse_config in
3131+ let checkouts = Monopam.Verse_config.src_path verse_config in
3232+ let monorepo = Monopam.Verse_config.mono_path verse_config in
3333+ let default_branch = Monopam.Verse_config.default_branch in
3434+ Ok (Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch ())
51355252-let with_config env config_file f =
5353- match load_config env config_file with
3636+let with_config env f =
3737+ match load_config env with
5438 | Ok config -> f config
5539 | Error msg ->
5640 Fmt.epr "Error loading config: %s@." msg;
4141+ Fmt.epr "Run 'monopam verse init' first to create a workspace.@.";
5742 `Error (false, "configuration error")
58435944(* Status command *)
···7762 ]
7863 in
7964 let info = Cmd.info "status" ~doc ~man in
8080- let run config_file () =
6565+ let run () =
8166 Eio_main.run @@ fun env ->
8282- with_config env config_file @@ fun config ->
6767+ with_config env @@ fun config ->
8368 let fs = Eio.Stdenv.fs env in
8469 let proc = Eio.Stdenv.process_mgr env in
8570 match Monopam.status ~proc ~fs ~config () with
8671 | Ok statuses ->
8772 Fmt.pr "%a@." Monopam.Status.pp_summary statuses;
7373+ (* Check for unregistered opam files *)
7474+ (match Monopam.discover_packages ~fs ~config () with
7575+ | Ok pkgs ->
7676+ let unregistered = Monopam.find_unregistered_opam_files ~fs ~config pkgs in
7777+ if unregistered <> [] then begin
7878+ Fmt.pr "@.";
7979+ Fmt.pr "@[<v>Warning: Found opam files not in overlay:@,";
8080+ List.iter (fun (repo, pkg) ->
8181+ Fmt.pr " %s/%s.opam@," repo pkg) unregistered;
8282+ Fmt.pr "Consider adding these packages to the opam overlay.@]@."
8383+ end
8484+ | Error _ -> ());
8885 `Ok ()
8986 | Error e ->
9087 Fmt.epr "Error: %a@." Monopam.pp_error e;
9188 `Error (false, "status failed")
9289 in
9393- Cmd.v info Term.(ret (const run $ config_file_arg $ logging_term))
9090+ Cmd.v info Term.(ret (const run $ logging_term))
94919592(* Pull command *)
9693···109106 merges" );
110107 `I ("2.", "Adds or pulls the git subtree into the monorepo");
111108 `P
109109+ "If the opam-repo doesn't exist locally, it will be cloned from the \
110110+ URL registered for your account in the opamverse registry.";
111111+ `P
112112 "If a specific package is given, only that package's repository is \
113113 processed.";
114114 `P "The operation will fail if any checkout has uncommitted changes.";
115115 ]
116116 in
117117 let info = Cmd.info "pull" ~doc ~man in
118118- let run config_file package () =
118118+ let run package () =
119119 Eio_main.run @@ fun env ->
120120- with_config env config_file @@ fun config ->
120120+ with_config env @@ fun config ->
121121 let fs = Eio.Stdenv.fs env in
122122 let proc = Eio.Stdenv.process_mgr env in
123123- match Monopam.pull ~proc ~fs ~config ?package () with
123123+ (* Look up opam-repo URL from registry using verse config *)
124124+ let opam_repo_url =
125125+ match Monopam.Verse_config.load ~fs () with
126126+ | Error _ -> None
127127+ | Ok verse_config ->
128128+ let handle = Monopam.Verse_config.handle verse_config in
129129+ match Monopam.Verse_registry.clone_or_pull ~proc ~fs ~config:verse_config () with
130130+ | Error _ -> None
131131+ | Ok registry ->
132132+ match Monopam.Verse_registry.find_member registry ~handle with
133133+ | None -> None
134134+ | Some member -> Some member.opamrepo
135135+ in
136136+ match Monopam.pull ~proc ~fs ~config ?package ?opam_repo_url () with
124137 | Ok () ->
125138 Fmt.pr "Pull completed.@.";
126139 `Ok ()
···129142 `Error (false, "pull failed")
130143 in
131144 Cmd.v info
132132- Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
145145+ Term.(ret (const run $ package_arg $ logging_term))
133146134147(* Push command *)
135148···162175 in
163176 Arg.(value & flag & info [ "upstream" ] ~doc)
164177 in
165165- let run config_file package upstream () =
178178+ let run package upstream () =
166179 Eio_main.run @@ fun env ->
167167- with_config env config_file @@ fun config ->
180180+ with_config env @@ fun config ->
168181 let fs = Eio.Stdenv.fs env in
169182 let proc = Eio.Stdenv.process_mgr env in
170183 match Monopam.push ~proc ~fs ~config ?package ~upstream () with
···177190 in
178191 Cmd.v info
179192 Term.(
180180- ret (const run $ config_file_arg $ package_arg $ upstream_arg $ logging_term))
181181-182182-(* Extract command *)
183183-184184-let extract_cmd =
185185- let doc = "Extract a subdirectory as a standalone repository" in
186186- let man = [
187187- `S Manpage.s_description;
188188- `P "Extracts a subdirectory from the monorepo as a standalone git \
189189- repository with full history. Enables 'develop first, extract later'.";
190190- `P "The extraction process:";
191191- `I ("1.", "Runs git subtree split to extract commits");
192192- `I ("2.", "Creates a new git repository in checkouts");
193193- `I ("3.", "Configures the remote URL");
194194- `S "EXAMPLES";
195195- `Pre " monopam extract my-lib --repo git@github.com:user/my-lib.git";
196196- `Pre " monopam extract my-lib --repo git@github.com:user/my-lib.git --push";
197197- ] in
198198- let info = Cmd.info "extract" ~doc ~man in
199199- let subdir_arg =
200200- let doc = "Subdirectory in monorepo to extract" in
201201- Arg.(required & pos 0 (some string) None & info [] ~docv:"SUBDIR" ~doc)
202202- in
203203- let repo_arg =
204204- let doc = "Git URL for the new repository" in
205205- Arg.(required & opt (some string) None & info [ "repo"; "r" ] ~docv:"URL" ~doc)
206206- in
207207- let branch_arg =
208208- let doc = "Branch name (default: from config)" in
209209- Arg.(value & opt (some string) None & info [ "branch"; "b" ] ~docv:"BRANCH" ~doc)
210210- in
211211- let push_arg =
212212- let doc = "Push to remote after extraction" in
213213- Arg.(value & flag & info [ "push" ] ~doc)
214214- in
215215- let create_opam_arg =
216216- let doc = "Create opam package metadata in overlay" in
217217- Arg.(value & flag & info [ "create-opam" ] ~doc)
218218- in
219219- let run config_file subdir repo branch push create_opam () =
220220- Eio_main.run @@ fun env ->
221221- with_config env config_file @@ fun config ->
222222- let fs = Eio.Stdenv.fs env in
223223- let proc = Eio.Stdenv.process_mgr env in
224224- match Monopam.extract ~proc ~fs ~config ~subdir ~repo_url:repo
225225- ?branch ~push ~create_opam () with
226226- | Ok () -> `Ok ()
227227- | Error e ->
228228- Fmt.epr "Error: %a@." Monopam.pp_error e;
229229- `Error (false, "extract failed")
230230- in
231231- Cmd.v info
232232- Term.(ret (const run $ config_file_arg $ subdir_arg $ repo_arg
233233- $ branch_arg $ push_arg $ create_opam_arg $ logging_term))
193193+ ret (const run $ package_arg $ upstream_arg $ logging_term))
234194235195(* Add command *)
236196···250210 let doc = "Package name to add" in
251211 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
252212 in
253253- let run config_file package () =
213213+ let run package () =
254214 Eio_main.run @@ fun env ->
255255- with_config env config_file @@ fun config ->
215215+ with_config env @@ fun config ->
256216 let fs = Eio.Stdenv.fs env in
257217 let proc = Eio.Stdenv.process_mgr env in
258218 match Monopam.add ~proc ~fs ~config ~package () with
···264224 `Error (false, "add failed")
265225 in
266226 Cmd.v info
267267- Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
227227+ Term.(ret (const run $ package_arg $ logging_term))
268228269229(* Remove command *)
270230···284244 let doc = "Package name to remove" in
285245 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
286246 in
287287- let run config_file package () =
247247+ let run package () =
288248 Eio_main.run @@ fun env ->
289289- with_config env config_file @@ fun config ->
249249+ with_config env @@ fun config ->
290250 let fs = Eio.Stdenv.fs env in
291251 let proc = Eio.Stdenv.process_mgr env in
292252 match Monopam.remove ~proc ~fs ~config ~package () with
···298258 `Error (false, "remove failed")
299259 in
300260 Cmd.v info
301301- Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
302302-303303-(* Init command *)
304304-305305-let prompt_path ~stdin ~stdout ~cwd prompt ~default =
306306- let default_str =
307307- match default with Some d -> Fmt.str " [%a]" Fpath.pp d | None -> ""
308308- in
309309- Eio.Flow.copy_string (Fmt.str "%s%s: " prompt default_str) stdout;
310310- let input = String.trim (Eio.Buf_read.line stdin) in
311311- let input =
312312- if input = "" then Option.map Fpath.to_string default else Some input
313313- in
314314- match input with
315315- | None -> Error "Path is required"
316316- | Some s -> (
317317- (* Expand tilde *)
318318- let s =
319319- if String.length s > 0 && s.[0] = '~' then
320320- match Sys.getenv_opt "HOME" with
321321- | Some home ->
322322- if String.length s = 1 then home
323323- else if s.[1] = '/' then
324324- home ^ String.sub s 1 (String.length s - 1)
325325- else s
326326- | None -> s
327327- else s
328328- in
329329- match Fpath.of_string s with
330330- | Error (`Msg m) -> Error m
331331- | Ok path ->
332332- (* Convert relative to absolute using cwd *)
333333- let path =
334334- if Fpath.is_abs path then path else Fpath.(cwd // path |> normalize)
335335- in
336336- Ok path)
337337-338338-let init_cmd =
339339- let doc = "Initialize a new monopam configuration" in
340340- let man =
341341- [
342342- `S Manpage.s_description;
343343- `P
344344- "Interactively creates a monopam.toml configuration file in the \
345345- current directory. Prompts for the paths to the opam overlay, \
346346- checkouts directory, and monorepo directory.";
347347- `P
348348- "All paths must be absolute. You can use ~/ for your home directory, \
349349- and relative paths will be converted to absolute based on the current \
350350- working directory.";
351351- ]
352352- in
353353- let info = Cmd.info "init" ~doc ~man in
354354- let output_arg =
355355- let doc = "Output path for config file (default: monopam.toml)" in
356356- Arg.(
357357- value & opt string "monopam.toml"
358358- & info [ "o"; "output" ] ~docv:"FILE" ~doc)
359359- in
360360- let run output () =
361361- Eio_main.run @@ fun env ->
362362- let _fs = Eio.Stdenv.fs env in
363363- let cwd_path = Eio.Stdenv.cwd env in
364364- let stdin =
365365- Eio.Buf_read.of_flow ~max_size:(1024 * 1024) (Eio.Stdenv.stdin env)
366366- in
367367- let stdout = Eio.Stdenv.stdout env in
368368- (* Get current working directory as Fpath *)
369369- let cwd =
370370- let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
371371- match Fpath.of_string cwd_str with Ok p -> p | Error _ -> Fpath.v "/"
372372- in
373373- Eio.Flow.copy_string "Monopam Configuration Setup\n" stdout;
374374- Eio.Flow.copy_string "===========================\n\n" stdout;
375375- Eio.Flow.copy_string
376376- "All paths must be absolute. Use ~/ for home directory.\n" stdout;
377377- Eio.Flow.copy_string "Relative paths will be converted to absolute.\n\n"
378378- stdout;
379379- (* Prompt for opam_repo *)
380380- let opam_repo = ref None in
381381- while !opam_repo = None do
382382- match
383383- prompt_path ~stdin ~stdout ~cwd "Path to opam overlay repository"
384384- ~default:None
385385- with
386386- | Ok p -> opam_repo := Some p
387387- | Error msg ->
388388- Eio.Flow.copy_string
389389- (Fmt.str "Error: %s. Please try again.\n" msg)
390390- stdout
391391- done;
392392- let opam_repo = Option.get !opam_repo in
393393- (* Prompt for checkouts *)
394394- let default_checkouts = Fpath.(parent opam_repo / "src") in
395395- let checkouts = ref None in
396396- while !checkouts = None do
397397- match
398398- prompt_path ~stdin ~stdout ~cwd "Path for git checkouts"
399399- ~default:(Some default_checkouts)
400400- with
401401- | Ok p -> checkouts := Some p
402402- | Error msg ->
403403- Eio.Flow.copy_string
404404- (Fmt.str "Error: %s. Please try again.\n" msg)
405405- stdout
406406- done;
407407- let checkouts = Option.get !checkouts in
408408- (* Prompt for monorepo *)
409409- let default_monorepo = Fpath.(parent opam_repo / "mono") in
410410- let monorepo = ref None in
411411- while !monorepo = None do
412412- match
413413- prompt_path ~stdin ~stdout ~cwd "Path for monorepo"
414414- ~default:(Some default_monorepo)
415415- with
416416- | Ok p -> monorepo := Some p
417417- | Error msg ->
418418- Eio.Flow.copy_string
419419- (Fmt.str "Error: %s. Please try again.\n" msg)
420420- stdout
421421- done;
422422- let monorepo = Option.get !monorepo in
423423- (* Prompt for default branch *)
424424- Eio.Flow.copy_string "Default git branch [main]: " stdout;
425425- let branch_input = String.trim (Eio.Buf_read.line stdin) in
426426- let default_branch = if branch_input = "" then "main" else branch_input in
427427- (* Create config *)
428428- let config =
429429- Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch ()
430430- in
431431- (* Save config *)
432432- let output_path = Fpath.v output in
433433- match
434434- Monopam.Config.save ~fs:(cwd_path :> _ Eio.Path.t) config output_path
435435- with
436436- | Ok () ->
437437- Eio.Flow.copy_string
438438- (Fmt.str "\nConfiguration saved to %s\n" output)
439439- stdout;
440440- Eio.Flow.copy_string
441441- "\nYou can now run 'monopam pull' to initialize the monorepo.\n"
442442- stdout;
443443- `Ok ()
444444- | Error msg ->
445445- Fmt.epr "Error saving config: %s@." msg;
446446- `Error (false, "init failed")
447447- in
448448- Cmd.v info Term.(ret (const run $ output_arg $ logging_term))
261261+ Term.(ret (const run $ package_arg $ logging_term))
449262450263(* Changes command *)
451264···511324 let doc = "Skip generating .changes/YYYYMMDD.json aggregated file (--daily generates it by default)" in
512325 Arg.(value & flag & info [ "no-aggregate" ] ~doc)
513326 in
514514- let run config_file package daily weeks days history dry_run no_aggregate () =
327327+ let run package daily weeks days history dry_run no_aggregate () =
515328 Eio_main.run @@ fun env ->
516516- with_config env config_file @@ fun config ->
329329+ with_config env @@ fun config ->
517330 let fs = Eio.Stdenv.fs env in
518331 let proc = Eio.Stdenv.process_mgr env in
519332 let clock = Eio.Stdenv.clock env in
···541354 Cmd.v info
542355 Term.(
543356 ret
544544- (const run $ config_file_arg $ package_arg $ daily $ weeks $ days $ history $ dry_run
357357+ (const run $ package_arg $ daily $ weeks $ days $ history $ dry_run
545358 $ no_aggregate $ logging_term))
546359360360+(* Verse commands *)
361361+362362+(* Helper to load verse config from XDG *)
363363+let with_verse_config env f =
364364+ let fs = Eio.Stdenv.fs env in
365365+ match Monopam.Verse_config.load ~fs () with
366366+ | Ok config -> f config
367367+ | Error msg ->
368368+ Fmt.epr "Error loading opamverse config: %s@." msg;
369369+ Fmt.epr "Run 'monopam verse init' to create a workspace.@.";
370370+ `Error (false, "configuration error")
371371+372372+let verse_root_arg =
373373+ let doc = "Path to workspace root directory. Defaults to current directory." in
374374+ Arg.(
375375+ value
376376+ & opt (some (conv (Fpath.of_string, Fpath.pp))) None
377377+ & info [ "root" ] ~docv:"PATH" ~doc)
378378+379379+let verse_handle_arg =
380380+ let doc = "Tangled handle (e.g., alice.bsky.social)" in
381381+ Arg.(required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
382382+383383+let verse_handle_pos_arg =
384384+ let doc = "Tangled handle (e.g., alice.bsky.social)" in
385385+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
386386+387387+let verse_handle_opt_pos_arg =
388388+ let doc = "Tangled handle. If not specified, operates on all tracked members." in
389389+ Arg.(value & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
390390+391391+let verse_init_cmd =
392392+ let doc = "Initialize a new opamverse workspace" in
393393+ let man =
394394+ [
395395+ `S Manpage.s_description;
396396+ `P
397397+ "Creates a new opamverse workspace for federated monorepo collaboration. \
398398+ An opamverse workspace lets you browse and track other developers' \
399399+ monorepos alongside your own.";
400400+ `S "WORKSPACE STRUCTURE";
401401+ `P "The init command creates the following directory structure at the workspace root:";
402402+ `I ("mono/", "Your monorepo - use with standard monopam commands");
403403+ `I ("src/", "Your source checkouts - individual git repos");
404404+ `I ("verse/", "Other users' monorepos, organized by handle");
405405+ `P "Configuration and data are stored in XDG directories:";
406406+ `I ("~/.config/monopam/opamverse.toml", "Workspace configuration");
407407+ `I ("~/.local/share/monopam/opamverse-registry/", "Git clone of the community registry");
408408+ `S "CONFIGURATION FILE";
409409+ `P "The opamverse.toml file has the following structure:";
410410+ `Pre "[workspace]\n\
411411+ root = \"/path/to/workspace\"\n\
412412+ default_branch = \"main\"\n\n\
413413+ [paths]\n\
414414+ mono = \"mono\"\n\
415415+ src = \"src\"\n\
416416+ verse = \"verse\"\n\n\
417417+ [identity]\n\
418418+ handle = \"yourname.bsky.social\"";
419419+ `S "AUTHENTICATION";
420420+ `P
421421+ "Before running init, you must authenticate with the tangled network:";
422422+ `Pre "tangled auth login";
423423+ `P
424424+ "The handle you provide is validated against the AT Protocol identity \
425425+ system to ensure it exists and you are authenticated.";
426426+ `S "REGISTRY";
427427+ `P
428428+ "The opamverse registry is a git repository containing an opamverse.toml \
429429+ file that lists community members and their monorepo URLs. The default \
430430+ registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
431431+ `S Manpage.s_examples;
432432+ `P "Initialize a workspace in ~/tangled:";
433433+ `Pre "cd ~/tangled\n\
434434+ monopam verse init --handle alice.bsky.social";
435435+ `P "Initialize with explicit root path:";
436436+ `Pre "monopam verse init --root ~/my-workspace --handle alice.bsky.social";
437437+ ]
438438+ in
439439+ let info = Cmd.info "init" ~doc ~man in
440440+ let run root handle () =
441441+ Eio_main.run @@ fun env ->
442442+ Eio.Switch.run @@ fun sw ->
443443+ let fs = Eio.Stdenv.fs env in
444444+ let proc = Eio.Stdenv.process_mgr env in
445445+ let root =
446446+ match root with
447447+ | Some r -> r
448448+ | None ->
449449+ let cwd_path = Eio.Stdenv.cwd env in
450450+ let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
451451+ match Fpath.of_string cwd_str with
452452+ | Ok p -> p
453453+ | Error (`Msg _) -> Fpath.v "."
454454+ in
455455+ match Monopam.Verse.init ~proc ~fs ~sw ~env ~root ~handle () with
456456+ | Ok () ->
457457+ Fmt.pr "Monoverse workspace initialized at %a@." Fpath.pp root;
458458+ `Ok ()
459459+ | Error e ->
460460+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
461461+ `Error (false, "init failed")
462462+ in
463463+ Cmd.v info Term.(ret (const run $ verse_root_arg $ verse_handle_arg $ logging_term))
464464+465465+let verse_status_cmd =
466466+ let doc = "Show workspace status" in
467467+ let man =
468468+ [
469469+ `S Manpage.s_description;
470470+ `P
471471+ "Displays the status of your opamverse workspace, including which \
472472+ members you're tracking and the git state of their local clones.";
473473+ `S "OUTPUT";
474474+ `P "For each tracked member, shows:";
475475+ `I ("handle", "The member's tangled handle (e.g., alice.bsky.social)");
476476+ `I ("monorepo URL", "Git URL of their monorepo");
477477+ `I ("status", "One of: not cloned, clean, dirty, ahead N/behind M");
478478+ `S "STATUS INDICATORS";
479479+ `I ("not cloned", "Member added but monorepo not yet cloned locally");
480480+ `I ("clean", "Local clone matches remote, no uncommitted changes");
481481+ `I ("dirty", "Local clone has uncommitted changes");
482482+ `I ("ahead N, behind M", "Local is N commits ahead and M commits behind remote");
483483+ `S Manpage.s_examples;
484484+ `Pre "$ monopam verse status\n\
485485+ Workspace: /home/user/tangled\n\
486486+ Registry: tangled-community\n\
487487+ Members:\n\
488488+ \ alice.bsky.social -> https://github.com/alice/mono [clean]\n\
489489+ \ bob.example.com -> https://github.com/bob/mono [ahead 2, behind 0]";
490490+ ]
491491+ in
492492+ let info = Cmd.info "status" ~doc ~man in
493493+ let run () =
494494+ Eio_main.run @@ fun env ->
495495+ with_verse_config env @@ fun config ->
496496+ let fs = Eio.Stdenv.fs env in
497497+ let proc = Eio.Stdenv.process_mgr env in
498498+ match Monopam.Verse.status ~proc ~fs ~config () with
499499+ | Ok status ->
500500+ Fmt.pr "%a@." Monopam.Verse.pp_status status;
501501+ `Ok ()
502502+ | Error e ->
503503+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
504504+ `Error (false, "status failed")
505505+ in
506506+ Cmd.v info Term.(ret (const run $ logging_term))
507507+508508+let verse_members_cmd =
509509+ let doc = "List registry members" in
510510+ let man =
511511+ [
512512+ `S Manpage.s_description;
513513+ `P
514514+ "Lists all members registered in the opamverse community registry. \
515515+ This shows everyone who has published their monorepo for collaboration.";
516516+ `P
517517+ "The registry is automatically pulled (git pull) when running this \
518518+ command to ensure you see the latest members.";
519519+ `S "REGISTRY FORMAT";
520520+ `P
521521+ "The registry is a git repository containing an opamverse.toml file \
522522+ with the following structure:";
523523+ `Pre "[registry]\n\
524524+ name = \"tangled-community\"\n\n\
525525+ [[members]]\n\
526526+ handle = \"alice.bsky.social\"\n\
527527+ monorepo = \"https://github.com/alice/mono\"\n\n\
528528+ [[members]]\n\
529529+ handle = \"bob.example.com\"\n\
530530+ monorepo = \"https://github.com/bob/mono\"";
531531+ `S "OUTPUT";
532532+ `P "Each line shows a member's handle and their monorepo git URL:";
533533+ `Pre "alice.bsky.social -> https://github.com/alice/mono\n\
534534+ bob.example.com -> https://github.com/bob/mono";
535535+ `S "ADDING YOURSELF";
536536+ `P
537537+ "To add yourself to the registry, submit a pull request to the \
538538+ registry repository adding your entry to opamverse.toml.";
539539+ ]
540540+ in
541541+ let info = Cmd.info "members" ~doc ~man in
542542+ let run () =
543543+ Eio_main.run @@ fun env ->
544544+ with_verse_config env @@ fun config ->
545545+ let fs = Eio.Stdenv.fs env in
546546+ let proc = Eio.Stdenv.process_mgr env in
547547+ match Monopam.Verse.members ~proc ~fs ~config () with
548548+ | Ok members ->
549549+ Fmt.pr "@[<v>%a@]@."
550550+ Fmt.(list ~sep:cut Monopam.Verse_registry.pp_member)
551551+ members;
552552+ `Ok ()
553553+ | Error e ->
554554+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
555555+ `Error (false, "members failed")
556556+ in
557557+ Cmd.v info Term.(ret (const run $ logging_term))
558558+559559+let verse_add_all_arg =
560560+ let doc = "Add all members from the registry." in
561561+ Arg.(value & flag & info [ "all" ] ~doc)
562562+let verse_add_cmd =
563563+ let doc = "Add a member to the workspace" in
564564+ let man =
565565+ [
566566+ `S Manpage.s_description;
567567+ `P
568568+ "Adds a community member's monorepo to your workspace by cloning it \
569569+ to the verse/<handle>/ directory.";
570570+ `P
571571+ "With --all, adds all members from the registry that are not already \
572572+ tracked in your workspace.";
573573+ `S "PROCESS";
574574+ `P "The add command performs the following steps:";
575575+ `I ("1.", "Validates the handle against the tangled network (AT Protocol)");
576576+ `I ("2.", "Looks up the handle in the opamverse registry");
577577+ `I ("3.", "Clones their monorepo to verse/<handle>/");
578578+ `S "HANDLE VALIDATION";
579579+ `P
580580+ "Handles are validated against the AT Protocol identity system to \
581581+ ensure they exist. This requires prior authentication:";
582582+ `Pre "tangled auth login";
583583+ `S "AFTER ADDING";
584584+ `P
585585+ "Once added, you can browse the member's code in verse/<handle>/. \
586586+ Their monorepo follows the same structure as yours (managed by monopam), \
587587+ so you can explore their packages and dependencies.";
588588+ `P "Use 'monopam verse pull <handle>' to fetch updates later.";
589589+ `S Manpage.s_examples;
590590+ `Pre "# Add a member\n\
591591+ monopam verse add alice.bsky.social\n\n\
592592+ # Add all members from the registry\n\
593593+ monopam verse add --all\n\n\
594594+ # Browse their code\n\
595595+ ls verse/alice.bsky.social/\n\
596596+ cd verse/alice.bsky.social && dune build";
597597+ `S "ERRORS";
598598+ `I ("Member not found", "The handle is not in the registry - they need to register first");
599599+ `I ("Handle not found", "The handle doesn't exist on the tangled network");
600600+ `I ("Not authenticated", "Run 'tangled auth login' first");
601601+ ]
602602+ in
603603+ let info = Cmd.info "add" ~doc ~man in
604604+ let run handle all () =
605605+ Eio_main.run @@ fun env ->
606606+ Eio.Switch.run @@ fun sw ->
607607+ with_verse_config env @@ fun config ->
608608+ let fs = Eio.Stdenv.fs env in
609609+ let proc = Eio.Stdenv.process_mgr env in
610610+ match (handle, all) with
611611+ | None, false ->
612612+ Fmt.epr "Error: Either provide a HANDLE or use --all@.";
613613+ `Error (true, "missing argument")
614614+ | Some _, true ->
615615+ Fmt.epr "Error: Cannot use --all with a specific handle@.";
616616+ `Error (true, "conflicting arguments")
617617+ | Some handle, false ->
618618+ (match Monopam.Verse.add ~proc ~fs ~sw ~env ~config ~handle () with
619619+ | Ok () ->
620620+ Fmt.pr "Added %s to workspace.@." handle;
621621+ `Ok ()
622622+ | Error e ->
623623+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
624624+ `Error (false, "add failed"))
625625+ | None, true ->
626626+ (match Monopam.Verse.add_all ~proc ~fs ~sw ~env ~config () with
627627+ | Ok members ->
628628+ Fmt.pr "Added %d members to workspace.@." (List.length members);
629629+ `Ok ()
630630+ | Error e ->
631631+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
632632+ `Error (false, "add --all failed"))
633633+ in
634634+ Cmd.v info Term.(ret (const run $ verse_handle_opt_pos_arg $ verse_add_all_arg $ logging_term))
635635+636636+let verse_remove_cmd =
637637+ let doc = "Remove a member from the workspace" in
638638+ let man =
639639+ [
640640+ `S Manpage.s_description;
641641+ `P
642642+ "Removes a member's monorepo from your workspace by deleting the \
643643+ verse/<handle>/ directory.";
644644+ `P
645645+ "This is a local operation - it only affects your workspace. The \
646646+ member remains in the registry and can be re-added later.";
647647+ `S "WARNING";
648648+ `P
649649+ "This permanently deletes the local clone. Any local changes you \
650650+ made in verse/<handle>/ will be lost. If you have uncommitted work, \
651651+ commit and push it first (if you have write access) or back it up.";
652652+ `S Manpage.s_examples;
653653+ `Pre "# Remove a member\n\
654654+ monopam verse remove alice.bsky.social\n\n\
655655+ # Re-add them later if needed\n\
656656+ monopam verse add alice.bsky.social";
657657+ ]
658658+ in
659659+ let info = Cmd.info "remove" ~doc ~man in
660660+ let run handle () =
661661+ Eio_main.run @@ fun env ->
662662+ with_verse_config env @@ fun config ->
663663+ let fs = Eio.Stdenv.fs env in
664664+ match Monopam.Verse.remove ~fs ~config ~handle () with
665665+ | Ok () ->
666666+ Fmt.pr "Removed %s from workspace.@." handle;
667667+ `Ok ()
668668+ | Error e ->
669669+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
670670+ `Error (false, "remove failed")
671671+ in
672672+ Cmd.v info Term.(ret (const run $ verse_handle_pos_arg $ logging_term))
673673+674674+let verse_pull_cmd =
675675+ let doc = "Pull updates for tracked members" in
676676+ let man =
677677+ [
678678+ `S Manpage.s_description;
679679+ `P
680680+ "Fetches and merges git updates for tracked members' monorepos. \
681681+ This runs 'git pull' in each member's directory under verse/.";
682682+ `S "SCOPE";
683683+ `P "With a handle argument: pulls only that specific member.";
684684+ `P "Without arguments: pulls all tracked members in verse/.";
685685+ `S "TRACKED MEMBERS";
686686+ `P
687687+ "A member is 'tracked' if their directory exists under verse/. \
688688+ This happens after running 'monopam verse add <handle>'.";
689689+ `S "ERROR HANDLING";
690690+ `P
691691+ "If a pull fails for one member (e.g., merge conflict), the error \
692692+ is reported but other members are still pulled.";
693693+ `P
694694+ "Resolve conflicts manually in verse/<handle>/ and commit, or use \
695695+ 'git reset --hard origin/main' to discard local changes.";
696696+ `S Manpage.s_examples;
697697+ `Pre "# Pull all tracked members\n\
698698+ monopam verse pull\n\n\
699699+ # Pull a specific member\n\
700700+ monopam verse pull alice.bsky.social";
701701+ ]
702702+ in
703703+ let info = Cmd.info "pull" ~doc ~man in
704704+ let run handle () =
705705+ Eio_main.run @@ fun env ->
706706+ with_verse_config env @@ fun config ->
707707+ let fs = Eio.Stdenv.fs env in
708708+ let proc = Eio.Stdenv.process_mgr env in
709709+ match Monopam.Verse.pull ~proc ~fs ~config ?handle () with
710710+ | Ok () ->
711711+ Fmt.pr "Pull completed.@.";
712712+ `Ok ()
713713+ | Error e ->
714714+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
715715+ `Error (false, "pull failed")
716716+ in
717717+ Cmd.v info Term.(ret (const run $ verse_handle_opt_pos_arg $ logging_term))
718718+719719+let verse_sync_cmd =
720720+ let doc = "Sync the workspace" in
721721+ let man =
722722+ [
723723+ `S Manpage.s_description;
724724+ `P
725725+ "Synchronizes your entire opamverse workspace with the latest upstream \
726726+ changes. This is the command to run regularly to stay up to date.";
727727+ `S "WHAT IT DOES";
728728+ `P "The sync command performs two operations:";
729729+ `I ("1.", "Updates the registry: git pull in ~/.local/share/monopam/opamverse-registry/");
730730+ `I ("2.", "Pulls all tracked members: git pull in each verse/<handle>/");
731731+ `S "USE CASES";
732732+ `P "Run sync when you want to:";
733733+ `I ("-", "See if any new members have joined the community");
734734+ `I ("-", "Get the latest code from all tracked members");
735735+ `I ("-", "Catch up after being away for a while");
736736+ `S "COMPARISON WITH PULL";
737737+ `P
738738+ "'verse sync' updates the registry AND pulls members. \
739739+ 'verse pull' only pulls members (skips registry update).";
740740+ `S Manpage.s_examples;
741741+ `Pre "# Daily sync routine\n\
742742+ cd ~/tangled\n\
743743+ monopam verse sync\n\
744744+ monopam verse status";
745745+ ]
746746+ in
747747+ let info = Cmd.info "sync" ~doc ~man in
748748+ let run () =
749749+ Eio_main.run @@ fun env ->
750750+ with_verse_config env @@ fun config ->
751751+ let fs = Eio.Stdenv.fs env in
752752+ let proc = Eio.Stdenv.process_mgr env in
753753+ match Monopam.Verse.sync ~proc ~fs ~config () with
754754+ | Ok () ->
755755+ Fmt.pr "Sync completed.@.";
756756+ `Ok ()
757757+ | Error e ->
758758+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error e;
759759+ `Error (false, "sync failed")
760760+ in
761761+ Cmd.v info Term.(ret (const run $ logging_term))
762762+763763+let verse_cmd =
764764+ let doc = "Federated monorepo collaboration" in
765765+ let man =
766766+ [
767767+ `S Manpage.s_description;
768768+ `P
769769+ "The opamverse system enables federated collaboration across multiple \
770770+ developers' monorepos. Each developer maintains their own monorepo \
771771+ (managed by standard monopam commands), and can track other developers' \
772772+ monorepos for code browsing, learning, and collaboration.";
773773+ `P
774774+ "Members are identified by tangled handles - decentralized identities \
775775+ from the AT Protocol network (the same system used by Bluesky).";
776776+ `S "QUICK START FOR NEW USERS";
777777+ `P "Run these commands in order to get started:";
778778+ `Pre "# Step 1: Authenticate with tangled (one-time setup)\n\
779779+ tangled auth login\n\n\
780780+ # Step 2: Create and initialize your workspace\n\
781781+ mkdir ~/tangled && cd ~/tangled\n\
782782+ monopam verse init --handle yourname.bsky.social\n\n\
783783+ # Step 3: Browse available community members\n\
784784+ monopam verse members\n\n\
785785+ # Step 4: Add a member to track their monorepo\n\
786786+ monopam verse add alice.bsky.social\n\n\
787787+ # Step 5: Browse their code\n\
788788+ ls verse/alice.bsky.social/\n\
789789+ cd verse/alice.bsky.social && dune build\n\n\
790790+ # Step 6: Keep everything updated (run daily/weekly)\n\
791791+ monopam verse sync";
792792+ `S "KEY CONCEPTS";
793793+ `I ("Workspace", "A directory containing your monorepo plus tracked members' monorepos");
794794+ `I ("Registry", "A git repository listing community members and their monorepo URLs");
795795+ `I ("Handle", "A tangled identity like 'alice.bsky.social' validated via AT Protocol");
796796+ `I ("Tracking", "Cloning another member's monorepo to your verse/ directory");
797797+ `S "WORKSPACE STRUCTURE";
798798+ `P "An opamverse workspace has this layout:";
799799+ `Pre "~/tangled/ # workspace root\n\
800800+ ├── mono/ # YOUR monorepo (use monopam pull/push here)\n\
801801+ ├── src/ # YOUR fork checkouts\n\
802802+ └── verse/\n\
803803+ \ ├── alice.bsky.social/ # Alice's monorepo (read-only tracking)\n\
804804+ \ └── bob.example.com/ # Bob's monorepo (read-only tracking)";
805805+ `P "Configuration and data are stored in XDG directories:";
806806+ `Pre "~/.config/monopam/\n\
807807+ └── opamverse.toml # workspace configuration\n\n\
808808+ ~/.local/share/monopam/\n\
809809+ └── opamverse-registry/ # cloned registry git repo";
810810+ `S "COMMAND FLOW";
811811+ `P "The expected sequence of commands for typical workflows:";
812812+ `P "$(b,First-time setup) (once per machine):";
813813+ `Pre "tangled auth login # authenticate\n\
814814+ monopam verse init --handle you.bsky.social # create workspace";
815815+ `P "$(b,Adding members to track):";
816816+ `Pre "monopam verse members # list available members\n\
817817+ monopam verse add alice.bsky.social # clone their monorepo\n\
818818+ monopam verse status # verify it was added";
819819+ `P "$(b,Daily maintenance):";
820820+ `Pre "monopam verse sync # update everything\n\
821821+ monopam verse status # check for changes";
822822+ `P "$(b,Working in your own monorepo):";
823823+ `Pre "cd ~/tangled/mono\n\
824824+ monopam pull # fetch upstream changes\n\
825825+ # ... make edits ...\n\
826826+ monopam push # export to checkouts";
827827+ `S "INTEGRATION WITH MONOPAM";
828828+ `P
829829+ "The verse system complements standard monopam commands. Your mono/ \
830830+ directory works exactly like a normal monopam-managed monorepo:";
831831+ `Pre "# Work in your monorepo\n\
832832+ cd ~/tangled/mono\n\
833833+ monopam status\n\
834834+ monopam pull\n\
835835+ # ... make changes ...\n\
836836+ monopam push";
837837+ `P
838838+ "The verse/ directories are for reading and learning from others' code. \
839839+ You generally don't push to them (unless you're a collaborator).";
840840+ `S "REGISTRY FORMAT";
841841+ `P
842842+ "The registry is a git repository containing opamverse.toml:";
843843+ `Pre "[registry]\n\
844844+ name = \"tangled-community\"\n\n\
845845+ [[members]]\n\
846846+ handle = \"alice.bsky.social\"\n\
847847+ monorepo = \"https://github.com/alice/mono\"";
848848+ `P
849849+ "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
850850+ `S "COMMANDS REFERENCE";
851851+ `I ("init", "Create a new workspace with config and directories");
852852+ `I ("status", "Show tracked members and their git status");
853853+ `I ("members", "List all members in the registry");
854854+ `I ("add <handle>", "Clone a member's monorepo to verse/");
855855+ `I ("remove <handle>", "Delete a member's local clone");
856856+ `I ("pull [<handle>]", "Git pull tracked member(s)");
857857+ `I ("sync", "Update registry and pull all members");
858858+ `S "AUTHENTICATION";
859859+ `P
860860+ "Handle validation uses the AT Protocol identity system. The tangled \
861861+ CLI stores session credentials that monopam verse commands reuse.";
862862+ `P "If you see 'Not authenticated', run:";
863863+ `Pre "tangled auth login";
864864+ ]
865865+ in
866866+ let info = Cmd.info "verse" ~doc ~man in
867867+ Cmd.group info
868868+ [
869869+ verse_init_cmd;
870870+ verse_status_cmd;
871871+ verse_members_cmd;
872872+ verse_add_cmd;
873873+ verse_remove_cmd;
874874+ verse_pull_cmd;
875875+ verse_sync_cmd;
876876+ ]
877877+547878(* Main command group *)
548879549880let main_cmd =
···586917 "Review changes in src/*/, then git push each one" );
587918 `S "CONFIGURATION";
588919 `P
589589- "Run $(b,monopam init) to interactively create a configuration file. \
590590- Configuration is read from monopam.toml in the current directory or \
591591- XDG config locations.";
592592- `P "All paths in the configuration must be absolute. Example:";
920920+ "Run $(b,monopam verse init --handle <handle>) to create a workspace. \
921921+ Configuration is stored in ~/.config/monopam/opamverse.toml and \
922922+ all paths are derived from the workspace root.";
923923+ `P "Workspace structure:";
593924 `Pre
594594- "opam_repo = \"/home/user/opam-overlay\"\n\
595595- checkouts = \"/home/user/src\"\n\
596596- monorepo = \"/home/user/mono\"\n\
597597- default_branch = \"main\"";
925925+ "root/\n\
926926+ ├── mono/ # Your monorepo\n\
927927+ ├── src/ # Git checkouts\n\
928928+ ├── opam-repo/ # Opam overlay\n\
929929+ └── verse/ # Other members' monorepos";
598930 `S Manpage.s_commands;
599931 `P "Use $(b,monopam COMMAND --help) for help on a specific command.";
600932 ]
601933 in
602934 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in
603935 Cmd.group info
604604- [ init_cmd; status_cmd; pull_cmd; push_cmd; extract_cmd; add_cmd; remove_cmd; changes_cmd ]
936936+ [ status_cmd; pull_cmd; push_cmd; add_cmd; remove_cmd; changes_cmd; verse_cmd ]
605937606938let () = exit (Cmd.eval main_cmd)
···57575858let is_repo ~proc ~fs path =
5959 let cwd = path_to_eio ~fs path in
6060- let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in
6161- result.exit_code = 0
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 *)
62646365let is_dirty ~proc ~fs path =
6466 let cwd = path_to_eio ~fs path in
+108-123
monopam/lib/monopam.ml
···44module Git = Git
55module Status = Status
66module Changes = Changes
77+module Verse = Verse
88+module Verse_config = Verse_config
99+module Verse_registry = Verse_registry
710811let src = Logs.Src.create "monopam" ~doc:"Monopam operations"
912···1619 | Dirty_state of Package.t list
1720 | Package_not_found of string
1821 | Claude_error of string
1919- | Subdir_not_found of string
2020- | Checkout_exists of Fpath.t
21222223let pp_error ppf = function
2324 | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
···2930 pkgs
3031 | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name
3132 | Claude_error msg -> Fmt.pf ppf "Claude error: %s" msg
3232- | Subdir_not_found name -> Fmt.pf ppf "Subdirectory not found: %s" name
3333- | Checkout_exists path -> Fmt.pf ppf "Checkout already exists: %a" Fpath.pp path
34333534let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t =
3635 let dir, _ = fs in
···7776 ensure_checkouts_dir ~fs ~config;
7877 discover_packages ~fs:(fs :> _ Eio.Path.t) ~config ()
7978 |> Result.map (Status.compute_all ~proc ~fs ~config)
7979+8080+(** Find opam files in monorepo subtrees that aren't registered in the overlay.
8181+ Returns a list of (subtree_name, unregistered_package_name) pairs. *)
8282+let find_unregistered_opam_files ~fs ~config pkgs =
8383+ let fs = fs_typed fs in
8484+ let monorepo = Config.Paths.monorepo config in
8585+ (* Group registered packages by repo name *)
8686+ let registered_by_repo = Hashtbl.create 16 in
8787+ List.iter
8888+ (fun pkg ->
8989+ let repo = Package.repo_name pkg in
9090+ let name = Package.name pkg in
9191+ let existing = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
9292+ Hashtbl.replace registered_by_repo repo (name :: existing))
9393+ pkgs;
9494+ (* Get unique subtree directories *)
9595+ let seen_repos = Hashtbl.create 16 in
9696+ let repos =
9797+ List.filter
9898+ (fun pkg ->
9999+ let repo = Package.repo_name pkg in
100100+ if Hashtbl.mem seen_repos repo then false
101101+ else begin
102102+ Hashtbl.add seen_repos repo ();
103103+ true
104104+ end)
105105+ pkgs
106106+ in
107107+ (* For each subtree, find opam files not in the registry *)
108108+ List.concat_map
109109+ (fun pkg ->
110110+ let repo = Package.repo_name pkg in
111111+ let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in
112112+ let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in
113113+ let registered = try Hashtbl.find registered_by_repo repo with Not_found -> [] in
114114+ try
115115+ Eio.Path.read_dir eio_path
116116+ |> List.filter_map (fun name ->
117117+ if Filename.check_suffix name ".opam" then
118118+ let pkg_name = Filename.chop_suffix name ".opam" in
119119+ if List.mem pkg_name registered then None
120120+ else Some (repo, pkg_name)
121121+ else None)
122122+ with Eio.Io _ -> [])
123123+ repos
8012481125let get_branch ~config pkg =
82126 let default = Config.default_branch config in
···231275root.opam
232276|}
233277234234-(** Collect all external dependencies from packages.
278278+(** Collect all external dependencies by scanning monorepo subtree directories.
279279+ This scans all .opam files in each subtree directory to find dependencies,
280280+ ensuring we get dependencies from all packages in a directory, not just
281281+ those registered in the opam overlay.
235282 Returns a sorted, deduplicated list of package names that are dependencies
236283 but not packages in the repo itself. *)
237237-let collect_external_deps pkgs =
238238- let pkg_names = List.map Package.name pkgs in
284284+let collect_external_deps ~fs ~config pkgs =
285285+ let monorepo = Config.Paths.monorepo config in
286286+ (* Get unique repos to avoid scanning the same directory multiple times *)
287287+ let seen = Hashtbl.create 16 in
288288+ let repos =
289289+ List.filter
290290+ (fun pkg ->
291291+ let repo = Package.repo_name pkg in
292292+ if Hashtbl.mem seen repo then false
293293+ else begin
294294+ Hashtbl.add seen repo ();
295295+ true
296296+ end)
297297+ pkgs
298298+ in
299299+ (* Scan each subtree directory for .opam files and collect dependencies *)
239300 let all_deps =
240240- List.concat_map Package.depends pkgs
301301+ List.concat_map
302302+ (fun pkg ->
303303+ let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in
304304+ Opam_repo.scan_opam_files_for_deps ~fs subtree_dir)
305305+ repos
306306+ |> List.sort_uniq String.compare
307307+ in
308308+ (* Get all package names from all .opam files in monorepo *)
309309+ let pkg_names =
310310+ List.concat_map
311311+ (fun pkg ->
312312+ let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in
313313+ let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in
314314+ try
315315+ Eio.Path.read_dir eio_path
316316+ |> List.filter_map (fun name ->
317317+ if Filename.check_suffix name ".opam" then
318318+ Some (Filename.chop_suffix name ".opam")
319319+ else None)
320320+ with Eio.Io _ -> [])
321321+ repos
241322 |> List.sort_uniq String.compare
242323 in
243324 (* Filter out packages that are in the repo *)
···245326246327(** Generate dune-project content for the monorepo root.
247328 Lists all external dependencies as a virtual package. *)
248248-let generate_dune_project pkgs =
249249- let external_deps = collect_external_deps pkgs in
329329+let generate_dune_project ~fs ~config pkgs =
330330+ let external_deps = collect_external_deps ~fs ~config pkgs in
250331 let buf = Buffer.create 1024 in
251332 Buffer.add_string buf "(lang dune 3.20)\n";
252333 Buffer.add_string buf "(name root)\n";
···270351 let monorepo = Config.Paths.monorepo config in
271352 let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
272353 let dune_project_path = Eio.Path.(monorepo_eio / "dune-project") in
273273- let content = generate_dune_project pkgs in
354354+ let content = generate_dune_project ~fs ~config pkgs in
274355 (* Check if dune-project already exists with same content *)
275356 let needs_update =
276357 match Eio.Path.load dune_project_path with
···295376 ignore (Eio.Process.await child));
296377 Log.app (fun m ->
297378 m "Updated dune-project with %d external dependencies"
298298- (List.length (collect_external_deps pkgs)))
379379+ (List.length (collect_external_deps ~fs ~config pkgs)))
299380 end
300381301382let ensure_monorepo_initialized ~proc ~fs ~config =
···525606 | Ok ab -> ab.behind
526607 | Error _ -> 0
527608528528-let pull ~proc ~fs ~config ?package () =
609609+let pull ~proc ~fs ~config ?package ?opam_repo_url () =
529610 let fs_t = fs_typed fs in
530530- (* Update the opam repo first *)
611611+ (* Update the opam repo first - clone if needed *)
531612 let opam_repo = Config.Paths.opam_repo config in
532613 if Git.is_repo ~proc ~fs:fs_t opam_repo then begin
533614 Log.info (fun m -> m "Updating opam repo at %a" Fpath.pp opam_repo);
···540621 | Ok () -> ()
541622 | Error e ->
542623 Log.warn (fun m -> m "Failed to update opam repo: %a" Git.pp_error e)
624624+ end
625625+ else begin
626626+ (* Opam repo doesn't exist - clone it if we have a URL *)
627627+ match opam_repo_url with
628628+ | Some url ->
629629+ Log.info (fun m -> m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
630630+ let url = Uri.of_string url in
631631+ let branch = Config.default_branch config in
632632+ (match Git.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
633633+ | Ok () -> Log.info (fun m -> m "Opam repo cloned successfully")
634634+ | Error e -> Log.warn (fun m -> m "Failed to clone opam repo: %a" Git.pp_error e))
635635+ | None ->
636636+ Log.info (fun m -> m "Opam repo at %a does not exist and no URL provided" Fpath.pp opam_repo)
543637 end;
544638 (* Ensure directories exist before computing status *)
545639 ensure_checkouts_dir ~fs:fs_t ~config;
···835929 end
836930 else Ok ()
837931 end
838838- end
839839-840840-let create_opam_package ~fs ~config ~name ~repo_url =
841841- let opam_repo = Config.Paths.opam_repo config in
842842- let pkg_dir = Fpath.(opam_repo / "packages" / name / (name ^ ".dev")) in
843843- let opam_file = Fpath.(pkg_dir / "opam") in
844844- let content = Printf.sprintf {|opam-version: "2.0"
845845-name: "%s"
846846-version: "dev"
847847-synopsis: "TODO: Add synopsis"
848848-dev-repo: "git+%s"
849849-depends: [
850850- "dune" {>= "3.0"}
851851- "ocaml" {>= "4.14"}
852852-]
853853-build: [
854854- ["dune" "build" "-p" name "-j" jobs]
855855-]
856856-|} name repo_url in
857857- let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in
858858- mkdirs pkg_dir_eio;
859859- let opam_eio = Eio.Path.(fs / Fpath.to_string opam_file) in
860860- Eio.Path.save ~create:(`Or_truncate 0o644) opam_eio content;
861861- Log.app (fun m -> m "Created opam package at %a" Fpath.pp opam_file);
862862- Ok ()
863863-864864-let extract ~proc ~fs ~config ~subdir ~repo_url ?branch ?(push = false)
865865- ?(create_opam = false) () =
866866- let ( let* ) r f = Result.bind (Result.map_error (fun e -> Git_error e) r) f in
867867- let fs = fs_typed fs in
868868- let monorepo = Config.Paths.monorepo config in
869869- let checkouts_root = Config.Paths.checkouts config in
870870- let checkout_dir = Fpath.(checkouts_root / subdir) in
871871- let branch = Option.value branch ~default:(Config.default_branch config) in
872872-873873- (* Validate: subdir exists in monorepo *)
874874- if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subdir) then
875875- Error (Subdir_not_found subdir)
876876- else
877877- (* Validate: checkout doesn't already exist *)
878878- let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
879879- let checkout_exists =
880880- match Eio.Path.kind ~follow:true checkout_eio with
881881- | `Directory -> true | _ -> false | exception _ -> false
882882- in
883883- if checkout_exists then Error (Checkout_exists checkout_dir)
884884- else
885885- (* Validate: monorepo is clean *)
886886- if Git.is_dirty ~proc ~fs monorepo then
887887- Error (Git_error (Git.Dirty_worktree monorepo))
888888- else begin
889889- ensure_checkouts_dir ~fs ~config;
890890-891891- (* Step 1: Split the subtree history *)
892892- Log.info (fun m -> m "Splitting subtree history for %s" subdir);
893893- let* split_commit = Git.Subtree.split ~proc ~fs ~repo:monorepo ~prefix:subdir () in
894894- Log.info (fun m -> m "Split commit: %s" split_commit);
895895-896896- (* Step 2: Create new repo from split *)
897897- Log.info (fun m -> m "Creating checkout at %a" Fpath.pp checkout_dir);
898898- let* () = Git.init ~proc ~fs checkout_dir in
899899- let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
900900- let monorepo_path = Fpath.to_string monorepo in
901901-902902- (* Fetch split commit from monorepo *)
903903- let* _ = run_git_in ~proc ~cwd:checkout_eio
904904- [ "fetch"; monorepo_path; split_commit ] in
905905- let* _ = run_git_in ~proc ~cwd:checkout_eio
906906- [ "checkout"; "-b"; branch; "FETCH_HEAD" ] in
907907-908908- (* Step 3: Add origin remote *)
909909- Log.info (fun m -> m "Adding remote origin: %s" repo_url);
910910- let* _ = run_git_in ~proc ~cwd:checkout_eio
911911- [ "remote"; "add"; "origin"; repo_url ] in
912912-913913- (* Step 4: Optionally push *)
914914- let push_result =
915915- if push then begin
916916- Log.info (fun m -> m "Pushing to %s" repo_url);
917917- Git.push_remote ~proc ~fs ~branch checkout_dir
918918- |> Result.map_error (fun e -> Git_error e)
919919- end else Ok ()
920920- in
921921- match push_result with
922922- | Error e -> Error e
923923- | Ok () ->
924924-925925- (* Step 5: Optionally create opam metadata *)
926926- let create_opam_result =
927927- if create_opam then
928928- create_opam_package ~fs ~config ~name:subdir ~repo_url
929929- else Ok ()
930930- in
931931- match create_opam_result with
932932- | Error e -> Error e
933933- | Ok () ->
934934-935935- (* Print summary *)
936936- Log.app (fun m -> m "Extracted %s to %a" subdir Fpath.pp checkout_dir);
937937- Log.app (fun m -> m "");
938938- Log.app (fun m -> m "Next steps:");
939939- if not push then begin
940940- Log.app (fun m -> m " 1. Create the remote repository");
941941- Log.app (fun m -> m " 2. Push: cd %a && git push -u origin %s"
942942- Fpath.pp checkout_dir branch)
943943- end;
944944- if not create_opam then
945945- Log.app (fun m -> m " 3. Add opam package metadata to enable push/pull");
946946- Ok ()
947932 end
948933949934let add ~proc ~fs ~config ~package () =
+26-36
monopam/lib/monopam.mli
···2828module Git = Git
2929module Status = Status
3030module Changes = Changes
3131+module Verse = Verse
3232+module Verse_config = Verse_config
3333+module Verse_registry = Verse_registry
31343235(** {1 High-Level Operations} *)
3336···4043 (** Operation blocked due to dirty packages *)
4144 | Package_not_found of string (** Named package not found in opam repo *)
4245 | Claude_error of string (** Claude API or response parsing error *)
4343- | Subdir_not_found of string (** Subdirectory not found in monorepo *)
4444- | Checkout_exists of Fpath.t (** Checkout already exists at path *)
45464647val pp_error : error Fmt.t
4748(** [pp_error] formats errors. *)
···6869 fs:Eio.Fs.dir_ty Eio.Path.t ->
6970 config:Config.t ->
7071 ?package:string ->
7272+ ?opam_repo_url:string ->
7173 unit ->
7274 (unit, error) result
7373-(** [pull ~proc ~fs ~config ?package ()] pulls updates from remotes.
7575+(** [pull ~proc ~fs ~config ?package ?opam_repo_url ()] pulls updates from remotes.
74767577 For each package (or the specified package): 1. Clones or fetches the
7678 individual checkout 2. Adds or pulls the subtree in the monorepo
77798080+ If the opam-repo doesn't exist locally and [opam_repo_url] is provided,
8181+ clones it from that URL first.
8282+7883 Aborts if any checkout or the monorepo has uncommitted changes.
79848085 @param proc Eio process manager
8186 @param fs Eio filesystem
8287 @param config Monopam configuration
8383- @param package Optional specific package to pull *)
8888+ @param package Optional specific package to pull
8989+ @param opam_repo_url Optional URL to clone opam-repo from if it doesn't exist *)
84908591(** {2 Push} *)
8692···110116 @param package Optional specific package to push
111117 @param upstream If true, also push checkouts to their git remotes *)
112118113113-(** {2 Extract} *)
114114-115115-val extract :
116116- proc:_ Eio.Process.mgr ->
117117- fs:Eio.Fs.dir_ty Eio.Path.t ->
118118- config:Config.t ->
119119- subdir:string ->
120120- repo_url:string ->
121121- ?branch:string ->
122122- ?push:bool ->
123123- ?create_opam:bool ->
124124- unit ->
125125- (unit, error) result
126126-(** [extract ~proc ~fs ~config ~subdir ~repo_url ()] extracts a subdirectory
127127- from the monorepo as a standalone git repository with full history.
128128-129129- Enables the "develop in monorepo first, extract later" workflow.
130130-131131- The extraction process:
132132- 1. Runs git subtree split to extract commits affecting the subdirectory
133133- 2. Creates a new git repository in the checkouts directory
134134- 3. Configures the remote URL
135135-136136- @param proc Eio process manager
137137- @param fs Eio filesystem
138138- @param config Monopam configuration
139139- @param subdir Subdirectory in monorepo to extract
140140- @param repo_url Git URL for the new repository
141141- @param branch Branch name (default: from config)
142142- @param push If true, push to remote after extraction
143143- @param create_opam If true, create opam package metadata in overlay *)
144144-145119(** {2 Package Management} *)
146120147121val add :
···199173 @param fs Eio filesystem
200174 @param config Monopam configuration
201175 @param name Package name to find *)
176176+177177+val find_unregistered_opam_files :
178178+ fs:Eio.Fs.dir_ty Eio.Path.t ->
179179+ config:Config.t ->
180180+ Package.t list ->
181181+ (string * string) list
182182+(** [find_unregistered_opam_files ~fs ~config pkgs] finds opam files in monorepo
183183+ subtree directories that aren't registered in the opam overlay.
184184+185185+ Returns a list of [(repo_name, package_name)] pairs for each unregistered
186186+ .opam file found. This helps identify packages that exist in the source
187187+ repositories but aren't being tracked by the overlay.
188188+189189+ @param fs Eio filesystem
190190+ @param config Monopam configuration
191191+ @param pkgs List of packages discovered from the opam overlay *)
202192203193(** {1 Changelog Generation} *)
204194
+23
monopam/lib/opam_repo.ml
···162162let validate_repo ~fs repo_path =
163163 let _, errors = scan_all ~fs repo_path in
164164 errors
165165+166166+(** 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. *)
169169+let scan_opam_files_for_deps ~fs dir_path =
170170+ let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in
171171+ try
172172+ let files = Eio.Path.read_dir eio_path in
173173+ let opam_files =
174174+ List.filter (fun name -> Filename.check_suffix name ".opam") files
175175+ in
176176+ List.concat_map
177177+ (fun opam_file ->
178178+ let opam_path = Eio.Path.(eio_path / opam_file) in
179179+ try
180180+ let content = Eio.Path.load opam_path in
181181+ let opamfile =
182182+ OpamParser.FullPos.string content (Fpath.to_string dir_path ^ "/" ^ opam_file)
183183+ in
184184+ find_depends opamfile.file_contents
185185+ with _ -> [])
186186+ opam_files
187187+ with Eio.Io _ -> []
+11
monopam/lib/opam_repo.mli
···75757676 For example, "git+https://example.com/repo.git" becomes
7777 "https://example.com/repo.git". *)
7878+7979+val scan_opam_files_for_deps : fs:_ Eio.Path.t -> Fpath.t -> string list
8080+(** [scan_opam_files_for_deps ~fs dir_path] scans a directory for .opam files
8181+ and extracts all dependencies from them.
8282+8383+ This is used to find dependencies from monorepo subtree directories,
8484+ where multiple .opam files may exist that aren't in the opam overlay.
8585+8686+ @param fs Eio filesystem capability
8787+ @param dir_path Path to the directory to scan
8888+ @return List of dependency package names *)
+393
monopam/lib/verse.ml
···11+type error =
22+ | Config_error of string
33+ | Git_error of Git.error
44+ | Registry_error of string
55+ | Handle_not_found of string
66+ | Not_authenticated
77+ | Member_not_found of string
88+ | Workspace_exists of Fpath.t
99+ | Not_a_workspace of Fpath.t
1010+1111+let pp_error ppf = function
1212+ | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
1313+ | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e
1414+ | Registry_error msg -> Fmt.pf ppf "Registry error: %s" msg
1515+ | Handle_not_found h -> Fmt.pf ppf "Handle not found: %s" h
1616+ | Not_authenticated ->
1717+ Fmt.pf ppf "Not authenticated. Run 'tangled auth login' first."
1818+ | Member_not_found h -> Fmt.pf ppf "Member not in registry: %s" h
1919+ | Workspace_exists p -> Fmt.pf ppf "Workspace already exists: %a" Fpath.pp p
2020+ | Not_a_workspace p -> Fmt.pf ppf "Not a opamverse workspace: %a" Fpath.pp p
2121+2222+type member_status = {
2323+ handle : string;
2424+ monorepo_url : string;
2525+ local_path : Fpath.t;
2626+ cloned : bool;
2727+ clean : bool option;
2828+ ahead_behind : Git.ahead_behind option;
2929+}
3030+3131+type status = {
3232+ config : Verse_config.t;
3333+ registry : Verse_registry.t;
3434+ tracked_members : member_status list;
3535+}
3636+3737+let pp_member_status ppf m =
3838+ let status =
3939+ if not m.cloned then "not cloned"
4040+ else
4141+ match (m.clean, m.ahead_behind) with
4242+ | Some false, _ -> "dirty"
4343+ | Some true, Some ab when ab.ahead > 0 || ab.behind > 0 ->
4444+ Fmt.str "ahead %d, behind %d" ab.ahead ab.behind
4545+ | Some true, _ -> "clean"
4646+ | None, _ -> "unknown"
4747+ in
4848+ Fmt.pf ppf "@[<hov 2>%s@ (%s)@ [%s]@]" m.handle m.monorepo_url status
4949+5050+let pp_status ppf s =
5151+ Fmt.pf ppf "@[<v>Workspace: %a@,Registry: %s@,Members:@, @[<v>%a@]@]"
5252+ Fpath.pp (Verse_config.root s.config)
5353+ s.registry.name
5454+ Fmt.(list ~sep:cut pp_member_status)
5555+ s.tracked_members
5656+5757+(* Helper to validate handle via tangled API.
5858+ We reuse the tangled CLI's session credentials - user must run 'tangled auth login' first. *)
5959+let validate_handle ~sw ~env handle =
6060+ try
6161+ (* Use app_name:"tangled" to reuse tangled CLI's session without polluting monopam's directory *)
6262+ let api =
6363+ Tangled.Api.create ~sw ~env ~app_name:"tangled" ~pds:"https://bsky.social" ()
6464+ in
6565+ (* Try to load existing session from tangled CLI *)
6666+ let session = Xrpc_auth.Session.load env#fs ~app_name:"tangled" () in
6767+ match session with
6868+ | None -> Error Not_authenticated
6969+ | Some session -> (
7070+ Tangled.Api.resume api ~session;
7171+ try
7272+ let _did = Tangled.Api.resolve_handle api handle in
7373+ Ok ()
7474+ with Eio.Io (Xrpc.Error.E _, _) -> Error (Handle_not_found handle))
7575+ with Eio.Io (Xrpc.Error.E _, _) -> Error Not_authenticated
7676+7777+(* Helper to check if a path is a directory *)
7878+let is_directory ~fs path =
7979+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
8080+ match Eio.Path.kind ~follow:true eio_path with
8181+ | `Directory -> true
8282+ | _ -> false
8383+ | exception _ -> false
8484+8585+(* Helper to check if a path is a regular file *)
8686+let is_file ~fs path =
8787+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
8888+ match Eio.Path.kind ~follow:true eio_path with
8989+ | `Regular_file -> true
9090+ | _ -> false
9191+ | exception _ -> false
9292+9393+(* Helper to create a directory if it doesn't exist *)
9494+let ensure_dir ~fs path =
9595+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
9696+ try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ()
9797+9898+(* Helper to recursively delete a directory *)
9999+let rec rm_rf ~fs path =
100100+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
101101+ match Eio.Path.kind ~follow:false eio_path with
102102+ | `Directory ->
103103+ (* List and delete contents first *)
104104+ let entries = Eio.Path.read_dir eio_path in
105105+ List.iter (fun name -> rm_rf ~fs Fpath.(path / name)) entries;
106106+ Eio.Path.rmdir eio_path
107107+ | `Regular_file | `Symbolic_link | `Block_device | `Character_special
108108+ | `Fifo | `Socket | `Unknown ->
109109+ Eio.Path.unlink eio_path
110110+ | `Not_found -> ()
111111+ | exception _ -> ()
112112+113113+(* Get list of tracked members by looking at verse/ directory *)
114114+let get_tracked_handles ~fs config =
115115+ let verse_path = Verse_config.verse_path config in
116116+ if not (is_directory ~fs verse_path) then []
117117+ else
118118+ let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in
119119+ try
120120+ Eio.Path.read_dir eio_path
121121+ |> List.filter (fun name ->
122122+ is_directory ~fs Fpath.(verse_path / name))
123123+ with Eio.Io _ -> []
124124+125125+let init ~proc ~fs ~sw ~env ~root ~handle () =
126126+ (* Check if config already exists in XDG *)
127127+ let config_file = Verse_config.config_file () in
128128+ Logs.info (fun m -> m "Config file: %a" Fpath.pp config_file);
129129+ if is_file ~fs config_file then begin
130130+ Logs.err (fun m -> m "Config already exists at %a" Fpath.pp config_file);
131131+ Error (Workspace_exists root)
132132+ end
133133+ else
134134+ (* Resolve root to absolute path *)
135135+ let root =
136136+ if Fpath.is_abs root then root
137137+ else
138138+ (* Get absolute path via realpath *)
139139+ let root_str = Fpath.to_string root in
140140+ let eio_path = Eio.Path.(fs / root_str) in
141141+ (* Ensure the directory exists first so realpath works *)
142142+ (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ());
143143+ match Unix.realpath root_str with
144144+ | abs_str -> (match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
145145+ | exception _ -> root
146146+ in
147147+ Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root);
148148+ (* Validate handle *)
149149+ Logs.info (fun m -> m "Validating handle: %s" handle);
150150+ match validate_handle ~sw ~env handle with
151151+ | Error e ->
152152+ Logs.err (fun m -> m "Handle validation failed");
153153+ Error e
154154+ | Ok () ->
155155+ Logs.info (fun m -> m "Handle validated successfully");
156156+ (* Create config - need this temporarily to get paths *)
157157+ let config = Verse_config.create ~root ~handle () in
158158+ (* Clone registry first to look up user's repos *)
159159+ Logs.info (fun m -> m "Cloning registry...");
160160+ match Verse_registry.clone_or_pull ~proc ~fs ~config () with
161161+ | Error msg ->
162162+ Logs.err (fun m -> m "Registry clone failed: %s" msg);
163163+ Error (Registry_error msg)
164164+ | Ok registry ->
165165+ Logs.info (fun m -> m "Registry loaded");
166166+ (* Look up user in registry *)
167167+ match Verse_registry.find_member registry ~handle with
168168+ | None ->
169169+ Logs.err (fun m -> m "Handle %s not found in registry" handle);
170170+ Error (Member_not_found handle)
171171+ | Some member ->
172172+ Logs.info (fun m -> m "Found member: mono=%s opam=%s" member.monorepo 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 Git.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch mono_path with
183183+ | Error e ->
184184+ Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e);
185185+ Error (Git_error e)
186186+ | Ok () ->
187187+ Logs.info (fun m -> m "Monorepo cloned");
188188+ (* Clone user's opam repo *)
189189+ let opam_path = Verse_config.opam_repo_path config in
190190+ Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path);
191191+ let opam_url = Uri.of_string member.opamrepo in
192192+ (match Git.clone ~proc ~fs ~url:opam_url ~branch:Verse_config.default_branch opam_path with
193193+ | Error e ->
194194+ Logs.err (fun m -> m "Opam repo clone failed: %a" Git.pp_error e);
195195+ Error (Git_error e)
196196+ | Ok () ->
197197+ Logs.info (fun m -> m "Opam repo cloned");
198198+ (* Save config to XDG *)
199199+ Logs.info (fun m -> m "Saving config to %a" Fpath.pp config_file);
200200+ (match Verse_config.save ~fs config with
201201+ | Error msg ->
202202+ Logs.err (fun m -> m "Failed to save config: %s" msg);
203203+ Error (Config_error msg)
204204+ | Ok () ->
205205+ Logs.info (fun m -> m "Workspace initialized successfully");
206206+ Ok ())))
207207+208208+let status ~proc ~fs ~config () =
209209+ (* Load registry *)
210210+ match Verse_registry.clone_or_pull ~proc ~fs ~config () with
211211+ | Error msg -> Error (Registry_error msg)
212212+ | Ok registry ->
213213+ (* Get tracked handles *)
214214+ let tracked_handles = get_tracked_handles ~fs config in
215215+ (* Build status for each tracked member *)
216216+ let tracked_members =
217217+ List.filter_map
218218+ (fun handle ->
219219+ (* Find member in registry *)
220220+ match Verse_registry.find_member registry ~handle with
221221+ | None ->
222222+ (* Member not in registry but locally tracked - show anyway *)
223223+ let local_path = Fpath.(Verse_config.verse_path config / handle) in
224224+ let cloned = is_directory ~fs local_path in
225225+ Some
226226+ {
227227+ handle;
228228+ monorepo_url = "(not in registry)";
229229+ local_path;
230230+ cloned;
231231+ clean = None;
232232+ ahead_behind = None;
233233+ }
234234+ | Some member ->
235235+ let local_path =
236236+ Fpath.(Verse_config.verse_path config / handle)
237237+ in
238238+ let cloned = Git.is_repo ~proc ~fs local_path in
239239+ let clean =
240240+ if cloned then Some (not (Git.is_dirty ~proc ~fs local_path))
241241+ else None
242242+ in
243243+ let ahead_behind =
244244+ if cloned then
245245+ match Git.ahead_behind ~proc ~fs local_path with
246246+ | Ok ab -> Some ab
247247+ | Error _ -> None
248248+ else None
249249+ in
250250+ Some
251251+ {
252252+ handle;
253253+ monorepo_url = member.monorepo;
254254+ local_path;
255255+ cloned;
256256+ clean;
257257+ ahead_behind;
258258+ })
259259+ tracked_handles
260260+ in
261261+ Ok { config; registry; tracked_members }
262262+263263+let members ~proc ~fs ~config () =
264264+ match Verse_registry.clone_or_pull ~proc ~fs ~config () with
265265+ | Error msg -> Error (Registry_error msg)
266266+ | Ok registry -> Ok registry.members
267267+268268+let add ~proc ~fs ~sw ~env ~config ~handle () =
269269+ Logs.info (fun m -> m "Adding member: %s" handle);
270270+ (* Validate handle *)
271271+ match validate_handle ~sw ~env handle with
272272+ | Error e -> Error e
273273+ | Ok () -> (
274274+ (* Load registry *)
275275+ match Verse_registry.clone_or_pull ~proc ~fs ~config () with
276276+ | Error msg -> Error (Registry_error msg)
277277+ | Ok registry -> (
278278+ (* Find member *)
279279+ match Verse_registry.find_member registry ~handle with
280280+ | None -> Error (Member_not_found handle)
281281+ | Some member ->
282282+ (* Ensure verse directory exists *)
283283+ let verse_dir = Verse_config.verse_path config in
284284+ Logs.info (fun m -> m "Verse directory: %a" Fpath.pp verse_dir);
285285+ ensure_dir ~fs verse_dir;
286286+ let local_path = Fpath.(verse_dir / handle) in
287287+ Logs.info (fun m -> m "Clone target: %a" Fpath.pp local_path);
288288+ (* Check if already cloned *)
289289+ if Git.is_repo ~proc ~fs local_path then begin
290290+ Logs.info (fun m -> m "Already cloned");
291291+ Ok ()
292292+ end
293293+ else begin
294294+ (* Clone the monorepo *)
295295+ let url = Uri.of_string member.monorepo in
296296+ Logs.info (fun m -> m "Cloning from %s" member.monorepo);
297297+ match Git.clone ~proc ~fs ~url ~branch:Verse_config.default_branch local_path with
298298+ | Error e -> Error (Git_error e)
299299+ | Ok () ->
300300+ Logs.info (fun m -> m "Clone succeeded");
301301+ Ok ()
302302+ end))
303303+304304+let remove ~fs ~config ~handle () =
305305+ let local_path = Fpath.(Verse_config.verse_path config / handle) in
306306+ if not (is_directory ~fs local_path) then
307307+ Error (Member_not_found handle)
308308+ else begin
309309+ rm_rf ~fs local_path;
310310+ Ok ()
311311+ end
312312+313313+let add_all ~proc ~fs ~sw ~env ~config () =
314314+ Logs.info (fun m -> m "Adding all registry members");
315315+ (* Load registry *)
316316+ match Verse_registry.clone_or_pull ~proc ~fs ~config () with
317317+ | Error msg -> Error (Registry_error msg)
318318+ | Ok registry ->
319319+ (* Get already tracked handles to skip them *)
320320+ let tracked = get_tracked_handles ~fs config in
321321+ let tracked_set = Hashtbl.create (List.length tracked) in
322322+ List.iter (fun h -> Hashtbl.add tracked_set h ()) tracked;
323323+ (* Ensure verse directory exists *)
324324+ let verse_dir = Verse_config.verse_path config in
325325+ ensure_dir ~fs verse_dir;
326326+ (* Add each member that isn't already tracked *)
327327+ let added = ref [] in
328328+ let errors =
329329+ List.filter_map
330330+ (fun (member : Verse_registry.member) ->
331331+ let handle = member.handle in
332332+ if Hashtbl.mem tracked_set handle then begin
333333+ Logs.info (fun m -> m "Skipping %s (already tracked)" handle);
334334+ None
335335+ end
336336+ else begin
337337+ (* Validate handle *)
338338+ match validate_handle ~sw ~env handle with
339339+ | Error e ->
340340+ Logs.warn (fun m -> m "Skipping %s: %a" handle pp_error e);
341341+ Some (Fmt.str "%s: %a" handle pp_error e)
342342+ | Ok () ->
343343+ let local_path = Fpath.(verse_dir / handle) in
344344+ let url = Uri.of_string member.monorepo in
345345+ Logs.info (fun m -> m "Cloning %s from %s" handle member.monorepo);
346346+ match Git.clone ~proc ~fs ~url ~branch:Verse_config.default_branch local_path with
347347+ | Error e ->
348348+ Logs.warn (fun m -> m "Failed to clone %s: %a" handle Git.pp_error e);
349349+ Some (Fmt.str "%s: %a" handle Git.pp_error e)
350350+ | Ok () ->
351351+ Logs.info (fun m -> m "Cloned %s" handle);
352352+ added := member :: !added;
353353+ None
354354+ end)
355355+ registry.members
356356+ in
357357+ if errors = [] then Ok (List.rev !added)
358358+ else Error (Git_error (Git.Io_error (String.concat "; " errors)))
359359+360360+let pull ~proc ~fs ~config ?handle () =
361361+ match handle with
362362+ | Some h ->
363363+ let local_path = Fpath.(Verse_config.verse_path config / h) in
364364+ if not (Git.is_repo ~proc ~fs local_path) then
365365+ Error (Member_not_found h)
366366+ else
367367+ (match Git.pull ~proc ~fs local_path with
368368+ | Error e -> Error (Git_error e)
369369+ | Ok () -> Ok ())
370370+ | None ->
371371+ (* Pull all tracked members *)
372372+ let tracked_handles = get_tracked_handles ~fs config in
373373+ let errors =
374374+ List.filter_map
375375+ (fun h ->
376376+ let local_path = Fpath.(Verse_config.verse_path config / h) in
377377+ if Git.is_repo ~proc ~fs local_path then
378378+ match Git.pull ~proc ~fs local_path with
379379+ | Error e -> Some (Fmt.str "%s: %a" h Git.pp_error e)
380380+ | Ok () -> None
381381+ else None)
382382+ tracked_handles
383383+ in
384384+ if errors = [] then Ok ()
385385+ else Error (Git_error (Git.Io_error (String.concat "; " errors)))
386386+387387+let sync ~proc ~fs ~config () =
388388+ (* Update registry *)
389389+ match Verse_registry.clone_or_pull ~proc ~fs ~config () with
390390+ | Error msg -> Error (Registry_error msg)
391391+ | Ok _registry ->
392392+ (* Pull all tracked members *)
393393+ pull ~proc ~fs ~config ()
+160
monopam/lib/verse.mli
···11+(** Monoverse operations.
22+33+ Federated monorepo collaboration. Members are identified by tangled handles
44+ with strict validation via the AT Protocol identity system. *)
55+66+(** {1 Error Types} *)
77+88+type error =
99+ | Config_error of string (** Configuration loading/saving error *)
1010+ | Git_error of Git.error (** Git operation failed *)
1111+ | Registry_error of string (** Registry clone/pull/parse error *)
1212+ | Handle_not_found of string (** Handle could not be resolved *)
1313+ | Not_authenticated (** Tangled login required *)
1414+ | Member_not_found of string (** Handle not in registry *)
1515+ | Workspace_exists of Fpath.t (** Workspace already initialized *)
1616+ | Not_a_workspace of Fpath.t (** Not a opamverse workspace *)
1717+1818+val pp_error : error Fmt.t
1919+(** [pp_error] formats errors. *)
2020+2121+(** {1 Status Types} *)
2222+2323+type member_status = {
2424+ handle : string; (** Member's tangled handle *)
2525+ monorepo_url : string; (** Git URL of member's monorepo *)
2626+ local_path : Fpath.t; (** Local path under verse/ *)
2727+ cloned : bool; (** Whether the monorepo is cloned locally *)
2828+ clean : bool option; (** Whether the clone is clean (None if not cloned) *)
2929+ ahead_behind : Git.ahead_behind option; (** Ahead/behind status (None if not cloned) *)
3030+}
3131+(** Status of a member's monorepo in the workspace. *)
3232+3333+type status = {
3434+ config : Verse_config.t; (** Workspace configuration *)
3535+ registry : Verse_registry.t; (** Registry contents *)
3636+ tracked_members : member_status list; (** Status of tracked members *)
3737+}
3838+(** Workspace status. *)
3939+4040+val pp_member_status : member_status Fmt.t
4141+(** [pp_member_status] formats a member's status. *)
4242+4343+val pp_status : status Fmt.t
4444+(** [pp_status] formats workspace status. *)
4545+4646+(** {1 Operations} *)
4747+4848+val init :
4949+ proc:_ Eio.Process.mgr ->
5050+ fs:Eio.Fs.dir_ty Eio.Path.t ->
5151+ sw:Eio.Switch.t ->
5252+ env:< clock : _ Eio.Time.clock ; net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; .. > ->
5353+ root:Fpath.t ->
5454+ handle:string ->
5555+ unit ->
5656+ (unit, error) result
5757+(** [init ~proc ~fs ~sw ~env ~root ~handle ()] initializes a new opamverse workspace.
5858+5959+ Creates the workspace structure:
6060+ - [root/.opamverse/config.toml]
6161+ - [root/.opamverse/registry/] (cloned registry)
6262+ - [root/mono/] (user's monorepo)
6363+ - [root/src/] (source checkouts)
6464+ - [root/verse/] (other users' monorepos)
6565+6666+ The handle is validated against the tangled network (requires prior login).
6767+6868+ @param proc Eio process manager
6969+ @param fs Eio filesystem
7070+ @param sw Eio switch
7171+ @param env Eio environment for tangled API
7272+ @param root Workspace root (must be absolute)
7373+ @param handle User's tangled handle *)
7474+7575+val status :
7676+ proc:_ Eio.Process.mgr ->
7777+ fs:Eio.Fs.dir_ty Eio.Path.t ->
7878+ config:Verse_config.t ->
7979+ unit ->
8080+ (status, error) result
8181+(** [status ~proc ~fs ~config ()] returns the workspace status.
8282+8383+ Shows which members are tracked and the state of their local clones. *)
8484+8585+val members :
8686+ proc:_ Eio.Process.mgr ->
8787+ fs:Eio.Fs.dir_ty Eio.Path.t ->
8888+ config:Verse_config.t ->
8989+ unit ->
9090+ (Verse_registry.member list, error) result
9191+(** [members ~proc ~fs ~config ()] returns all registry members.
9292+9393+ Pulls the latest registry before returning the member list. *)
9494+9595+val add :
9696+ proc:_ Eio.Process.mgr ->
9797+ fs:Eio.Fs.dir_ty Eio.Path.t ->
9898+ sw:Eio.Switch.t ->
9999+ env:< clock : _ Eio.Time.clock ; net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; .. > ->
100100+ config:Verse_config.t ->
101101+ handle:string ->
102102+ unit ->
103103+ (unit, error) result
104104+(** [add ~proc ~fs ~sw ~env ~config ~handle ()] adds a member to the workspace.
105105+106106+ Validates the handle against tangled, looks up the monorepo URL from the
107107+ registry, and clones it to [verse/<handle>/].
108108+109109+ @param handle Tangled handle of the member to add *)
110110+111111+val add_all :
112112+ proc:_ Eio.Process.mgr ->
113113+ fs:Eio.Fs.dir_ty Eio.Path.t ->
114114+ sw:Eio.Switch.t ->
115115+ env:< clock : _ Eio.Time.clock ; net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; .. > ->
116116+ config:Verse_config.t ->
117117+ unit ->
118118+ (Verse_registry.member list, error) result
119119+(** [add_all ~proc ~fs ~sw ~env ~config ()] adds all registry members to the workspace.
120120+121121+ Iterates over all members in the registry and clones their monorepos to
122122+ [verse/<handle>/]. Members already tracked are skipped.
123123+124124+ Returns the list of members that were added. *)
125125+126126+val remove :
127127+ fs:Eio.Fs.dir_ty Eio.Path.t ->
128128+ config:Verse_config.t ->
129129+ handle:string ->
130130+ unit ->
131131+ (unit, error) result
132132+(** [remove ~fs ~config ~handle ()] removes a member from the workspace.
133133+134134+ Deletes the member's monorepo clone from [verse/<handle>/].
135135+136136+ @param handle Tangled handle of the member to remove *)
137137+138138+val pull :
139139+ proc:_ Eio.Process.mgr ->
140140+ fs:Eio.Fs.dir_ty Eio.Path.t ->
141141+ config:Verse_config.t ->
142142+ ?handle:string ->
143143+ unit ->
144144+ (unit, error) result
145145+(** [pull ~proc ~fs ~config ?handle ()] pulls updates for members.
146146+147147+ If [handle] is specified, only pulls that member. Otherwise pulls all
148148+ tracked members.
149149+150150+ @param handle Optional specific member to pull *)
151151+152152+val sync :
153153+ proc:_ Eio.Process.mgr ->
154154+ fs:Eio.Fs.dir_ty Eio.Path.t ->
155155+ config:Verse_config.t ->
156156+ unit ->
157157+ (unit, error) result
158158+(** [sync ~proc ~fs ~config ()] syncs the workspace.
159159+160160+ Updates the registry and pulls updates for all tracked members. *)
+119
monopam/lib/verse_config.ml
···11+let app_name = "monopam"
22+33+(* Simplified config: just root and handle. Paths are hardcoded. *)
44+type t = {
55+ root : Fpath.t;
66+ handle : string;
77+}
88+99+let root t = t.root
1010+let handle t = t.handle
1111+1212+(* Hardcoded paths derived from root *)
1313+let default_branch = "main"
1414+let mono_path t = Fpath.(t.root / "mono")
1515+let src_path t = Fpath.(t.root / "src")
1616+let opam_repo_path t = Fpath.(t.root / "opam-repo")
1717+let verse_path t = Fpath.(t.root / "verse")
1818+1919+(* Compute XDG directories following XDG Base Directory Specification *)
2020+let xdg_config_home () =
2121+ match Sys.getenv_opt "XDG_CONFIG_HOME" with
2222+ | Some dir when dir <> "" -> Fpath.v dir
2323+ | _ ->
2424+ match Sys.getenv_opt "HOME" with
2525+ | Some home -> Fpath.(v home / ".config")
2626+ | None -> Fpath.v "/tmp"
2727+2828+let xdg_data_home () =
2929+ match Sys.getenv_opt "XDG_DATA_HOME" with
3030+ | Some dir when dir <> "" -> Fpath.v dir
3131+ | _ ->
3232+ match Sys.getenv_opt "HOME" with
3333+ | Some home -> Fpath.(v home / ".local" / "share")
3434+ | None -> Fpath.v "/tmp"
3535+3636+let config_dir () = Fpath.(xdg_config_home () / app_name)
3737+let data_dir () = Fpath.(xdg_data_home () / app_name)
3838+let config_file () = Fpath.(config_dir () / "opamverse.toml")
3939+let registry_path () = Fpath.(data_dir () / "opamverse-registry")
4040+4141+let create ~root ~handle () = { root; handle }
4242+4343+let expand_tilde s =
4444+ if String.length s > 0 && s.[0] = '~' then
4545+ match Sys.getenv_opt "HOME" with
4646+ | Some home ->
4747+ if String.length s = 1 then home
4848+ else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1)
4949+ else s
5050+ | None -> s
5151+ else s
5252+5353+let fpath_codec : Fpath.t Tomlt.t =
5454+ Tomlt.map
5555+ ~dec:(fun s ->
5656+ let s = expand_tilde s in
5757+ match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m)
5858+ ~enc:Fpath.to_string Tomlt.string
5959+6060+(* Simplified TOML structure:
6161+ [workspace]
6262+ root = "~/tangled"
6363+6464+ [identity]
6565+ handle = "anil.recoil.org"
6666+*)
6767+6868+type workspace_section = { w_root : Fpath.t }
6969+type identity_section = { i_handle : string }
7070+7171+let workspace_codec : workspace_section Tomlt.t =
7272+ Tomlt.(
7373+ Table.(
7474+ obj (fun w_root -> { w_root })
7575+ |> mem "root" fpath_codec ~enc:(fun w -> w.w_root)
7676+ |> finish))
7777+7878+let identity_codec : identity_section Tomlt.t =
7979+ Tomlt.(
8080+ Table.(
8181+ obj (fun i_handle -> { i_handle })
8282+ |> mem "handle" string ~enc:(fun i -> i.i_handle)
8383+ |> finish))
8484+8585+let codec : t Tomlt.t =
8686+ Tomlt.(
8787+ Table.(
8888+ obj (fun workspace identity ->
8989+ { root = workspace.w_root; handle = identity.i_handle })
9090+ |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root })
9191+ |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle })
9292+ |> finish))
9393+9494+let load ~fs () =
9595+ let path = config_file () in
9696+ let path_str = Fpath.to_string path in
9797+ try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str)
9898+ with
9999+ | Eio.Io _ as e -> Error (Printexc.to_string e)
100100+ | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
101101+102102+let save ~fs t =
103103+ let dir = config_dir () in
104104+ let path = config_file () in
105105+ try
106106+ (* Ensure XDG config directory exists *)
107107+ let dir_path = Eio.Path.(fs / Fpath.to_string dir) in
108108+ (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ());
109109+ Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path);
110110+ Ok ()
111111+ with Eio.Io _ as e -> Error (Printexc.to_string e)
112112+113113+let pp ppf t =
114114+ Fmt.pf ppf
115115+ "@[<v>workspace:@,\
116116+ \ root: %a@,\
117117+ identity:@,\
118118+ \ handle: %s@]"
119119+ Fpath.pp t.root t.handle
+83
monopam/lib/verse_config.mli
···11+(** Opamverse workspace configuration.
22+33+ Configuration is stored in the XDG config directory at
44+ [~/.config/monopam/opamverse.toml].
55+66+ The config stores just the workspace root and user's handle.
77+ All paths are derived from the root:
88+ - [mono/] - user's monorepo
99+ - [src/] - git checkouts for subtrees
1010+ - [opam-repo/] - opam overlay repository
1111+ - [verse/] - other members' monorepos *)
1212+1313+(** {1 Types} *)
1414+1515+type t
1616+(** Opamverse workspace configuration. *)
1717+1818+(** {1 Accessors} *)
1919+2020+val root : t -> Fpath.t
2121+(** [root t] returns the workspace root directory. *)
2222+2323+val handle : t -> string
2424+(** [handle t] returns the user's tangled handle. *)
2525+2626+(** {1 Derived Paths} *)
2727+2828+val default_branch : string
2929+(** Default git branch, always ["main"]. *)
3030+3131+val mono_path : t -> Fpath.t
3232+(** [mono_path t] returns the path to the user's monorepo ([root/mono/]). *)
3333+3434+val src_path : t -> Fpath.t
3535+(** [src_path t] returns the path to git checkouts ([root/src/]). *)
3636+3737+val opam_repo_path : t -> Fpath.t
3838+(** [opam_repo_path t] returns the path to the opam overlay ([root/opam-repo/]). *)
3939+4040+val verse_path : t -> Fpath.t
4141+(** [verse_path t] returns the path to tracked members' monorepos ([root/verse/]). *)
4242+4343+(** {1 XDG Paths} *)
4444+4545+val config_dir : unit -> Fpath.t
4646+(** [config_dir ()] returns the XDG config directory for monopam
4747+ (~/.config/monopam). *)
4848+4949+val data_dir : unit -> Fpath.t
5050+(** [data_dir ()] returns the XDG data directory for monopam
5151+ (~/.local/share/monopam). *)
5252+5353+val config_file : unit -> Fpath.t
5454+(** [config_file ()] returns the path to the opamverse config file
5555+ (~/.config/monopam/opamverse.toml). *)
5656+5757+val registry_path : unit -> Fpath.t
5858+(** [registry_path ()] returns the path to the cloned registry git repo
5959+ (~/.local/share/monopam/opamverse-registry). *)
6060+6161+(** {1 Loading and Saving} *)
6262+6363+val load : fs:Eio.Fs.dir_ty Eio.Path.t -> unit -> (t, string) result
6464+(** [load ~fs ()] loads the workspace configuration from the XDG config file.
6565+6666+ @param fs Eio filesystem *)
6767+6868+val save : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> (unit, string) result
6969+(** [save ~fs config] saves the configuration to the XDG config file.
7070+7171+ @param fs Eio filesystem
7272+ @param config Configuration to save *)
7373+7474+val create : root:Fpath.t -> handle:string -> unit -> t
7575+(** [create ~root ~handle ()] creates a new configuration.
7676+7777+ @param root Workspace root directory (absolute path)
7878+ @param handle User's tangled handle *)
7979+8080+(** {1 Pretty Printing} *)
8181+8282+val pp : t Fmt.t
8383+(** [pp] formats a workspace configuration. *)
+133
monopam/lib/verse_registry.ml
···11+type member = { handle : string; monorepo : string; opamrepo : string }
22+type t = { name : string; members : member list }
33+44+let default_url = "https://tangled.org/eeg.cl.cam.ac.uk/opamverse"
55+66+let pp_member ppf m =
77+ Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle m.monorepo m.opamrepo
88+99+let pp ppf t =
1010+ Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]"
1111+ t.name Fmt.(list ~sep:cut pp_member) t.members
1212+1313+(* TOML structure:
1414+ [registry]
1515+ name = "tangled-community"
1616+1717+ [[members]]
1818+ handle = "alice.bsky.social"
1919+ monorepo = "https://github.com/alice/mono"
2020+ opamrepo = "https://github.com/alice/opam-repo"
2121+*)
2222+2323+let member_codec : member Tomlt.t =
2424+ Tomlt.(
2525+ Table.(
2626+ obj (fun handle monorepo opamrepo -> { handle; monorepo; opamrepo })
2727+ |> mem "handle" string ~enc:(fun m -> m.handle)
2828+ |> mem "monorepo" string ~enc:(fun m -> m.monorepo)
2929+ |> mem "opamrepo" string ~enc:(fun m -> m.opamrepo)
3030+ |> finish))
3131+3232+type registry_info = { r_name : string }
3333+3434+let registry_info_codec : registry_info Tomlt.t =
3535+ Tomlt.(
3636+ Table.(
3737+ obj (fun r_name -> { r_name })
3838+ |> mem "name" string ~enc:(fun r -> r.r_name)
3939+ |> finish))
4040+4141+let codec : t Tomlt.t =
4242+ Tomlt.(
4343+ Table.(
4444+ obj (fun registry members ->
4545+ { name = registry.r_name; members = Option.value ~default:[] members })
4646+ |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name })
4747+ |> opt_mem "members" (list member_codec) ~enc:(fun t ->
4848+ match t.members with [] -> None | ms -> Some ms)
4949+ |> finish))
5050+5151+let empty_registry = { name = "opamverse"; members = [] }
5252+5353+let load ~fs path =
5454+ let path_str = Fpath.to_string path in
5555+ Logs.info (fun m -> m "Loading registry from path: %s" path_str);
5656+ try
5757+ let registry = Tomlt_eio.decode_path_exn codec ~fs path_str in
5858+ Logs.info (fun m -> m "Registry loaded: %d members" (List.length registry.members));
5959+ Ok registry
6060+ with
6161+ | Eio.Io _ as e ->
6262+ Logs.err (fun m -> m "Eio.Io error: %s" (Printexc.to_string e));
6363+ Error (Fmt.str "Registry IO error: %s" (Printexc.to_string e))
6464+ | Failure msg ->
6565+ Logs.err (fun m -> m "Registry parse error: %s" msg);
6666+ Error (Fmt.str "Invalid registry: %s" msg)
6767+ | exn ->
6868+ Logs.err (fun m -> m "Unexpected registry error: %s" (Printexc.to_string exn));
6969+ Error (Fmt.str "Registry error: %s" (Printexc.to_string exn))
7070+7171+let save ~fs path registry =
7272+ let path_str = Fpath.to_string path in
7373+ try
7474+ Tomlt_eio.encode_path codec registry ~fs path_str;
7575+ Ok ()
7676+ with Eio.Io _ as e -> Error (Printexc.to_string e)
7777+7878+let clone_or_pull ~proc ~fs ~config:_ () =
7979+ let registry_path = Verse_config.registry_path () in
8080+ let registry_toml = Fpath.(registry_path / "opamverse.toml") in
8181+ Logs.info (fun m -> m "Registry path: %a" Fpath.pp registry_path);
8282+ (* Check if registry directory exists as a git repo *)
8383+ let exists =
8484+ let path = Eio.Path.(fs / Fpath.to_string registry_path) in
8585+ match Eio.Path.kind ~follow:true path with
8686+ | `Directory -> Git.is_repo ~proc ~fs registry_path
8787+ | _ -> false
8888+ | exception _ -> false
8989+ in
9090+ if exists then begin
9191+ Logs.info (fun m -> m "Registry exists, pulling updates...");
9292+ (* Pull updates, but don't fail if pull fails *)
9393+ (match Git.pull ~proc ~fs registry_path with
9494+ | Ok () -> Logs.info (fun m -> m "Registry pull succeeded")
9595+ | Error e -> Logs.warn (fun m -> m "Registry pull failed: %a (using cached)" Git.pp_error e));
9696+ Logs.info (fun m -> m "Loading registry from %a" Fpath.pp registry_toml);
9797+ load ~fs registry_toml
9898+ end
9999+ else begin
100100+ Logs.info (fun m -> m "Registry not found, cloning from %s..." default_url);
101101+ (* Ensure parent directory exists *)
102102+ let parent = Fpath.parent registry_path in
103103+ let parent_path = Eio.Path.(fs / Fpath.to_string parent) in
104104+ (try Eio.Path.mkdirs ~perm:0o755 parent_path with Eio.Io _ -> ());
105105+ (* Try to clone the registry *)
106106+ let url = Uri.of_string default_url in
107107+ let branch = "main" in
108108+ match Git.clone ~proc ~fs ~url ~branch registry_path with
109109+ | Ok () ->
110110+ Logs.info (fun m -> m "Registry cloned successfully");
111111+ load ~fs registry_toml
112112+ | Error e ->
113113+ Logs.warn (fun m -> m "Registry clone failed: %a" Git.pp_error e);
114114+ Logs.info (fun m -> m "Creating empty local registry...");
115115+ (* Clone failed - create local registry directory with empty registry *)
116116+ let registry_eio = Eio.Path.(fs / Fpath.to_string registry_path) in
117117+ (try Eio.Path.mkdirs ~perm:0o755 registry_eio with Eio.Io _ -> ());
118118+ (* Initialize as git repo *)
119119+ (match Git.init ~proc ~fs registry_path with
120120+ | Ok () -> ()
121121+ | Error _ -> ());
122122+ (* Create empty registry file *)
123123+ (match save ~fs registry_toml empty_registry with
124124+ | Ok () -> ()
125125+ | Error _ -> ());
126126+ Ok empty_registry
127127+ end
128128+129129+let find_member t ~handle =
130130+ List.find_opt (fun m -> m.handle = handle) t.members
131131+132132+let find_members t ~handles =
133133+ List.filter (fun m -> List.mem m.handle handles) t.members
+62
monopam/lib/verse_registry.mli
···11+(** Opamverse registry management.
22+33+ The registry is a git repository containing a [opamverse.toml] file that
44+ lists community members and their monorepo URLs. *)
55+66+(** {1 Types} *)
77+88+type member = {
99+ handle : string; (** Tangled handle (e.g., "alice.bsky.social") *)
1010+ monorepo : string; (** Git URL of the member's monorepo *)
1111+ opamrepo : string; (** Git URL of the member's opam overlay repository *)
1212+}
1313+(** A registry member entry. *)
1414+1515+type t = {
1616+ name : string; (** Registry name *)
1717+ members : member list; (** List of registered members *)
1818+}
1919+(** The parsed registry contents. *)
2020+2121+(** {1 Registry Operations} *)
2222+2323+val default_url : string
2424+(** Default registry URL: [https://tangled.org/eeg.cl.cam.ac.uk/opamverse] *)
2525+2626+val clone_or_pull :
2727+ proc:_ Eio.Process.mgr ->
2828+ fs:Eio.Fs.dir_ty Eio.Path.t ->
2929+ config:Verse_config.t ->
3030+ unit ->
3131+ (t, string) result
3232+(** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present,
3333+ or pulls updates if it exists. Returns the parsed registry contents.
3434+3535+ The registry is cloned to [config.registry_path].
3636+3737+ @param proc Eio process manager
3838+ @param fs Eio filesystem
3939+ @param config Workspace configuration *)
4040+4141+val load : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> (t, string) result
4242+(** [load ~fs path] loads the registry from a [opamverse.toml] file.
4343+4444+ @param fs Eio filesystem
4545+ @param path Path to the opamverse.toml file *)
4646+4747+(** {1 Member Lookup} *)
4848+4949+val find_member : t -> handle:string -> member option
5050+(** [find_member registry ~handle] finds a member by their handle. *)
5151+5252+val find_members : t -> handles:string list -> member list
5353+(** [find_members registry ~handles] finds multiple members by their handles.
5454+ Returns only the members that were found. *)
5555+5656+(** {1 Pretty Printing} *)
5757+5858+val pp_member : member Fmt.t
5959+(** [pp_member] formats a registry member. *)
6060+6161+val pp : t Fmt.t
6262+(** [pp] formats the registry. *)
···3131 | Http1 (** HTTP/1.x (including plain HTTP) *)
3232 | Http2 (** HTTP/2 negotiated via ALPN *)
33333434+(* Track whether TLS tracing has been suppressed *)
3535+let tls_tracing_suppressed = ref false
3636+3737+(* Suppress TLS tracing debug output (hexdumps) unless explicitly enabled *)
3838+let suppress_tls_tracing () =
3939+ if not !tls_tracing_suppressed then begin
4040+ tls_tracing_suppressed := true;
4141+ match List.find_opt (fun s -> Logs.Src.name s = "tls.tracing") (Logs.Src.list ()) with
4242+ | Some tls_src ->
4343+ (* Only suppress if currently at Debug level *)
4444+ (match Logs.Src.level tls_src with
4545+ | Some Logs.Debug -> Logs.Src.set_level tls_src (Some Logs.Warning)
4646+ | _ -> ())
4747+ | None -> ()
4848+ end
4949+3450(* Helper to wrap connection with TLS if needed.
3551 Returns the TLS flow and the negotiated protocol. *)
3652let wrap_tls flow ~host ~verify_tls ~tls_config ~min_tls_version =
···6177 in
62786379 let tls_flow = Tls_eio.client_of_flow ~host:domain tls_cfg flow in
8080+ (* Suppress TLS tracing after first connection creates the tls.tracing source *)
8181+ suppress_tls_tracing ();
64826583 (* Check negotiated ALPN protocol *)
6684 let protocol = match Tls_eio.epoch tls_flow with
+28-8
ocaml-zulip/lib/zulip/channels.ml
···4343 in
4444 Error.decode_or_raise streams_codec json "parsing channels list"
45454646+(* Search for a channel by name in the list of all channels *)
4747+let find_channel_by_name channels ~name =
4848+ match List.find_opt (fun ch -> Channel.name ch = name) channels with
4949+ | Some ch -> Channel.stream_id ch
5050+ | None -> None
5151+4652let get_id client ~name =
4747- let encoded_name = Uri.pct_encode name in
4853 let response_codec =
4954 Jsont.Object.(
5055 map ~kind:"StreamIdResponse" Fun.id
5156 |> mem "stream_id" Jsont.int ~enc:Fun.id
5257 |> finish)
5358 in
5454- let json =
5555- Client.request client ~method_:`GET
5656- ~path:("/api/v1/get_stream_id?stream=" ^ encoded_name)
5757- ()
5858- in
5959- Error.decode_or_raise response_codec json
6060- (Printf.sprintf "getting stream id for %s" name)
5959+ try
6060+ let json =
6161+ Client.request client ~method_:`GET
6262+ ~path:"/api/v1/get_stream_id"
6363+ ~params:[("stream", name)]
6464+ ()
6565+ in
6666+ Error.decode_or_raise response_codec json
6767+ (Printf.sprintf "getting stream id for %s" name)
6868+ with Eio.Io (Error.E { code = Bad_request; _ }, _) ->
6969+ (* Fallback: search through channel list for exact name match *)
7070+ let channels = list client in
7171+ match find_channel_by_name channels ~name with
7272+ | Some id -> id
7373+ | None ->
7474+ (* Re-raise with helpful context about available channels *)
7575+ let available = List.map Channel.name channels |> String.concat ", " in
7676+ Error.raise_with_context
7777+ (Error.make ~code:Bad_request
7878+ ~message:(Printf.sprintf "Channel '%s' not found. Available: %s" name available)
7979+ ())
8080+ "getting stream id for %s" name
61816282let get_by_id client ~stream_id =
6383 let response_codec =
+5-4
ocaml-zulip/lib/zulip/client.ml
···3737 | `PATCH -> "PATCH"
38383939let request t ~method_ ~path ?params ?body ?content_type () =
4040- let url = Auth.server_url t.auth ^ path in
4141- Log.debug (fun m -> m "Request: %s %s" (method_to_string method_) path);
4040+ let base_url = Auth.server_url t.auth ^ path in
42414342 (* Convert params to URL query string if provided *)
4443 let url =
4544 params
4645 |> Option.map (fun p ->
4747- Uri.of_string url
4646+ Uri.of_string base_url
4847 |> Fun.flip
4948 (List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)))
5049 p
5150 |> Uri.to_string)
5252- |> Option.value ~default:url
5151+ |> Option.value ~default:base_url
5352 in
5353+5454+ Log.debug (fun m -> m "Request: %s %s" (method_to_string method_) url);
54555556 (* Prepare request body if provided *)
5657 let body_opt =
+66-14
poe/lib/changelog.ml
···1313 author: string;
1414 email: string;
1515 subject: string;
1616+ files: string list;
1617}
17181819type channel_member = {
···2021 email: string;
2122}
22232424+let get_commit_files ~proc ~cwd ~hash =
2525+ Eio.Switch.run @@ fun sw ->
2626+ let buf = Buffer.create 256 in
2727+ let child = Eio.Process.spawn proc ~sw ~cwd
2828+ ~stdout:(Eio.Flow.buffer_sink buf)
2929+ ["git"; "diff-tree"; "--no-commit-id"; "--name-only"; "-r"; hash]
3030+ in
3131+ match Eio.Process.await child with
3232+ | `Exited 0 ->
3333+ Buffer.contents buf
3434+ |> String.split_on_char '\n'
3535+ |> List.filter (fun s -> String.trim s <> "")
3636+ | _ -> []
3737+2338let get_git_log ~proc ~cwd ~since_head =
2439 Log.info (fun m -> m "Getting commits since %s" since_head);
2540 Eio.Switch.run @@ fun sw ->
···3752 |> List.filter_map (fun line ->
3853 match String.split_on_char '|' line with
3954 | [hash; author; email; subject] ->
4040- Some { hash; author; email; subject }
5555+ let files = get_commit_files ~proc ~cwd ~hash in
5656+ Some { hash; author; email; subject; files }
4157 | _ -> None)
4258 | _ -> []
4359···5874 |> List.filter_map (fun line ->
5975 match String.split_on_char '|' line with
6076 | [hash; author; email; subject] ->
6161- Some { hash; author; email; subject }
7777+ let files = get_commit_files ~proc ~cwd ~hash in
7878+ Some { hash; author; email; subject; files }
6279 | _ -> None)
6380 | _ -> []
6481···84101let create_claude_client ~sw ~proc ~clock =
85102 let options =
86103 Claude.Options.default
8787- |> Claude.Options.with_model `Opus_4
104104+ |> Claude.Options.with_model `Opus_4_5
88105 |> Claude.Options.with_permission_mode Claude.Permissions.Mode.Bypass_permissions
89106 |> Claude.Options.with_allowed_tools []
90107 in
···103120 in
104121 String.concat "" text
105122123123+(* Extract sub-project name from a file path (first directory component) *)
124124+let subproject_of_file path =
125125+ match String.split_on_char '/' path with
126126+ | dir :: _ when dir <> "" && dir <> "." -> Some dir
127127+ | _ -> None
128128+129129+(* Get unique sub-projects affected by a list of commits *)
130130+let affected_subprojects commits =
131131+ commits
132132+ |> List.concat_map (fun c -> c.files)
133133+ |> List.filter_map subproject_of_file
134134+ |> List.sort_uniq String.compare
135135+106136let generate ~sw ~proc ~clock ~commits ~members =
107137 if commits = [] then None
108138 else begin
109139 Log.info (fun m -> m "Generating narrative changelog with Claude for %d commits" (List.length commits));
110140111111- (* Format commits for the prompt *)
141141+ (* Get affected sub-projects *)
142142+ let subprojects = affected_subprojects commits in
143143+ let subprojects_text = String.concat ", " subprojects in
144144+145145+ (* Format commits for the prompt, including files *)
112146 let commits_text = commits
113147 |> List.map (fun c ->
114114- Printf.sprintf "- %s by %s <%s>: %s" c.hash c.author c.email c.subject)
148148+ let files_text = match c.files with
149149+ | [] -> ""
150150+ | files -> Printf.sprintf "\n Files: %s" (String.concat ", " files)
151151+ in
152152+ Printf.sprintf "- %s by %s <%s>: %s%s" c.hash c.author c.email c.subject files_text)
115153 |> String.concat "\n"
116154 in
117155···123161 in
124162125163 let prompt = Printf.sprintf
126126-{|You are writing a brief changelog update for a Zulip channel. Given these git commits:
164164+{|You are writing a changelog update for a Zulip channel about a monorepo.
165165+166166+Git commits:
127167128168%s
129169130130-And these channel members who can be @mentioned (use the exact @**Name** format):
170170+Affected sub-projects: %s
171171+172172+Channel members who can be @mentioned (use exact @**Name** format):
131173132174%s
133175134134-Write a brief, narrative changelog (2-4 sentences) that:
135135-1. Focuses on user-visible features and API changes
136136-2. Uses @**Name** mentions when a commit author matches a channel member (by name or email)
137137-3. Is conversational and not bullet-pointed
138138-4. Skips internal refactoring or minor fixes unless they're the only changes
176176+Write a bullet-point changelog. Each bullet should have the project name first in bold, then a brief description of the change, and the change type in italics at the end.
139177140140-If commits are purely internal/maintenance with no user-visible changes, just write a single sentence noting routine maintenance.
178178+Format:
179179+- **project-name**: Description of the change. *change type*
141180142142-Write ONLY the changelog text, no preamble or explanation.|} commits_text members_text
181181+Example:
182182+- **ocaml-claudeio**: Added model types for Opus 4.5 and 4.1. *new feature*
183183+- **ocaml-zulip**: Fixed encoding bug in channel name lookups that affected names with spaces. *bug fix*
184184+- **poe**: Updated to use the latest Opus model for changelog generation. *enhancement*
185185+186186+Guidelines:
187187+1. One bullet per logical change (group related commits)
188188+2. Project name in bold at the start
189189+3. One or two sentences describing the change
190190+4. Change type in italics at the end: *new feature*, *bug fix*, *enhancement*, *refactoring*, etc.
191191+5. Use @**Name** mentions when authors match channel members
192192+6. No emojis
193193+194194+Write ONLY the bullet points, no preamble or header.|} commits_text subprojects_text members_text
143195 in
144196145197 let response = ask_claude ~sw ~proc ~clock prompt in
+7-8
poe/lib/changelog.mli
···1616 author: string;
1717 email: string;
1818 subject: string;
1919+ files: string list;
1920}
2020-(** A git commit with metadata. *)
2121+(** A git commit with metadata and list of changed files. *)
21222223type channel_member = {
2324 full_name: string;
···6061 commits:commit list ->
6162 members:channel_member list ->
6263 string option
6363-(** [generate ~sw ~proc ~clock ~commits ~members] generates a narrative
6464+(** [generate ~sw ~proc ~clock ~commits ~members] generates a bullet-point
6465 changelog using Claude. Returns [None] if commits is empty, or
6565- [Some narrative] with the generated text.
6666+ [Some changelog] with the generated text.
66676767- The narrative:
6868- - Focuses on user-visible features and API changes
6969- - Uses @**Name** mentions for authors matching channel members
7070- - Is conversational prose, not bullet points
7171- - Summarizes internal changes briefly *)
6868+ Each bullet has the project name in bold, a description of the change,
6969+ and the change type in italics (e.g. "new feature", "bug fix").
7070+ Zulip @-mentions are used for authors matching channel members. *)