Select the types of activity you want to include in your feed.
fix(lint): resolve E320, E331 in monopam
Rename find_other_repos to other_repos, make_local_member to local_member (E331), fix push.ml shadowing bug, and extract helpers to reduce complexity (E320).
···11open Cmdliner
2233+let parse_source source =
44+ if String.ends_with ~suffix:".lock" source then
55+ Monopam.Import.Lock_file (Fpath.v source)
66+ else
77+ (* Parse opam URL syntax: URL#ref *)
88+ let url, ref_ =
99+ match String.rindex_opt source '#' with
1010+ | Some i ->
1111+ let url = String.sub source 0 i in
1212+ let ref_ = String.sub source (i + 1) (String.length source - i - 1) in
1313+ (url, Some ref_)
1414+ | None -> (source, None)
1515+ in
1616+ Monopam.Import.Git_url { url; branch = None; ref_ }
1717+1818+let run source dir dry_run () =
1919+ Eio_main.run @@ fun env ->
2020+ let fs = Eio.Stdenv.fs env in
2121+ let proc = Eio.Stdenv.process_mgr env in
2222+ let target = Fpath.v (Sys.getcwd ()) in
2323+ let source = parse_source source in
2424+ match Monopam.Import.run ~proc ~fs ~target ~source ~name:dir ~dry_run () with
2525+ | Ok results ->
2626+ if results = [] then Fmt.pr "Nothing added.@."
2727+ else begin
2828+ Fmt.pr "Added %d subtree%s:@." (List.length results)
2929+ (if List.length results = 1 then "" else "s");
3030+ List.iter
3131+ (fun r ->
3232+ Fmt.pr " %s (%s)@." r.Monopam.Import.name
3333+ (String.sub r.Monopam.Import.commit 0
3434+ (min 7 (String.length r.Monopam.Import.commit))))
3535+ results
3636+ end;
3737+ `Ok ()
3838+ | Error e ->
3939+ Fmt.epr "Error: %s@." e;
4040+ `Error (false, "add failed")
4141+342let cmd =
443 let doc = "Add a package from a git URL or lock file" in
544 let man =
···5291 let dry_run_arg =
5392 let doc = "Show what would be added without making changes." in
5493 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
5555- in
5656- let run source dir dry_run () =
5757- Eio_main.run @@ fun env ->
5858- let fs = Eio.Stdenv.fs env in
5959- let proc = Eio.Stdenv.process_mgr env in
6060- let target = Fpath.v (Sys.getcwd ()) in
6161- (* Auto-detect if source is a lock file or git URL *)
6262- let source =
6363- if String.ends_with ~suffix:".lock" source then
6464- Monopam.Import.Lock_file (Fpath.v source)
6565- else
6666- (* Parse opam URL syntax: URL#ref *)
6767- let url, ref_ =
6868- match String.rindex_opt source '#' with
6969- | Some i ->
7070- let url = String.sub source 0 i in
7171- let ref_ =
7272- String.sub source (i + 1) (String.length source - i - 1)
7373- in
7474- (url, Some ref_)
7575- | None -> (source, None)
7676- in
7777- Monopam.Import.Git_url { url; branch = None; ref_ }
7878- in
7979- match
8080- Monopam.Import.run ~proc ~fs ~target ~source ~name:dir ~dry_run ()
8181- with
8282- | Ok results ->
8383- if results = [] then Fmt.pr "Nothing added.@."
8484- else begin
8585- Fmt.pr "Added %d subtree%s:@." (List.length results)
8686- (if List.length results = 1 then "" else "s");
8787- List.iter
8888- (fun r ->
8989- Fmt.pr " %s (%s)@." r.Monopam.Import.name
9090- (String.sub r.Monopam.Import.commit 0
9191- (min 7 (String.length r.Monopam.Import.commit))))
9292- results
9393- end;
9494- `Ok ()
9595- | Error e ->
9696- Fmt.epr "Error: %s@." e;
9797- `Error (false, "add failed")
9894 in
9995 Cmd.v info
10096 Term.(
+60-68
bin/cmd_diff.ml
···11open Cmdliner
2233+let print_diff ~fs ~config status incoming =
44+ let pkg = status.Monopam.Status.package in
55+ let repo_name = Monopam.Package.repo_name pkg in
66+ let checkouts_root = Monopam.Config.Paths.checkouts config in
77+ let checkout_path = Fpath.(checkouts_root / repo_name) in
88+ let fs_t = (fs :> Eio.Fs.dir_ty Eio.Path.t) in
99+ if Eio.Path.is_directory Eio.Path.(fs_t / Fpath.to_string checkout_path) then
1010+ let base, tip =
1111+ if incoming then ("HEAD", "origin/main") else ("origin/main", "HEAD")
1212+ in
1313+ let repo = Git.Repository.open_repo ~fs:fs_t checkout_path in
1414+ match Git.Repository.log_range_refs repo ~base ~tip ~max_count:20 () with
1515+ | Ok [] -> false
1616+ | Ok entries ->
1717+ let direction = if incoming then "incoming" else "outgoing" in
1818+ Fmt.pr "@.%a (%s, %d commits):@."
1919+ Fmt.(styled `Bold string)
2020+ repo_name direction (List.length entries);
2121+ List.iter
2222+ (fun (e : Git.Repository.log_entry) ->
2323+ let short_hash = String.sub e.hash 0 7 in
2424+ Fmt.pr " %a %s@." Fmt.(styled `Yellow string) short_hash e.subject)
2525+ entries;
2626+ true
2727+ | Error _ -> false
2828+ else false
2929+3030+let run package incoming () =
3131+ Eio_main.run @@ fun env ->
3232+ Common.with_config env @@ fun config ->
3333+ let fs = Eio.Stdenv.fs env in
3434+ match Monopam.status ~fs:(fs :> Eio.Fs.dir_ty Eio.Path.t) ~config () with
3535+ | Error e ->
3636+ Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
3737+ `Error (false, "diff failed")
3838+ | Ok statuses ->
3939+ let statuses =
4040+ match package with
4141+ | Some name ->
4242+ List.filter
4343+ (fun s -> Monopam.Package.name s.Monopam.Status.package = name)
4444+ statuses
4545+ | None -> statuses
4646+ in
4747+ if statuses = [] then begin
4848+ (match package with
4949+ | Some name -> Fmt.epr "Package not found: %s@." name
5050+ | None -> Fmt.epr "No packages found@.");
5151+ `Error (false, "no packages")
5252+ end
5353+ else begin
5454+ let has_diff =
5555+ List.exists
5656+ (fun status -> print_diff ~fs ~config status incoming)
5757+ statuses
5858+ in
5959+ if not has_diff then Fmt.pr "No differences.@.";
6060+ `Ok ()
6161+ end
6262+363let cmd =
464 let doc = "Show diff between monorepo and upstream" in
565 let man =
···2686 let incoming_arg =
2787 let doc = "Show incoming changes from upstream (what you would pull)." in
2888 Arg.(value & flag & info [ "incoming"; "i" ] ~doc)
2929- in
3030- let run package incoming () =
3131- Eio_main.run @@ fun env ->
3232- Common.with_config env @@ fun config ->
3333- let fs = Eio.Stdenv.fs env in
3434- match Monopam.status ~fs ~config () with
3535- | Error e ->
3636- Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
3737- `Error (false, "diff failed")
3838- | Ok statuses ->
3939- let statuses =
4040- match package with
4141- | Some name ->
4242- List.filter
4343- (fun s -> Monopam.Package.name s.Monopam.Status.package = name)
4444- statuses
4545- | None -> statuses
4646- in
4747- if statuses = [] then begin
4848- (match package with
4949- | Some name -> Fmt.epr "Package not found: %s@." name
5050- | None -> Fmt.epr "No packages found@.");
5151- `Error (false, "no packages")
5252- end
5353- else begin
5454- let has_diff = ref false in
5555- List.iter
5656- (fun status ->
5757- let pkg = status.Monopam.Status.package in
5858- let repo_name = Monopam.Package.repo_name pkg in
5959- let checkouts_root = Monopam.Config.Paths.checkouts config in
6060- let checkout_path = Fpath.(checkouts_root / repo_name) in
6161- let fs_t = (fs :> Eio.Fs.dir_ty Eio.Path.t) in
6262- if
6363- Eio.Path.is_directory
6464- Eio.Path.(fs_t / Fpath.to_string checkout_path)
6565- then begin
6666- (* Use log_range to show commits *)
6767- let base, tip =
6868- if incoming then ("HEAD", "origin/main")
6969- else ("origin/main", "HEAD")
7070- in
7171- let repo = Git.Repository.open_repo ~fs checkout_path in
7272- match
7373- Git.Repository.log_range_refs repo ~base ~tip ~max_count:20 ()
7474- with
7575- | Ok [] -> () (* No diff *)
7676- | Ok entries ->
7777- has_diff := true;
7878- let direction =
7979- if incoming then "incoming" else "outgoing"
8080- in
8181- Fmt.pr "@.%a (%s, %d commits):@."
8282- Fmt.(styled `Bold string)
8383- repo_name direction (List.length entries);
8484- List.iter
8585- (fun (e : Git.Repository.log_entry) ->
8686- let short_hash = String.sub e.hash 0 7 in
8787- Fmt.pr " %a %s@."
8888- Fmt.(styled `Yellow string)
8989- short_hash e.subject)
9090- entries
9191- | Error _ -> ()
9292- end)
9393- statuses;
9494- if not !has_diff then Fmt.pr "No differences.@.";
9595- `Ok ()
9696- end
9789 in
9890 Cmd.v info
9991 Term.(
+69-70
bin/cmd_fetch.ml
···11open Cmdliner
2233+let fetch_repos ~proc ~fs ~config statuses =
44+ let seen = Hashtbl.create 32 in
55+ let repos =
66+ List.filter_map
77+ (fun status ->
88+ let pkg = status.Monopam.Status.package in
99+ let repo_name = Monopam.Package.repo_name pkg in
1010+ if Hashtbl.mem seen repo_name then None
1111+ else begin
1212+ Hashtbl.add seen repo_name true;
1313+ Some pkg
1414+ end)
1515+ statuses
1616+ in
1717+ let total = List.length repos in
1818+ Fmt.pr "Fetching %d repositories...@." total;
1919+ let fs_t = (fs :> Eio.Fs.dir_ty Eio.Path.t) in
2020+ let fetched = ref 0 in
2121+ let errors = ref 0 in
2222+ List.iter
2323+ (fun pkg ->
2424+ let repo_name = Monopam.Package.repo_name pkg in
2525+ let checkouts_root = Monopam.Config.Paths.checkouts config in
2626+ let checkout_path = Fpath.(checkouts_root / repo_name) in
2727+ if Eio.Path.is_directory Eio.Path.(fs_t / Fpath.to_string checkout_path)
2828+ then (
2929+ match Monopam.Git_cli.fetch ~proc ~fs:fs_t checkout_path with
3030+ | Ok () ->
3131+ incr fetched;
3232+ Fmt.pr " %s: fetched@." repo_name
3333+ | Error e ->
3434+ incr errors;
3535+ Fmt.epr " %s: %a@." repo_name Monopam.Git_cli.pp_error e)
3636+ else Fmt.pr " %s: not cloned, skipping@." repo_name)
3737+ repos;
3838+ Fmt.pr "@.Fetched %d repositories" !fetched;
3939+ if !errors > 0 then Fmt.pr " (%d errors)" !errors;
4040+ Fmt.pr ".@.";
4141+ Fmt.pr "@.Run $(b,monopam diff --incoming) to see changes.@."
4242+4343+let run package () =
4444+ Eio_main.run @@ fun env ->
4545+ Common.with_config env @@ fun config ->
4646+ let fs = Eio.Stdenv.fs env in
4747+ let proc = Eio.Stdenv.process_mgr env in
4848+ match Monopam.Ctx.status ~fs ~config () with
4949+ | Error e ->
5050+ Fmt.epr "Error: %a@." Monopam.Ctx.pp_error_with_hint e;
5151+ `Error (false, "fetch failed")
5252+ | Ok statuses ->
5353+ let statuses =
5454+ match package with
5555+ | Some name ->
5656+ List.filter
5757+ (fun s -> Monopam.Package.name s.Monopam.Status.package = name)
5858+ statuses
5959+ | None -> statuses
6060+ in
6161+ if statuses = [] then begin
6262+ (match package with
6363+ | Some name -> Fmt.epr "Package not found: %s@." name
6464+ | None -> Fmt.epr "No packages found@.");
6565+ `Error (false, "no packages")
6666+ end
6767+ else begin
6868+ fetch_repos ~proc ~fs ~config statuses;
6969+ `Ok ()
7070+ end
7171+372let cmd =
473 let doc = "Fetch changes from upstream without merging" in
574 let man =
···2796 ]
2897 in
2998 let info = Cmd.info "fetch" ~doc ~man in
3030- let run package () =
3131- Eio_main.run @@ fun env ->
3232- Common.with_config env @@ fun config ->
3333- let fs = Eio.Stdenv.fs env in
3434- let proc = Eio.Stdenv.process_mgr env in
3535- (* Get status to find packages *)
3636- match Monopam.Ctx.status ~fs ~config () with
3737- | Error e ->
3838- Fmt.epr "Error: %a@." Monopam.Ctx.pp_error_with_hint e;
3939- `Error (false, "fetch failed")
4040- | Ok statuses ->
4141- let statuses =
4242- match package with
4343- | Some name ->
4444- List.filter
4545- (fun s -> Monopam.Package.name s.Monopam.Status.package = name)
4646- statuses
4747- | None -> statuses
4848- in
4949- if statuses = [] then begin
5050- (match package with
5151- | Some name -> Fmt.epr "Package not found: %s@." name
5252- | None -> Fmt.epr "No packages found@.");
5353- `Error (false, "no packages")
5454- end
5555- else begin
5656- (* Get unique repos *)
5757- let seen = Hashtbl.create 32 in
5858- let repos =
5959- List.filter_map
6060- (fun status ->
6161- let pkg = status.Monopam.Status.package in
6262- let repo_name = Monopam.Package.repo_name pkg in
6363- if Hashtbl.mem seen repo_name then None
6464- else begin
6565- Hashtbl.add seen repo_name true;
6666- Some pkg
6767- end)
6868- statuses
6969- in
7070- let total = List.length repos in
7171- Fmt.pr "Fetching %d repositories...@." total;
7272- let fs_t = (fs :> Eio.Fs.dir_ty Eio.Path.t) in
7373- let fetched = ref 0 in
7474- let errors = ref 0 in
7575- List.iter
7676- (fun pkg ->
7777- let repo_name = Monopam.Package.repo_name pkg in
7878- let checkouts_root = Monopam.Config.Paths.checkouts config in
7979- let checkout_path = Fpath.(checkouts_root / repo_name) in
8080- if
8181- Eio.Path.is_directory
8282- Eio.Path.(fs_t / Fpath.to_string checkout_path)
8383- then (
8484- match Monopam.Git_cli.fetch ~proc ~fs:fs_t checkout_path with
8585- | Ok () ->
8686- incr fetched;
8787- Fmt.pr " %s: fetched@." repo_name
8888- | Error e ->
8989- incr errors;
9090- Fmt.epr " %s: %a@." repo_name Monopam.Git_cli.pp_error e)
9191- else Fmt.pr " %s: not cloned, skipping@." repo_name)
9292- repos;
9393- Fmt.pr "@.Fetched %d repositories" !fetched;
9494- if !errors > 0 then Fmt.pr " (%d errors)" !errors;
9595- Fmt.pr ".@.";
9696- Fmt.pr "@.Run $(b,monopam diff --incoming) to see changes.@.";
9797- `Ok ()
9898- end
9999- in
10099 Cmd.v info Term.(ret (const run $ Common.package_arg $ Common.logging_term))
+22-22
bin/cmd_init.ml
···1414 Arg.(
1515 required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
16161717+let run root handle () =
1818+ Eio_main.run @@ fun env ->
1919+ let fs = Eio.Stdenv.fs env in
2020+ let proc = Eio.Stdenv.process_mgr env in
2121+ let root =
2222+ match root with
2323+ | Some r -> r
2424+ | None -> (
2525+ let cwd_path = Eio.Stdenv.cwd env in
2626+ let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
2727+ match Fpath.of_string cwd_str with
2828+ | Ok p -> p
2929+ | Error (`Msg _) -> Fpath.v ".")
3030+ in
3131+ match Monopam.Verse.init ~proc ~fs ~root ~handle () with
3232+ | Ok () ->
3333+ Fmt.pr "Workspace initialized at %a@." Fpath.pp root;
3434+ `Ok ()
3535+ | Error e ->
3636+ Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
3737+ `Error (false, "init failed")
3838+1739let cmd =
1840 let doc = "Initialize a new monopam workspace" in
1941 let man =
···6486 ]
6587 in
6688 let info = Cmd.info "init" ~doc ~man in
6767- let run root handle () =
6868- Eio_main.run @@ fun env ->
6969- let fs = Eio.Stdenv.fs env in
7070- let proc = Eio.Stdenv.process_mgr env in
7171- let root =
7272- match root with
7373- | Some r -> r
7474- | None -> (
7575- let cwd_path = Eio.Stdenv.cwd env in
7676- let _, cwd_str = (cwd_path :> _ Eio.Path.t) in
7777- match Fpath.of_string cwd_str with
7878- | Ok p -> p
7979- | Error (`Msg _) -> Fpath.v ".")
8080- in
8181- match Monopam.Verse.init ~proc ~fs ~root ~handle () with
8282- | Ok () ->
8383- Fmt.pr "Workspace initialized at %a@." Fpath.pp root;
8484- `Ok ()
8585- | Error e ->
8686- Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
8787- `Error (false, "init failed")
8888- in
8989 Cmd.v info
9090 Term.(ret (const run $ root_arg $ handle_arg $ Common.logging_term))
+44-50
bin/cmd_publish.ml
···11open Cmdliner
2233+let run packages opam_repo no_commit dry_run no_checkouts () =
44+ Eio_main.run @@ fun env ->
55+ let fs = Eio.Stdenv.fs env in
66+ let proc = Eio.Stdenv.process_mgr env in
77+ let source = Fpath.v (Sys.getcwd ()) in
88+ let config_opt = Result.to_option (Common.load_config env) in
99+ let target =
1010+ match opam_repo with
1111+ | Some path ->
1212+ if Filename.is_relative path then Fpath.(source / path)
1313+ else Fpath.v path
1414+ | None -> (
1515+ match config_opt with
1616+ | Some config -> Monopam.Config.Paths.opam_repo config
1717+ | None -> Fpath.(parent source / "opam-repo"))
1818+ in
1919+ if dry_run then
2020+ Fmt.pr "Dry run: publishing from %a to %a@." Fpath.pp source Fpath.pp target;
2121+ match
2222+ Monopam.Opam_sync.run_from_cwd ~fs ~proc ~source ~target ~packages
2323+ ~no_commit ~dry_run ()
2424+ with
2525+ | Error (`Config_error e) ->
2626+ Fmt.epr "Error: %s@." e;
2727+ `Error (false, "publish failed")
2828+ | Ok opam_result ->
2929+ Fmt.pr "%a@." Monopam.Opam_sync.pp opam_result;
3030+ (match config_opt with
3131+ | Some config when (not no_checkouts) && not dry_run -> (
3232+ Fmt.pr "@.Exporting to checkouts...@.";
3333+ match
3434+ Monopam.Push.run ~proc ~fs ~config ~packages ~upstream:false
3535+ ~clean:false ~force:false ()
3636+ with
3737+ | Ok () -> Fmt.pr "Checkouts updated.@."
3838+ | Error e ->
3939+ Fmt.epr "Warning: checkout export failed: %a@."
4040+ Monopam.Ctx.pp_error_with_hint e)
4141+ | Some _ when no_checkouts ->
4242+ Fmt.pr "(Skipping checkout export due to --no-checkouts)@."
4343+ | Some _ when dry_run -> Fmt.pr "(Would also export to checkouts)@."
4444+ | _ -> ());
4545+ `Ok ()
4646+347let cmd =
448 let doc = "Publish packages to opam-repo" in
549 let man =
···61105 let no_checkouts_arg =
62106 let doc = "Skip exporting to checkouts (only publish opam files)." in
63107 Arg.(value & flag & info [ "no-checkouts" ] ~doc)
6464- in
6565- let run packages opam_repo no_commit dry_run no_checkouts () =
6666- Eio_main.run @@ fun env ->
6767- let fs = Eio.Stdenv.fs env in
6868- let proc = Eio.Stdenv.process_mgr env in
6969- (* Determine source directory (CWD) *)
7070- let source = Fpath.v (Sys.getcwd ()) in
7171- (* Check if we have config (running from main mono) *)
7272- let config_opt = Result.to_option (Common.load_config env) in
7373- (* Determine target opam-repo *)
7474- let target =
7575- match opam_repo with
7676- | Some path ->
7777- if Filename.is_relative path then Fpath.(source / path)
7878- else Fpath.v path
7979- | None -> (
8080- match config_opt with
8181- | Some config -> Monopam.Config.Paths.opam_repo config
8282- | None -> Fpath.(parent source / "opam-repo"))
8383- in
8484- if dry_run then
8585- Fmt.pr "Dry run: publishing from %a to %a@." Fpath.pp source Fpath.pp
8686- target;
8787- (* Publish opam files *)
8888- match
8989- Monopam.Opam_sync.run_from_cwd ~fs ~proc ~source ~target ~packages
9090- ~no_commit ~dry_run ()
9191- with
9292- | Error (`Config_error e) ->
9393- Fmt.epr "Error: %s@." e;
9494- `Error (false, "publish failed")
9595- | Ok opam_result ->
9696- Fmt.pr "%a@." Monopam.Opam_sync.pp opam_result;
9797- (* Also export to checkouts if we have config and not disabled *)
9898- (match config_opt with
9999- | Some config when (not no_checkouts) && not dry_run -> (
100100- Fmt.pr "@.Exporting to checkouts...@.";
101101- match
102102- Monopam.Push.run ~proc ~fs ~config ~packages ~upstream:false
103103- ~clean:false ~force:false ()
104104- with
105105- | Ok () -> Fmt.pr "Checkouts updated.@."
106106- | Error e ->
107107- Fmt.epr "Warning: checkout export failed: %a@."
108108- Monopam.Ctx.pp_error_with_hint e)
109109- | Some _ when no_checkouts ->
110110- Fmt.pr "(Skipping checkout export due to --no-checkouts)@."
111111- | Some _ when dry_run -> Fmt.pr "(Would also export to checkouts)@."
112112- | _ -> ());
113113- `Ok ()
114108 in
115109 Cmd.v info
116110 Term.(
+84-85
bin/cmd_push.ml
···11open Cmdliner
2233+let man =
44+ [
55+ `S Manpage.s_description;
66+ `P
77+ "Exports changes from your monorepo to upstream git repositories. This \
88+ is how you publish your work.";
99+ `S "WORKFLOW";
1010+ `P "After making and committing changes in mono/:";
1111+ `Pre
1212+ "# Edit files in mono/\n\
1313+ git add -A && git commit -m \"Add feature\"\n\
1414+ monopam push";
1515+ `S "WHAT IT DOES";
1616+ `I ("1.", "Validates that the monorepo has no uncommitted changes");
1717+ `I ("2.", "Exports subtree changes to checkouts (internal)");
1818+ `I ("3.", "Pushes checkouts to their upstream git remotes");
1919+ `S "OPTIONS";
2020+ `I
2121+ ( "--local",
2222+ "Only export to checkouts without pushing to remotes. Useful for \
2323+ reviewing changes before pushing." );
2424+ `I
2525+ ( "--clean",
2626+ "Clean commit history by removing empty commits from unrelated subtree \
2727+ merges." );
2828+ `I ("--force", "Force push to upstream (use with --clean).");
2929+ `S Manpage.s_examples;
3030+ `P "Push all changes to upstream:";
3131+ `Pre "monopam push";
3232+ `P "Push changes for a specific package:";
3333+ `Pre "monopam push eio";
3434+ `P "Export without pushing (for review):";
3535+ `Pre "monopam push --local";
3636+ `P "Push with cleaned history:";
3737+ `Pre "monopam push --clean";
3838+ ]
3939+4040+let local_arg =
4141+ let doc =
4242+ "Only export to checkouts, don't push to remotes. Use to review changes."
4343+ in
4444+ Arg.(value & flag & info [ "local" ] ~doc)
4545+4646+let clean_arg =
4747+ let doc =
4848+ "Clean commit history before pushing. Removes empty commits from unrelated \
4949+ subtree merges while preserving tree content."
5050+ in
5151+ Arg.(value & flag & info [ "clean" ] ~doc)
5252+5353+let force_arg =
5454+ let doc = "Force push to upstream. Required when using --clean." in
5555+ Arg.(value & flag & info [ "force" ] ~doc)
5656+5757+let pp_success ~local_only ~elapsed =
5858+ if local_only then
5959+ Fmt.pr "@.%a Changes exported to checkouts in %a.@." Tty.Span.pp
6060+ (Tty.Span.styled Tty.Style.(fg Tty.Color.green) "✓")
6161+ Tty.Span.pp
6262+ (Tty.Span.styled Tty.Style.(fg Tty.Color.cyan) (Fmt.str "%.1fs" elapsed))
6363+ else
6464+ Fmt.pr "@.%a Changes pushed to upstream in %a.@." Tty.Span.pp
6565+ (Tty.Span.styled Tty.Style.(fg Tty.Color.green) "✓")
6666+ Tty.Span.pp
6767+ (Tty.Span.styled Tty.Style.(fg Tty.Color.cyan) (Fmt.str "%.1fs" elapsed))
6868+6969+let run packages local_only clean force () =
7070+ let t0 = Unix.gettimeofday () in
7171+ Eio_main.run @@ fun env ->
7272+ Common.with_config env @@ fun config ->
7373+ let fs = Eio.Stdenv.fs env in
7474+ let proc = Eio.Stdenv.process_mgr env in
7575+ match
7676+ Monopam.Push.run ~proc ~fs ~config ~packages ~upstream:(not local_only)
7777+ ~clean ~force ()
7878+ with
7979+ | Ok () ->
8080+ let elapsed = Unix.gettimeofday () -. t0 in
8181+ pp_success ~local_only ~elapsed;
8282+ `Ok ()
8383+ | Error e ->
8484+ Fmt.epr "Error: %a@." Monopam.Ctx.pp_error_with_hint e;
8585+ `Error (false, "push failed")
8686+387let cmd =
488 let doc = "Push local changes to upstream repositories" in
55- let man =
66- [
77- `S Manpage.s_description;
88- `P
99- "Exports changes from your monorepo to upstream git repositories. This \
1010- is how you publish your work.";
1111- `S "WORKFLOW";
1212- `P "After making and committing changes in mono/:";
1313- `Pre
1414- "# Edit files in mono/\n\
1515- git add -A && git commit -m \"Add feature\"\n\
1616- monopam push";
1717- `S "WHAT IT DOES";
1818- `I ("1.", "Validates that the monorepo has no uncommitted changes");
1919- `I ("2.", "Exports subtree changes to checkouts (internal)");
2020- `I ("3.", "Pushes checkouts to their upstream git remotes");
2121- `S "OPTIONS";
2222- `I
2323- ( "--local",
2424- "Only export to checkouts without pushing to remotes. Useful for \
2525- reviewing changes before pushing." );
2626- `I
2727- ( "--clean",
2828- "Clean commit history by removing empty commits from unrelated \
2929- subtree merges." );
3030- `I ("--force", "Force push to upstream (use with --clean).");
3131- `S Manpage.s_examples;
3232- `P "Push all changes to upstream:";
3333- `Pre "monopam push";
3434- `P "Push changes for a specific package:";
3535- `Pre "monopam push eio";
3636- `P "Export without pushing (for review):";
3737- `Pre "monopam push --local";
3838- `P "Push with cleaned history:";
3939- `Pre "monopam push --clean";
4040- ]
4141- in
4289 let info = Cmd.info "push" ~doc ~man in
4343- let local_arg =
4444- let doc =
4545- "Only export to checkouts, don't push to remotes. Use to review changes."
4646- in
4747- Arg.(value & flag & info [ "local" ] ~doc)
4848- in
4949- let clean_arg =
5050- let doc =
5151- "Clean commit history before pushing. Removes empty commits from \
5252- unrelated subtree merges while preserving tree content."
5353- in
5454- Arg.(value & flag & info [ "clean" ] ~doc)
5555- in
5656- let force_arg =
5757- let doc = "Force push to upstream. Required when using --clean." in
5858- Arg.(value & flag & info [ "force" ] ~doc)
5959- in
6060- let run packages local_only clean force () =
6161- let t0 = Unix.gettimeofday () in
6262- Eio_main.run @@ fun env ->
6363- Common.with_config env @@ fun config ->
6464- let fs = Eio.Stdenv.fs env in
6565- let proc = Eio.Stdenv.process_mgr env in
6666- match
6767- Monopam.Push.run ~proc ~fs ~config ~packages ~upstream:(not local_only)
6868- ~clean ~force ()
6969- with
7070- | Ok () ->
7171- let elapsed = Unix.gettimeofday () -. t0 in
7272- if local_only then
7373- Fmt.pr "@.%a Changes exported to checkouts in %a.@." Tty.Span.pp
7474- (Tty.Span.styled Tty.Style.(fg Tty.Color.green) "✓")
7575- Tty.Span.pp
7676- (Tty.Span.styled
7777- Tty.Style.(fg Tty.Color.cyan)
7878- (Fmt.str "%.1fs" elapsed))
7979- else
8080- Fmt.pr "@.%a Changes pushed to upstream in %a.@." Tty.Span.pp
8181- (Tty.Span.styled Tty.Style.(fg Tty.Color.green) "✓")
8282- Tty.Span.pp
8383- (Tty.Span.styled
8484- Tty.Style.(fg Tty.Color.cyan)
8585- (Fmt.str "%.1fs" elapsed));
8686- `Ok ()
8787- | Error e ->
8888- Fmt.epr "Error: %a@." Monopam.Ctx.pp_error_with_hint e;
8989- `Error (false, "push failed")
9090- in
9190 Cmd.v info
9291 Term.(
9392 ret
+119-116
bin/cmd_remove.ml
···11open Cmdliner
2233-let cmd =
44- let doc = "Remove a subtree from the current project" in
55- let man =
66- [
77- `S Manpage.s_description;
88- `P
99- "Removes a subtree directory from the current project and updates the \
1010- mono.lock file.";
1111- `S "WHAT IT DOES";
1212- `I ("1.", "Checks if the directory exists");
1313- `I ("2.", "Checks for uncommitted changes (unless --force)");
1414- `I ("3.", "Removes the directory");
1515- `I ("4.", "Updates mono.lock to remove the entry");
1616- `I ("5.", "Stages and commits the removal (unless --no-commit)");
1717- `S Manpage.s_examples;
1818- `Pre "monopam remove eio";
1919- `Pre "monopam remove my-eio --force";
2020- `Pre "monopam remove eio --dry-run";
2121- `S Manpage.s_see_also;
2222- `P "$(b,monopam add)(1)";
2323- ]
2424- in
2525- let info = Cmd.info "remove" ~doc ~man in
2626- let dir_arg =
2727- let doc = "Directory to remove." in
2828- Arg.(required & pos 0 (some string) None & info [] ~docv:"DIR" ~doc)
2929- in
3030- let force_arg =
3131- let doc = "Remove even if there are uncommitted changes." in
3232- Arg.(value & flag & info [ "force"; "f" ] ~doc)
3333- in
3434- let no_commit_arg =
3535- let doc = "Skip automatic git commit." in
3636- Arg.(value & flag & info [ "no-commit" ] ~doc)
3737- in
3838- let dry_run_arg =
3939- let doc = "Show what would be removed without making changes." in
4040- Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
4141- in
4242- let check_uncommitted ~proc ~cwd dir =
4343- let buf = Buffer.create 256 in
4444- Eio.Switch.run @@ fun sw ->
4545- let child =
4646- Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
4747- [ "git"; "status"; "--porcelain"; "--"; dir ]
4848- in
4949- match Eio.Process.await child with
5050- | `Exited 0 ->
5151- let output = Buffer.contents buf in
5252- if String.trim output <> "" then begin
5353- Fmt.epr
5454- "Error: %s has uncommitted changes. Use --force to remove anyway.@."
5555- dir;
5656- Fmt.epr "Changes:@.%s@." output;
5757- exit 1
5858- end
5959- | _ -> ()
33+let man =
44+ [
55+ `S Manpage.s_description;
66+ `P
77+ "Removes a subtree directory from the current project and updates the \
88+ mono.lock file.";
99+ `S "WHAT IT DOES";
1010+ `I ("1.", "Checks if the directory exists");
1111+ `I ("2.", "Checks for uncommitted changes (unless --force)");
1212+ `I ("3.", "Removes the directory");
1313+ `I ("4.", "Updates mono.lock to remove the entry");
1414+ `I ("5.", "Stages and commits the removal (unless --no-commit)");
1515+ `S Manpage.s_examples;
1616+ `Pre "monopam remove eio";
1717+ `Pre "monopam remove my-eio --force";
1818+ `Pre "monopam remove eio --dry-run";
1919+ `S Manpage.s_see_also;
2020+ `P "$(b,monopam add)(1)";
2121+ ]
2222+2323+let dir_arg =
2424+ let doc = "Directory to remove." in
2525+ Arg.(required & pos 0 (some string) None & info [] ~docv:"DIR" ~doc)
2626+2727+let force_arg =
2828+ let doc = "Remove even if there are uncommitted changes." in
2929+ Arg.(value & flag & info [ "force"; "f" ] ~doc)
3030+3131+let no_commit_arg =
3232+ let doc = "Skip automatic git commit." in
3333+ Arg.(value & flag & info [ "no-commit" ] ~doc)
3434+3535+let dry_run_arg =
3636+ let doc = "Show what would be removed without making changes." in
3737+ Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
3838+3939+let check_uncommitted ~proc ~cwd dir =
4040+ let buf = Buffer.create 256 in
4141+ Eio.Switch.run @@ fun sw ->
4242+ let child =
4343+ Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf)
4444+ [ "git"; "status"; "--porcelain"; "--"; dir ]
6045 in
6161- let remove_dir ~proc ~cwd dir =
6262- Eio.Switch.run @@ fun sw ->
6363- let child = Eio.Process.spawn proc ~sw ~cwd [ "rm"; "-rf"; dir ] in
6464- match Eio.Process.await child with
6565- | `Exited 0 -> ()
6666- | _ ->
6767- Fmt.epr "Error: failed to remove %s@." dir;
4646+ match Eio.Process.await child with
4747+ | `Exited 0 ->
4848+ let output = Buffer.contents buf in
4949+ if String.trim output <> "" then begin
5050+ Fmt.epr
5151+ "Error: %s has uncommitted changes. Use --force to remove anyway.@."
5252+ dir;
5353+ Fmt.epr "Changes:@.%s@." output;
6854 exit 1
5555+ end
5656+ | _ -> ()
5757+5858+let remove_dir ~proc ~cwd dir =
5959+ Eio.Switch.run @@ fun sw ->
6060+ let child = Eio.Process.spawn proc ~sw ~cwd [ "rm"; "-rf"; dir ] in
6161+ match Eio.Process.await child with
6262+ | `Exited 0 -> ()
6363+ | _ ->
6464+ Fmt.epr "Error: failed to remove %s@." dir;
6565+ exit 1
6666+6767+let update_lock ~fs ~target dir =
6868+ match Monopam.Mono_lock.load ~fs target with
6969+ | Ok lock -> (
7070+ let lock' = Monopam.Mono_lock.remove lock ~name:dir in
7171+ match Monopam.Mono_lock.save ~fs target lock' with
7272+ | Ok () -> Fmt.pr "Updated mono.lock@."
7373+ | Error e -> Fmt.epr "Warning: failed to update mono.lock: %s@." e)
7474+ | Error _ -> ()
7575+7676+let commit_removal ~proc ~cwd dir =
7777+ Eio.Switch.run @@ fun sw ->
7878+ let child =
7979+ Eio.Process.spawn proc ~sw ~cwd [ "git"; "add"; "-A"; dir; "mono.lock" ]
6980 in
7070- let update_lock ~fs ~target dir =
7171- match Monopam.Mono_lock.load ~fs target with
7272- | Ok lock -> (
7373- let lock' = Monopam.Mono_lock.remove lock ~name:dir in
7474- match Monopam.Mono_lock.save ~fs target lock' with
7575- | Ok () -> Fmt.pr "Updated mono.lock@."
7676- | Error e -> Fmt.epr "Warning: failed to update mono.lock: %s@." e)
7777- | Error _ -> ()
8181+ (match Eio.Process.await child with `Exited 0 -> () | _ -> ());
8282+ let child =
8383+ Eio.Process.spawn proc ~sw ~cwd
8484+ [ "git"; "commit"; "-m"; Fmt.str "Remove subtree %s" dir ]
7885 in
7979- let commit_removal ~proc ~cwd dir =
8080- Eio.Switch.run @@ fun sw ->
8181- let child =
8282- Eio.Process.spawn proc ~sw ~cwd [ "git"; "add"; "-A"; dir; "mono.lock" ]
8383- in
8484- (match Eio.Process.await child with `Exited 0 -> () | _ -> ());
8585- let child =
8686- Eio.Process.spawn proc ~sw ~cwd
8787- [ "git"; "commit"; "-m"; Fmt.str "Remove subtree %s" dir ]
8888- in
8989- match Eio.Process.await child with
9090- | `Exited 0 -> Fmt.pr "Committed removal.@."
9191- | _ -> Fmt.pr "No changes to commit.@."
9292- in
9393- let run dir force no_commit dry_run () =
9494- Eio_main.run @@ fun env ->
9595- let fs = Eio.Stdenv.fs env in
9696- let proc = Eio.Stdenv.process_mgr env in
9797- let target = Fpath.v (Sys.getcwd ()) in
9898- let dir_path = Fpath.(target / dir) in
9999- let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in
100100- (match Eio.Path.kind ~follow:true eio_path with
101101- | `Directory -> ()
102102- | _ ->
103103- Fmt.epr "Error: %s is not a directory@." dir;
104104- exit 1
105105- | exception _ ->
106106- Fmt.epr "Error: directory %s does not exist@." dir;
107107- exit 1);
108108- let target_eio = Eio.Path.(fs / Fpath.to_string target) in
109109- if not force then check_uncommitted ~proc ~cwd:target_eio dir;
110110- if dry_run then begin
111111- Fmt.pr "Would remove: %s@." dir;
112112- `Ok ()
113113- end
114114- else begin
115115- Fmt.pr "Removing %s...@." dir;
116116- remove_dir ~proc ~cwd:target_eio dir;
117117- update_lock ~fs ~target dir;
118118- if not no_commit then commit_removal ~proc ~cwd:target_eio dir;
119119- Fmt.pr "Removed %s@." dir;
120120- `Ok ()
121121- end
122122- in
8686+ match Eio.Process.await child with
8787+ | `Exited 0 -> Fmt.pr "Committed removal.@."
8888+ | _ -> Fmt.pr "No changes to commit.@."
8989+9090+let validate_dir_exists ~fs dir_path dir =
9191+ let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in
9292+ match Eio.Path.kind ~follow:true eio_path with
9393+ | `Directory -> ()
9494+ | _ ->
9595+ Fmt.epr "Error: %s is not a directory@." dir;
9696+ exit 1
9797+ | exception _ ->
9898+ Fmt.epr "Error: directory %s does not exist@." dir;
9999+ exit 1
100100+101101+let run dir force no_commit dry_run () =
102102+ Eio_main.run @@ fun env ->
103103+ let fs = Eio.Stdenv.fs env in
104104+ let proc = Eio.Stdenv.process_mgr env in
105105+ let target = Fpath.v (Sys.getcwd ()) in
106106+ let dir_path = Fpath.(target / dir) in
107107+ validate_dir_exists ~fs dir_path dir;
108108+ let target_eio = Eio.Path.(fs / Fpath.to_string target) in
109109+ if not force then check_uncommitted ~proc ~cwd:target_eio dir;
110110+ if dry_run then begin
111111+ Fmt.pr "Would remove: %s@." dir;
112112+ `Ok ()
113113+ end
114114+ else begin
115115+ Fmt.pr "Removing %s...@." dir;
116116+ remove_dir ~proc ~cwd:target_eio dir;
117117+ update_lock ~fs ~target dir;
118118+ if not no_commit then commit_removal ~proc ~cwd:target_eio dir;
119119+ Fmt.pr "Removed %s@." dir;
120120+ `Ok ()
121121+ end
122122+123123+let cmd =
124124+ let doc = "Remove a subtree from the current project" in
125125+ let info = Cmd.info "remove" ~doc ~man in
123126 Cmd.v info
124127 Term.(
125128 ret
+143-138
bin/cmd_verse.ml
···11open Cmdliner
2233(* verse pull - pull from verse member *)
44+55+let pull_man =
66+ [
77+ `S Manpage.s_description;
88+ `P
99+ "Pulls commits from a verse member's forks into your local checkouts. \
1010+ This merges their changes into your checkout branches.";
1111+ `S "WORKFLOW";
1212+ `P "The typical workflow for incorporating changes from collaborators:";
1313+ `I ("1.", "$(b,monopam verse diff) - See what changes are available");
1414+ `I ("2.", "$(b,monopam verse pull <handle>) - Pull their changes");
1515+ `I ("3.", "$(b,monopam push) - Push merged changes upstream");
1616+ `S Manpage.s_examples;
1717+ `P "Pull all changes from a verse member:";
1818+ `Pre "monopam verse pull avsm.bsky.social";
1919+ `P "Pull changes for a specific repository:";
2020+ `Pre "monopam verse pull avsm.bsky.social eio";
2121+ ]
2222+2323+let pull_handle_arg =
2424+ let doc = "The verse member handle to pull from (e.g., avsm.bsky.social)." in
2525+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
2626+2727+let pull_repo_arg =
2828+ let doc = "Optional repository to pull from." in
2929+ Arg.(value & pos 1 (some string) None & info [] ~docv:"REPO" ~doc)
3030+3131+let pull_refresh_arg =
3232+ let doc = "Force fresh fetches from all remotes." in
3333+ Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
3434+3535+let handle_pull_result (result : Monopam.handle_pull_result) handle =
3636+ Fmt.pr "%a" Monopam.pp_handle_pull_result result;
3737+ if result.repos_failed <> [] then `Error (false, "some repos failed to pull")
3838+ else if result.repos_pulled = [] then begin
3939+ Fmt.pr "Nothing to pull from %s@." handle;
4040+ `Ok ()
4141+ end
4242+ else begin
4343+ Fmt.pr "@.Run $(b,monopam push) to publish merged changes.@.";
4444+ `Ok ()
4545+ end
4646+4747+let pull_inner ~proc ~fs ~config ~verse_config ~handle ~repo ~refresh =
4848+ match
4949+ Monopam.pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo
5050+ ~refresh ()
5151+ with
5252+ | Ok result -> handle_pull_result result handle
5353+ | Error e ->
5454+ Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
5555+ `Error (false, "pull failed")
5656+5757+let pull_run handle repo refresh () =
5858+ Eio_main.run @@ fun env ->
5959+ Common.with_config env @@ fun config ->
6060+ Common.with_verse_config env @@ fun verse_config ->
6161+ let fs = Eio.Stdenv.fs env in
6262+ let proc = Eio.Stdenv.process_mgr env in
6363+ pull_inner ~proc ~fs ~config ~verse_config ~handle ~repo ~refresh
6464+465let pull_cmd =
566 let doc = "Pull commits from a verse member's forks" in
66- let man =
77- [
88- `S Manpage.s_description;
99- `P
1010- "Pulls commits from a verse member's forks into your local checkouts. \
1111- This merges their changes into your checkout branches.";
1212- `S "WORKFLOW";
1313- `P "The typical workflow for incorporating changes from collaborators:";
1414- `I ("1.", "$(b,monopam verse diff) - See what changes are available");
1515- `I ("2.", "$(b,monopam verse pull <handle>) - Pull their changes");
1616- `I ("3.", "$(b,monopam push) - Push merged changes upstream");
1717- `S Manpage.s_examples;
1818- `P "Pull all changes from a verse member:";
1919- `Pre "monopam verse pull avsm.bsky.social";
2020- `P "Pull changes for a specific repository:";
2121- `Pre "monopam verse pull avsm.bsky.social eio";
2222- ]
6767+ let info = Cmd.info "pull" ~doc ~man:pull_man in
6868+ Cmd.v info
6969+ Term.(
7070+ ret
7171+ (const pull_run $ pull_handle_arg $ pull_repo_arg $ pull_refresh_arg
7272+ $ Common.logging_term))
7373+7474+(* verse diff - show diffs from verse members *)
7575+7676+let diff_man =
7777+ [
7878+ `S Manpage.s_description;
7979+ `P
8080+ "Shows commit diffs from verse members for repositories where they have \
8181+ commits you don't have.";
8282+ `S "OUTPUT";
8383+ `P "For each repository where a verse member is ahead:";
8484+ `I ("+N", "They have N commits you don't have");
8585+ `I ("+N/-M", "Diverged: they have N new commits, you have M new commits");
8686+ `S Manpage.s_examples;
8787+ `P "Show diffs for all repos:";
8888+ `Pre "monopam verse diff";
8989+ `P "Show diff for a specific repository:";
9090+ `Pre "monopam verse diff eio";
9191+ `P "Show patch for a specific commit:";
9292+ `Pre "monopam verse diff abc1234";
9393+ ]
9494+9595+let diff_arg =
9696+ let doc =
9797+ "Repository name or commit SHA. If a 7+ character hex string, shows the \
9898+ patch for that commit."
2399 in
2424- let info = Cmd.info "pull" ~doc ~man in
2525- let handle_arg =
2626- let doc =
2727- "The verse member handle to pull from (e.g., avsm.bsky.social)."
2828- in
2929- Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
100100+ Arg.(value & pos 0 (some string) None & info [] ~docv:"REPO|SHA" ~doc)
101101+102102+let diff_refresh_arg =
103103+ let doc = "Force fresh fetches from all remotes." in
104104+ Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
105105+106106+let diff_patch_arg =
107107+ let doc = "Show full patch content for each commit." in
108108+ Arg.(value & flag & info [ "patch"; "p" ] ~doc)
109109+110110+let show_commit_info (info : Monopam.commit_info) =
111111+ let short_hash =
112112+ String.sub info.commit_hash 0 (min 7 (String.length info.commit_hash))
30113 in
3131- let repo_arg =
3232- let doc = "Optional repository to pull from." in
3333- Arg.(value & pos 1 (some string) None & info [] ~docv:"REPO" ~doc)
3434- in
3535- let refresh_arg =
3636- let doc = "Force fresh fetches from all remotes." in
3737- Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
3838- in
3939- let handle_pull_result (result : Monopam.handle_pull_result) handle =
4040- Fmt.pr "%a" Monopam.pp_handle_pull_result result;
4141- if result.repos_failed <> [] then `Error (false, "some repos failed to pull")
4242- else if result.repos_pulled = [] then begin
4343- Fmt.pr "Nothing to pull from %s@." handle;
114114+ Fmt.pr "%a %s (%s/%s)@.@.%s@."
115115+ Fmt.(styled `Yellow string)
116116+ short_hash info.commit_subject info.commit_repo info.commit_handle
117117+ info.commit_patch;
118118+ `Ok ()
119119+120120+let handle_sha ~proc ~fs ~config ~verse_config ~sha ~refresh =
121121+ match
122122+ Monopam.diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh ()
123123+ with
124124+ | Some info -> show_commit_info info
125125+ | None ->
126126+ Fmt.epr "Commit %s not found in any verse diff@." sha;
127127+ `Error (false, "commit not found")
128128+129129+let diff_inner ~proc ~fs ~config ~verse_config ~arg ~refresh ~patch =
130130+ match arg with
131131+ | Some sha when Monopam.is_commit_sha sha ->
132132+ handle_sha ~proc ~fs ~config ~verse_config ~sha ~refresh
133133+ | repo ->
134134+ let result =
135135+ Monopam.diff ~proc ~fs ~config ~verse_config ?repo ~refresh ~patch ()
136136+ in
137137+ Fmt.pr "%a" (Monopam.pp_diff_result ~show_patch:patch) result;
44138 `Ok ()
4545- end
4646- else begin
4747- Fmt.pr "@.Run $(b,monopam push) to publish merged changes.@.";
4848- `Ok ()
4949- end
5050- in
5151- let pull_inner ~proc ~fs ~config ~verse_config ~handle ~repo ~refresh =
5252- match
5353- Monopam.pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo
5454- ~refresh ()
5555- with
5656- | Ok result -> handle_pull_result result handle
5757- | Error e ->
5858- Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
5959- `Error (false, "pull failed")
6060- in
6161- let run handle repo refresh () =
6262- Eio_main.run @@ fun env ->
6363- Common.with_config env @@ fun config ->
6464- Common.with_verse_config env @@ fun verse_config ->
6565- let fs = Eio.Stdenv.fs env in
6666- let proc = Eio.Stdenv.process_mgr env in
6767- pull_inner ~proc ~fs ~config ~verse_config ~handle ~repo ~refresh
6868- in
6969- Cmd.v info
7070- Term.(
7171- ret (const run $ handle_arg $ repo_arg $ refresh_arg $ Common.logging_term))
139139+140140+let diff_run arg refresh patch () =
141141+ Eio_main.run @@ fun env ->
142142+ Common.with_config env @@ fun config ->
143143+ Common.with_verse_config env @@ fun verse_config ->
144144+ let fs = Eio.Stdenv.fs env in
145145+ let proc = Eio.Stdenv.process_mgr env in
146146+ diff_inner ~proc ~fs ~config ~verse_config ~arg ~refresh ~patch
721477373-(* verse diff - show diffs from verse members *)
74148let diff_cmd =
75149 let doc = "Show diffs from verse members" in
7676- let man =
7777- [
7878- `S Manpage.s_description;
7979- `P
8080- "Shows commit diffs from verse members for repositories where they \
8181- have commits you don't have.";
8282- `S "OUTPUT";
8383- `P "For each repository where a verse member is ahead:";
8484- `I ("+N", "They have N commits you don't have");
8585- `I ("+N/-M", "Diverged: they have N new commits, you have M new commits");
8686- `S Manpage.s_examples;
8787- `P "Show diffs for all repos:";
8888- `Pre "monopam verse diff";
8989- `P "Show diff for a specific repository:";
9090- `Pre "monopam verse diff eio";
9191- `P "Show patch for a specific commit:";
9292- `Pre "monopam verse diff abc1234";
9393- ]
9494- in
9595- let info = Cmd.info "diff" ~doc ~man in
9696- let arg =
9797- let doc =
9898- "Repository name or commit SHA. If a 7+ character hex string, shows the \
9999- patch for that commit."
100100- in
101101- Arg.(value & pos 0 (some string) None & info [] ~docv:"REPO|SHA" ~doc)
102102- in
103103- let refresh_arg =
104104- let doc = "Force fresh fetches from all remotes." in
105105- Arg.(value & flag & info [ "refresh"; "r" ] ~doc)
106106- in
107107- let patch_arg =
108108- let doc = "Show full patch content for each commit." in
109109- Arg.(value & flag & info [ "patch"; "p" ] ~doc)
110110- in
111111- let show_commit_info ~sha (info : Monopam.commit_info) =
112112- let short_hash =
113113- String.sub info.commit_hash 0 (min 7 (String.length info.commit_hash))
114114- in
115115- Fmt.pr "%a %s (%s/%s)@.@.%s@."
116116- Fmt.(styled `Yellow string)
117117- short_hash info.commit_subject info.commit_repo info.commit_handle
118118- info.commit_patch;
119119- `Ok ()
120120- in
121121- let handle_sha ~proc ~fs ~config ~verse_config ~sha ~refresh =
122122- match
123123- Monopam.diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh ()
124124- with
125125- | Some info -> show_commit_info ~sha info
126126- | None ->
127127- Fmt.epr "Commit %s not found in any verse diff@." sha;
128128- `Error (false, "commit not found")
129129- in
130130- let diff_inner ~proc ~fs ~config ~verse_config ~arg ~refresh ~patch =
131131- match arg with
132132- | Some sha when Monopam.is_commit_sha sha ->
133133- handle_sha ~proc ~fs ~config ~verse_config ~sha ~refresh
134134- | repo ->
135135- let result =
136136- Monopam.diff ~proc ~fs ~config ~verse_config ?repo ~refresh ~patch ()
137137- in
138138- Fmt.pr "%a" (Monopam.pp_diff_result ~show_patch:patch) result;
139139- `Ok ()
140140- in
141141- let run arg refresh patch () =
142142- Eio_main.run @@ fun env ->
143143- Common.with_config env @@ fun config ->
144144- Common.with_verse_config env @@ fun verse_config ->
145145- let fs = Eio.Stdenv.fs env in
146146- let proc = Eio.Stdenv.process_mgr env in
147147- diff_inner ~proc ~fs ~config ~verse_config ~arg ~refresh ~patch
148148- in
150150+ let info = Cmd.info "diff" ~doc ~man:diff_man in
149151 Cmd.v info
150150- Term.(ret (const run $ arg $ refresh_arg $ patch_arg $ Common.logging_term))
152152+ Term.(
153153+ ret
154154+ (const diff_run $ diff_arg $ diff_refresh_arg $ diff_patch_arg
155155+ $ Common.logging_term))
151156152157(* verse cherrypick - cherry-pick specific commit *)
153158let cherrypick_cmd =
+46-46
bin/main.ml
···4455(* Main entry point - compose all subcommands *)
6677+let man =
88+ [
99+ `S Manpage.s_description;
1010+ `P
1111+ "$(b,monopam) manages OCaml packages in a monorepo structure. It syncs \
1212+ your monorepo with upstream git repositories.";
1313+ `S "QUICK START";
1414+ `P "Initialize a new workspace:";
1515+ `Pre "monopam init --handle yourname.bsky.social";
1616+ `P "Check the status of your packages:";
1717+ `Pre "monopam status";
1818+ `P "Pull latest changes from upstream:";
1919+ `Pre "monopam pull";
2020+ `P "Push your changes to upstream:";
2121+ `Pre "monopam push";
2222+ `S "CORE WORKFLOW";
2323+ `P "Commands match the git mental model:";
2424+ `I ("$(b,monopam add)", "Add a package (subtree) from a git URL");
2525+ `I ("$(b,monopam remove)", "Remove a package from the project");
2626+ `I ("$(b,monopam pull)", "Fetch and merge upstream changes into mono/");
2727+ `I ("$(b,monopam push)", "Push your mono/ changes to upstream remotes");
2828+ `I ("$(b,monopam fetch)", "Fetch upstream changes without merging");
2929+ `I ("$(b,monopam status)", "Show what's out of sync");
3030+ `I ("$(b,monopam diff)", "Show diff between mono/ and upstream");
3131+ `I ("$(b,monopam publish)", "Publish packages to opam-repo");
3232+ `S "TYPICAL SESSION";
3333+ `Pre
3434+ "# Start by pulling latest\n\
3535+ monopam pull\n\n\
3636+ # Make your changes\n\
3737+ vim mono/eio/lib/core.ml\n\n\
3838+ # Build and test\n\
3939+ dune build && dune test\n\n\
4040+ # Commit\n\
4141+ git add -A && git commit -m \"Add feature\"\n\n\
4242+ # Push to upstream\n\
4343+ monopam push";
4444+ `S "VERSE COLLABORATION";
4545+ `P "Collaborate with other developers via the verse system:";
4646+ `I ("$(b,monopam verse diff)", "See changes from collaborators");
4747+ `I ("$(b,monopam verse pull <handle>)", "Pull from a collaborator");
4848+ `I ("$(b,monopam verse status)", "Show verse member status");
4949+ `S Manpage.s_commands;
5050+ `P "Use $(b,monopam COMMAND --help) for help on a specific command.";
5151+ ]
5252+753let cmd =
854 let doc = "Monorepo package manager for OCaml" in
99- let man =
1010- [
1111- `S Manpage.s_description;
1212- `P
1313- "$(b,monopam) manages OCaml packages in a monorepo structure. It syncs \
1414- your monorepo with upstream git repositories.";
1515- `S "QUICK START";
1616- `P "Initialize a new workspace:";
1717- `Pre "monopam init --handle yourname.bsky.social";
1818- `P "Check the status of your packages:";
1919- `Pre "monopam status";
2020- `P "Pull latest changes from upstream:";
2121- `Pre "monopam pull";
2222- `P "Push your changes to upstream:";
2323- `Pre "monopam push";
2424- `S "CORE WORKFLOW";
2525- `P "Commands match the git mental model:";
2626- `I ("$(b,monopam add)", "Add a package (subtree) from a git URL");
2727- `I ("$(b,monopam remove)", "Remove a package from the project");
2828- `I ("$(b,monopam pull)", "Fetch and merge upstream changes into mono/");
2929- `I ("$(b,monopam push)", "Push your mono/ changes to upstream remotes");
3030- `I ("$(b,monopam fetch)", "Fetch upstream changes without merging");
3131- `I ("$(b,monopam status)", "Show what's out of sync");
3232- `I ("$(b,monopam diff)", "Show diff between mono/ and upstream");
3333- `I ("$(b,monopam publish)", "Publish packages to opam-repo");
3434- `S "TYPICAL SESSION";
3535- `Pre
3636- "# Start by pulling latest\n\
3737- monopam pull\n\n\
3838- # Make your changes\n\
3939- vim mono/eio/lib/core.ml\n\n\
4040- # Build and test\n\
4141- dune build && dune test\n\n\
4242- # Commit\n\
4343- git add -A && git commit -m \"Add feature\"\n\n\
4444- # Push to upstream\n\
4545- monopam push";
4646- `S "VERSE COLLABORATION";
4747- `P "Collaborate with other developers via the verse system:";
4848- `I ("$(b,monopam verse diff)", "See changes from collaborators");
4949- `I ("$(b,monopam verse pull <handle>)", "Pull from a collaborator");
5050- `I ("$(b,monopam verse status)", "Show verse member status");
5151- `S Manpage.s_commands;
5252- `P "Use $(b,monopam COMMAND --help) for help on a specific command.";
5353- ]
5454- in
5555 let info = Cmd.info "monopam" ~version ~doc ~man in
5656 Cmd.group info
5757 [
+170-306
lib/changes.ml
···245245 cf.entries;
246246 Buffer.contents buf
247247248248-let aggregate ~history (cfs : file list) =
249249- (* Collect all entries from all files, tagged with repository *)
250250- let all_entries =
251251- List.concat_map
252252- (fun (cf : file) ->
253253- List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries)
254254- cfs
255255- in
256256- (* Sort by week_start descending *)
257257- let sorted =
258258- List.sort
259259- (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) ->
260260- String.compare e2.week_start e1.week_start)
261261- all_entries
262262- in
263263- (* Group by week *)
264264- let rec group_by_week acc current_week current_group = function
248248+let group_weekly_entries sorted =
249249+ let rec loop acc current_week current_group = function
265250 | [] ->
266251 if current_group <> [] then
267252 (current_week, List.rev current_group) :: acc
···269254 | (repo, (entry : weekly_entry)) :: rest ->
270255 let week_key = entry.week_start ^ " to " ^ entry.week_end in
271256 if current_week = "" || current_week = week_key then
272272- group_by_week acc week_key ((repo, entry) :: current_group) rest
257257+ loop acc week_key ((repo, entry) :: current_group) rest
273258 else
274274- group_by_week
259259+ loop
275260 ((current_week, List.rev current_group) :: acc)
276261 week_key
277262 [ (repo, entry) ]
278263 rest
279264 in
280280- let grouped = List.rev (group_by_week [] "" [] sorted) in
281281- (* Take only the requested number of weeks *)
265265+ List.rev (loop [] "" [] sorted)
266266+267267+let aggregate ~history (cfs : file list) =
268268+ let all_entries =
269269+ List.concat_map
270270+ (fun (cf : file) ->
271271+ List.map (fun (e : weekly_entry) -> (cf.repository, e)) cf.entries)
272272+ cfs
273273+ in
274274+ let sorted =
275275+ List.sort
276276+ (fun (_, (e1 : weekly_entry)) (_, (e2 : weekly_entry)) ->
277277+ String.compare e2.week_start e1.week_start)
278278+ all_entries
279279+ in
280280+ let grouped = group_weekly_entries sorted in
282281 let limited =
283282 if history > 0 then List.filteri (fun i _ -> i < history) grouped
284283 else grouped
285284 in
286286- (* Generate markdown *)
287285 let buf = Buffer.create 4096 in
288286 Buffer.add_string buf "# Changelog\n\n";
289287 List.iter
···474472475473(* Claude prompt generation *)
476474477477-let generate_weekly_prompt ~repository ~week_start ~week_end commits =
478478- let buf = Buffer.create 4096 in
475475+let format_commit_block buf (commit : Git.Repository.log_entry) =
479476 Buffer.add_string buf
480480- (Fmt.str "You are analyzing git commits for the OCaml library \"%s\".\n"
481481- repository);
482482- Buffer.add_string buf
483483- (Fmt.str
484484- "Generate a user-facing changelog entry for the week of %s to %s.\n\n"
485485- week_start week_end);
486486- Buffer.add_string buf "## Commits this week:\n\n";
487487- List.iter
488488- (fun (commit : Git.Repository.log_entry) ->
489489- Buffer.add_string buf
490490- (Fmt.str "### %s by %s (%s)\n"
491491- (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
492492- commit.author commit.date);
493493- Buffer.add_string buf (Fmt.str "%s\n\n" commit.subject);
494494- if commit.body <> "" then begin
495495- Buffer.add_string buf (Fmt.str "%s\n" commit.body)
496496- end;
497497- Buffer.add_string buf "---\n\n")
498498- commits;
499499- Buffer.add_string buf
500500- {|## Instructions:
477477+ (Fmt.str "### %s by %s (%s)\n"
478478+ (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
479479+ commit.author commit.date);
480480+ Buffer.add_string buf (Fmt.str "%s\n\n" commit.subject);
481481+ if commit.body <> "" then begin
482482+ Buffer.add_string buf (Fmt.str "%s\n" commit.body)
483483+ end;
484484+ Buffer.add_string buf "---\n\n"
485485+486486+let changelog_instructions =
487487+ {|## Instructions:
5014885024891. Focus on USER-FACING changes only. Skip:
503490 - Internal refactoring with no API impact
···525512 - Action-oriented (start with verbs: Added, Fixed, Improved, Removed)
5265135275145. Maximum 5 bullet points. Group related changes if needed.
528528-|};
515515+|}
516516+517517+let generate_weekly_prompt ~repository ~week_start ~week_end commits =
518518+ let buf = Buffer.create 4096 in
519519+ Buffer.add_string buf
520520+ (Fmt.str "You are analyzing git commits for the OCaml library \"%s\".\n"
521521+ repository);
522522+ Buffer.add_string buf
523523+ (Fmt.str
524524+ "Generate a user-facing changelog entry for the week of %s to %s.\n\n"
525525+ week_start week_end);
526526+ Buffer.add_string buf "## Commits this week:\n\n";
527527+ List.iter (format_commit_block buf) commits;
528528+ Buffer.add_string buf changelog_instructions;
529529 Buffer.contents buf
530530531531let generate_daily_prompt ~repository ~date commits =
···536536 Buffer.add_string buf
537537 (Fmt.str "Generate a user-facing changelog entry for %s.\n\n" date);
538538 Buffer.add_string buf "## Commits today:\n\n";
539539- List.iter
540540- (fun (commit : Git.Repository.log_entry) ->
541541- Buffer.add_string buf
542542- (Fmt.str "### %s by %s (%s)\n"
543543- (String.sub commit.hash 0 (min 7 (String.length commit.hash)))
544544- commit.author commit.date);
545545- Buffer.add_string buf (Fmt.str "%s\n\n" commit.subject);
546546- if commit.body <> "" then begin
547547- Buffer.add_string buf (Fmt.str "%s\n" commit.body)
548548- end;
549549- Buffer.add_string buf "---\n\n")
550550- commits;
551551- Buffer.add_string buf
552552- {|## Instructions:
553553-554554-1. Focus on USER-FACING changes only. Skip:
555555- - Internal refactoring with no API impact
556556- - CI/build system tweaks
557557- - Typo fixes in code comments
558558- - Dependency bumps (unless they add features)
559559-560560-2. IMPORTANT: If there are NO user-facing changes, output a blank entry with empty
561561- summary and empty changes array. Do NOT write "no changes" or similar text.
562562- Example for no changes: {"summary": "", "changes": []}
563563-564564-3. Otherwise, respond in this exact JSON format:
565565-{
566566- "summary": "One sentence describing the most important change",
567567- "changes": [
568568- "First user-facing change as a bullet point",
569569- "Second change",
570570- "..."
571571- ]
572572-}
573573-574574-4. Write for developers using this library. Be:
575575- - Concise (max 80 chars per bullet)
576576- - Specific (mention function/module names)
577577- - Action-oriented (start with verbs: Added, Fixed, Improved, Removed)
578578-579579-5. Maximum 5 bullet points. Group related changes if needed.
580580-|};
539539+ List.iter (format_commit_block buf) commits;
540540+ Buffer.add_string buf changelog_instructions;
581541 Buffer.contents buf
582542583543(* Backwards compatibility *)
···608568609569(* Main analysis function *)
610570571571+let changelog_output_schema =
572572+ let open Jsont in
573573+ Object
574574+ ( [
575575+ (("type", Meta.none), String ("object", Meta.none));
576576+ ( ("properties", Meta.none),
577577+ Object
578578+ ( [
579579+ ( ("summary", Meta.none),
580580+ Object
581581+ ( [ (("type", Meta.none), String ("string", Meta.none)) ],
582582+ Meta.none ) );
583583+ ( ("changes", Meta.none),
584584+ Object
585585+ ( [
586586+ (("type", Meta.none), String ("array", Meta.none));
587587+ ( ("items", Meta.none),
588588+ Object
589589+ ( [
590590+ ( ("type", Meta.none),
591591+ String ("string", Meta.none) );
592592+ ],
593593+ Meta.none ) );
594594+ ],
595595+ Meta.none ) );
596596+ ],
597597+ Meta.none ) );
598598+ ( ("required", Meta.none),
599599+ Array
600600+ ( [ String ("summary", Meta.none); String ("changes", Meta.none) ],
601601+ Meta.none ) );
602602+ ],
603603+ Meta.none )
604604+605605+let process_claude_responses responses =
606606+ let result = ref None in
607607+ List.iter
608608+ (function
609609+ | Claude.Response.Complete c -> (
610610+ match Claude.Response.Complete.structured_output c with
611611+ | Some json -> (
612612+ match Jsont.Json.decode claude_response_jsont json with
613613+ | Ok r ->
614614+ if r.summary = "" && r.changes = [] then
615615+ result := Some (Ok None)
616616+ else result := Some (Ok (Some r))
617617+ | Error e -> result := Some (err_decode e))
618618+ | None -> (
619619+ match Claude.Response.Complete.result_text c with
620620+ | Some text -> result := Some (parse_claude_response text)
621621+ | None -> result := Some (Ok None)))
622622+ | Claude.Response.Text t ->
623623+ let text = Claude.Response.Text.content t in
624624+ if String.trim text = "NO_CHANGES" then result := Some (Ok None)
625625+ | Claude.Response.Error e ->
626626+ result :=
627627+ Some
628628+ (Error
629629+ (Fmt.str "Claude error: %s" (Claude.Response.Error.message e)))
630630+ | _ -> ())
631631+ responses;
632632+ match !result with Some r -> r | None -> Ok None
633633+634634+let run_claude_analysis ~sw ~process_mgr ~clock prompt =
635635+ let output_format =
636636+ Claude.Proto.Structured_output.of_json_schema changelog_output_schema
637637+ in
638638+ let options =
639639+ Claude.Options.default
640640+ |> Claude.Options.with_output_format output_format
641641+ |> Claude.Options.with_max_turns 1
642642+ in
643643+ let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
644644+ Claude.Client.query client prompt;
645645+ let responses = Claude.Client.receive_all client in
646646+ process_claude_responses responses
647647+611648let analyze_commits ~sw ~process_mgr ~clock ~repository ~week_start ~week_end
612649 commits =
613650 if commits = [] then Ok None
614614- else begin
651651+ else
615652 let prompt = generate_prompt ~repository ~week_start ~week_end commits in
616616-617617- (* Create Claude options with structured output *)
618618- let output_schema =
619619- let open Jsont in
620620- Object
621621- ( [
622622- (("type", Meta.none), String ("object", Meta.none));
623623- ( ("properties", Meta.none),
624624- Object
625625- ( [
626626- ( ("summary", Meta.none),
627627- Object
628628- ( [ (("type", Meta.none), String ("string", Meta.none)) ],
629629- Meta.none ) );
630630- ( ("changes", Meta.none),
631631- Object
632632- ( [
633633- (("type", Meta.none), String ("array", Meta.none));
634634- ( ("items", Meta.none),
635635- Object
636636- ( [
637637- ( ("type", Meta.none),
638638- String ("string", Meta.none) );
639639- ],
640640- Meta.none ) );
641641- ],
642642- Meta.none ) );
643643- ],
644644- Meta.none ) );
645645- ( ("required", Meta.none),
646646- Array
647647- ( [
648648- String ("summary", Meta.none); String ("changes", Meta.none);
649649- ],
650650- Meta.none ) );
651651- ],
652652- Meta.none )
653653- in
654654- let output_format =
655655- Claude.Proto.Structured_output.of_json_schema output_schema
656656- in
657657- let options =
658658- Claude.Options.default
659659- |> Claude.Options.with_output_format output_format
660660- |> Claude.Options.with_max_turns 1
661661- in
662662-663663- let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
664664- Claude.Client.query client prompt;
665665-666666- let responses = Claude.Client.receive_all client in
667667- let result = ref None in
668668- List.iter
669669- (function
670670- | Claude.Response.Complete c -> (
671671- match Claude.Response.Complete.structured_output c with
672672- | Some json -> (
673673- match Jsont.Json.decode claude_response_jsont json with
674674- | Ok r -> result := Some (Ok (Some r))
675675- | Error e -> result := Some (err_decode e))
676676- | None -> (
677677- (* Try to get text and parse it as fallback *)
678678- match Claude.Response.Complete.result_text c with
679679- | Some text -> result := Some (parse_claude_response text)
680680- | None -> result := Some (Ok None)))
681681- | Claude.Response.Text t ->
682682- let text = Claude.Response.Text.content t in
683683- if String.trim text = "NO_CHANGES" then result := Some (Ok None)
684684- | Claude.Response.Error e ->
685685- result :=
686686- Some
687687- (Error
688688- (Fmt.str "Claude error: %s"
689689- (Claude.Response.Error.message e)))
690690- | _ -> ())
691691- responses;
692692-693693- match !result with Some r -> r | None -> Ok None
694694- end
653653+ run_claude_analysis ~sw ~process_mgr ~clock prompt
695654696655(* Daily analysis function *)
697656let analyze_commits_daily ~sw ~process_mgr ~clock ~repository ~date commits =
698657 if commits = [] then Ok None
699699- else begin
658658+ else
700659 let prompt = generate_daily_prompt ~repository ~date commits in
701701-702702- (* Create Claude options with structured output *)
703703- let output_schema =
704704- let open Jsont in
705705- Object
706706- ( [
707707- (("type", Meta.none), String ("object", Meta.none));
708708- ( ("properties", Meta.none),
709709- Object
710710- ( [
711711- ( ("summary", Meta.none),
712712- Object
713713- ( [ (("type", Meta.none), String ("string", Meta.none)) ],
714714- Meta.none ) );
715715- ( ("changes", Meta.none),
716716- Object
717717- ( [
718718- (("type", Meta.none), String ("array", Meta.none));
719719- ( ("items", Meta.none),
720720- Object
721721- ( [
722722- ( ("type", Meta.none),
723723- String ("string", Meta.none) );
724724- ],
725725- Meta.none ) );
726726- ],
727727- Meta.none ) );
728728- ],
729729- Meta.none ) );
730730- ( ("required", Meta.none),
731731- Array
732732- ( [
733733- String ("summary", Meta.none); String ("changes", Meta.none);
734734- ],
735735- Meta.none ) );
736736- ],
737737- Meta.none )
738738- in
739739- let output_format =
740740- Claude.Proto.Structured_output.of_json_schema output_schema
741741- in
742742- let options =
743743- Claude.Options.default
744744- |> Claude.Options.with_output_format output_format
745745- |> Claude.Options.with_max_turns 1
746746- in
747747-748748- let client = Claude.Client.create ~sw ~process_mgr ~clock ~options () in
749749- Claude.Client.query client prompt;
750750-751751- let responses = Claude.Client.receive_all client in
752752- let result = ref None in
753753- List.iter
754754- (function
755755- | Claude.Response.Complete c -> (
756756- match Claude.Response.Complete.structured_output c with
757757- | Some json -> (
758758- match Jsont.Json.decode claude_response_jsont json with
759759- | Ok r ->
760760- (* Treat empty response as no changes *)
761761- if r.summary = "" && r.changes = [] then
762762- result := Some (Ok None)
763763- else result := Some (Ok (Some r))
764764- | Error e -> result := Some (err_decode e))
765765- | None -> (
766766- (* Try to get text and parse it as fallback *)
767767- match Claude.Response.Complete.result_text c with
768768- | Some text -> result := Some (parse_claude_response text)
769769- | None -> result := Some (Ok None)))
770770- | Claude.Response.Text t ->
771771- let text = Claude.Response.Text.content t in
772772- if String.trim text = "NO_CHANGES" then result := Some (Ok None)
773773- | Claude.Response.Error e ->
774774- result :=
775775- Some
776776- (Error
777777- (Fmt.str "Claude error: %s"
778778- (Claude.Response.Error.message e)))
779779- | _ -> ())
780780- responses;
781781-782782- match !result with Some r -> r | None -> Ok None
783783- end
660660+ run_claude_analysis ~sw ~process_mgr ~clock prompt
784661785662(* Refine daily changelog markdown to be more narrative *)
786663let refine_daily_changelog ~sw ~process_mgr ~clock markdown =
···893770 then Changes_aggregated.Feature
894771 else Changes_aggregated.Unknown
895772773773+let load_daily_entries_for_date changes_dir daily_files date_suffix_len =
774774+ List.concat_map
775775+ (fun filename ->
776776+ let repo_name =
777777+ String.sub filename 0 (String.length filename - date_suffix_len)
778778+ in
779779+ let path = Eio.Path.(changes_dir / filename) in
780780+ try
781781+ let content = Eio.Path.load path in
782782+ match Jsont_bytesrw.decode_string daily_file_jsont content with
783783+ | Ok dcf ->
784784+ List.filter_map
785785+ (fun (e : daily_entry) ->
786786+ if e.changes <> [] then Some (repo_name, e) else None)
787787+ dcf.entries
788788+ | Error _ -> []
789789+ with Eio.Io _ -> [])
790790+ daily_files
791791+792792+let daily_entry_to_aggregated (repo_name, (e : daily_entry)) =
793793+ let change_type = infer_change_type e.summary in
794794+ Changes_aggregated.
795795+ {
796796+ repository = repo_name;
797797+ hour = e.hour;
798798+ timestamp = e.timestamp;
799799+ summary = e.summary;
800800+ changes = e.changes;
801801+ commit_range =
802802+ {
803803+ from_hash = e.commit_range.from_hash;
804804+ to_hash = e.commit_range.to_hash;
805805+ count = e.commit_range.count;
806806+ };
807807+ contributors = e.contributors;
808808+ repo_url = e.repo_url;
809809+ change_type;
810810+ }
811811+896812(** Generate an aggregated daily file from individual daily json files. This
897813 creates a YYYYMMDD.json file in the .changes directory. *)
898814let generate_aggregated ~fs ~monorepo ~date ~git_head ~now =
899815 let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in
900900-901901- (* List all *-<date>.json files (new per-day format) *)
902816 let files = try Eio.Path.read_dir changes_dir with Eio.Io _ -> [] in
903903- (* Match files like "<repo>-2026-01-19.json" for the given date *)
904817 let date_suffix = "-" ^ date ^ ".json" in
905818 let date_suffix_len = String.length date_suffix in
906819 let daily_files =
···910823 && String.length f > date_suffix_len)
911824 files
912825 in
913913-914914- (* Load all daily files for this date and collect entries *)
915826 let entries =
916916- List.concat_map
917917- (fun filename ->
918918- (* Extract repo name: filename is "<repo>-<date>.json" *)
919919- let repo_name =
920920- String.sub filename 0 (String.length filename - date_suffix_len)
921921- in
922922- let path = Eio.Path.(changes_dir / filename) in
923923- try
924924- let content = Eio.Path.load path in
925925- match Jsont_bytesrw.decode_string daily_file_jsont content with
926926- | Ok dcf ->
927927- List.filter_map
928928- (fun (e : daily_entry) ->
929929- if e.changes <> [] then Some (repo_name, e) else None)
930930- dcf.entries
931931- | Error _ -> []
932932- with Eio.Io _ -> [])
933933- daily_files
827827+ load_daily_entries_for_date changes_dir daily_files date_suffix_len
934828 in
935935-936936- (* Convert to aggregated format *)
937937- let agg_entries =
938938- List.map
939939- (fun (repo_name, (e : daily_entry)) ->
940940- let change_type = infer_change_type e.summary in
941941- Changes_aggregated.
942942- {
943943- repository = repo_name;
944944- hour = e.hour;
945945- timestamp = e.timestamp;
946946- summary = e.summary;
947947- changes = e.changes;
948948- commit_range =
949949- {
950950- from_hash = e.commit_range.from_hash;
951951- to_hash = e.commit_range.to_hash;
952952- count = e.commit_range.count;
953953- };
954954- contributors = e.contributors;
955955- repo_url = e.repo_url;
956956- change_type;
957957- })
958958- entries
959959- in
960960-961961- (* Collect all unique authors *)
829829+ let agg_entries = List.map daily_entry_to_aggregated entries in
962830 let authors =
963831 entries
964832 |> List.concat_map (fun (_, (e : daily_entry)) -> e.contributors)
965833 |> List.sort_uniq String.compare
966834 in
967967-968968- (* Create the aggregated structure *)
969835 let aggregated : Changes_aggregated.t =
970836 { date; generated_at = now; git_head; entries = agg_entries; authors }
971837 in
972972-973973- (* Save to YYYYMMDD.json *)
974838 let changes_dir_fpath = Fpath.(v (Fpath.to_string monorepo) / ".changes") in
975839 Changes_aggregated.save ~fs ~changes_dir:changes_dir_fpath aggregated
+35-48
lib/changes_daily.ml
···184184 let dates = list_dates ~fs ~changes_dir ~repo in
185185 List.concat_map (fun date -> load_file ~fs ~changes_dir ~repo ~date) dates
186186187187+let build_by_repo_map (days : day list) : day list String_map.t =
188188+ let by_repo =
189189+ List.fold_left
190190+ (fun acc (d : day) ->
191191+ let existing =
192192+ String_map.find_opt d.repository acc |> Option.value ~default:[]
193193+ in
194194+ String_map.add d.repository (d :: existing) acc)
195195+ String_map.empty days
196196+ in
197197+ String_map.map
198198+ (fun (ds : day list) ->
199199+ List.sort (fun (d1 : day) (d2 : day) -> String.compare d2.date d1.date) ds)
200200+ by_repo
201201+202202+let build_by_date_map (days : day list) : day list String_map.t =
203203+ let by_date =
204204+ List.fold_left
205205+ (fun acc (d : day) ->
206206+ let existing =
207207+ String_map.find_opt d.date acc |> Option.value ~default:[]
208208+ in
209209+ String_map.add d.date (d :: existing) acc)
210210+ String_map.empty days
211211+ in
212212+ String_map.map
213213+ (fun (ds : day list) ->
214214+ List.sort
215215+ (fun (d1 : day) (d2 : day) ->
216216+ String.compare d1.repository d2.repository)
217217+ ds)
218218+ by_date
219219+187220let load_all ~fs ~changes_dir =
188221 let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in
189222 match Eio.Path.kind ~follow:true dir_path with
190223 | `Directory ->
191224 let files = Eio.Path.read_dir dir_path in
192225 let parsed_files = List.filter_map parse_daily_filename files in
193193-194194- (* Load all files and build days *)
195226 let days : day list =
196227 List.filter_map
197228 (fun (repo, date) ->
···209240 Some ({ repository = repo; date; entries = sorted_entries } : day))
210241 parsed_files
211242 in
212212-213213- (* Build by_repo map *)
214214- let by_repo : day list String_map.t =
215215- List.fold_left
216216- (fun acc (d : day) ->
217217- let existing =
218218- String_map.find_opt d.repository acc |> Option.value ~default:[]
219219- in
220220- String_map.add d.repository (d :: existing) acc)
221221- String_map.empty days
222222- in
223223-224224- (* Sort each repo's days by date descending *)
225225- let by_repo : day list String_map.t =
226226- String_map.map
227227- (fun (ds : day list) ->
228228- List.sort
229229- (fun (d1 : day) (d2 : day) -> String.compare d2.date d1.date)
230230- ds)
231231- by_repo
232232- in
233233-234234- (* Build by_date map *)
235235- let by_date : day list String_map.t =
236236- List.fold_left
237237- (fun acc (d : day) ->
238238- let existing =
239239- String_map.find_opt d.date acc |> Option.value ~default:[]
240240- in
241241- String_map.add d.date (d :: existing) acc)
242242- String_map.empty days
243243- in
244244-245245- (* Sort each date's days by repo name *)
246246- let by_date : day list String_map.t =
247247- String_map.map
248248- (fun (ds : day list) ->
249249- List.sort
250250- (fun (d1 : day) (d2 : day) ->
251251- String.compare d1.repository d2.repository)
252252- ds)
253253- by_date
254254- in
255255-256256- (* Collect all entries sorted by timestamp *)
243243+ let by_repo = build_by_repo_map days in
244244+ let by_date = build_by_date_map days in
257245 let all_entries : entry list =
258246 days
259247 |> List.concat_map (fun (d : day) -> d.entries)
260248 |> List.sort (fun (e1 : entry) (e2 : entry) ->
261249 Ptime.compare e1.timestamp e2.timestamp)
262250 in
263263-264251 { by_repo; by_date; all_entries }
265252 | _ -> empty
266253 | exception Eio.Io _ -> empty
+48-60
lib/clean.ml
···2929 Log.app (fun m -> m " ✓ %s cleaned" name);
3030 Some issue_count
31313232+(** {1 Helpers} *)
3333+3434+let check_and_fix ~fs_t ~dry_run ~name ~check_fn ~fix_fn path =
3535+ if not (Git.Repository.is_repo ~fs:fs_t path) then None
3636+ else
3737+ let repo = Git.Repository.open_repo ~fs:fs_t path in
3838+ match Git.Repository.head repo with
3939+ | None -> None
4040+ | Some head ->
4141+ let checked, issues = check_fn repo ~head in
4242+ if issues = [] then None
4343+ else begin
4444+ Log.app (fun m ->
4545+ m "%s: %d issues (of %d checked)" name (List.length issues)
4646+ checked);
4747+ apply_fix ~name ~repo ~dry_run
4848+ ~fix_fn:(fun () -> fix_fn repo ~head)
4949+ ~issue_count:(List.length issues)
5050+ end
5151+5252+let force_push_checkouts ~proc ~fs_t ~checkouts_path ~checkouts =
5353+ Log.app (fun m -> m "Force-pushing cleaned histories to upstream...");
5454+ let push name =
5555+ let path = Fpath.(checkouts / name) in
5656+ if Git.Repository.is_repo ~fs:fs_t path then
5757+ match
5858+ Git_cli.push_remote ~proc ~fs:(fs_t :> _ Eio.Path.t) ~force:true path
5959+ with
6060+ | Ok () -> Log.app (fun m -> m " ✓ %s" name)
6161+ | Error e -> Log.app (fun m -> m " ✗ %s: %a" name Git_cli.pp_error e)
6262+ in
6363+ try Eio.Path.read_dir checkouts_path |> List.iter push with Eio.Io _ -> ()
6464+3265(** {1 Main Clean Operation} *)
33663467let run ~proc ~fs ~config ~dry_run ~force () =
3568 let fs_t = Ctx.fs_typed fs in
3669 let mono = Config.Paths.monorepo config in
3770 let checkouts = Config.Paths.checkouts config in
7171+ let checkouts_path = Eio.Path.(fs_t / Fpath.to_string checkouts) in
38723939- let clean_mono () =
4040- if not (Git.Repository.is_repo ~fs:fs_t mono) then None
4141- else
4242- let repo = Git.Repository.open_repo ~fs:fs_t mono in
4343- match Git.Repository.head repo with
4444- | None -> None
4545- | Some head ->
4646- let checked, issues = Git.Subtree.check_mono repo ~head () in
4747- if issues = [] then None
4848- else begin
4949- Log.app (fun m ->
5050- m "mono: %d empty commits (of %d checked)" (List.length issues)
5151- checked);
5252- apply_fix ~name:"mono" ~repo ~dry_run
5353- ~fix_fn:(fun () -> Git.Subtree.fix_mono repo ~head ())
5454- ~issue_count:(List.length issues)
5555- end
5656- in
5757-5858- let clean_checkout name =
5959- let path = Fpath.(checkouts / name) in
6060- if not (Git.Repository.is_repo ~fs:fs_t path) then None
6161- else
6262- let repo = Git.Repository.open_repo ~fs:fs_t path in
6363- match Git.Repository.head repo with
6464- | None -> None
6565- | Some head ->
6666- let checked, issues = Git.Subtree.check repo ~prefix:name ~head () in
6767- if issues = [] then None
6868- else begin
6969- Log.app (fun m ->
7070- m "%s: %d unrelated merges (of %d checked)" name
7171- (List.length issues) checked);
7272- apply_fix ~name ~repo ~dry_run
7373- ~fix_fn:(fun () -> Git.Subtree.fix repo ~prefix:name ~head ())
7474- ~issue_count:(List.length issues)
7575- end
7373+ let mono_cleaned =
7474+ check_and_fix ~fs_t ~dry_run ~name:"mono"
7575+ ~check_fn:(fun repo ~head -> Git.Subtree.check_mono repo ~head ())
7676+ ~fix_fn:(fun repo ~head -> Git.Subtree.fix_mono repo ~head ())
7777+ mono
7678 in
7777-7878- let mono_cleaned = clean_mono () in
7979-8080- let checkouts_path = Eio.Path.(fs_t / Fpath.to_string checkouts) in
8179 let checkout_results =
8080+ let clean_checkout name =
8181+ check_and_fix ~fs_t ~dry_run ~name
8282+ ~check_fn:(fun repo ~head ->
8383+ Git.Subtree.check repo ~prefix:name ~head ())
8484+ ~fix_fn:(fun repo ~head -> Git.Subtree.fix repo ~prefix:name ~head ())
8585+ Fpath.(checkouts / name)
8686+ in
8287 try Eio.Path.read_dir checkouts_path |> List.filter_map clean_checkout
8388 with Eio.Io _ -> []
8489 in
···8792 Option.value ~default:0 mono_cleaned
8893 + List.fold_left ( + ) 0 checkout_results
8994 in
9090-9195 if total_cleaned = 0 then begin
9296 Log.app (fun m -> m "No empty commits found");
9397 Ok ()
···100104 end
101105 else begin
102106 Log.app (fun m -> m "Removed %d commits" total_cleaned);
103103- if force then begin
104104- Log.app (fun m -> m "Force-pushing cleaned histories to upstream...");
105105- let push_checkout name =
106106- let path = Fpath.(checkouts / name) in
107107- if Git.Repository.is_repo ~fs:fs_t path then
108108- match
109109- Git_cli.push_remote ~proc
110110- ~fs:(fs_t :> _ Eio.Path.t)
111111- ~force:true path
112112- with
113113- | Ok () -> Log.app (fun m -> m " ✓ %s" name)
114114- | Error e -> Log.app (fun m -> m " ✗ %s: %a" name Git_cli.pp_error e)
115115- in
116116- (try Eio.Path.read_dir checkouts_path |> List.iter push_checkout
117117- with Eio.Io _ -> ());
118118- Ok ()
119119- end
120120- else Ok ()
107107+ if force then force_push_checkouts ~proc ~fs_t ~checkouts_path ~checkouts;
108108+ Ok ()
121109 end
+39-54
lib/cross_status.ml
···202202 Diverged { my_ahead; their_ahead })
203203 end
204204205205-(** Compute cross-user status comparing my monorepo against all verse members.
206206-*)
205205+let compare_my_repo ~fs ~my_mono ~checkouts ~verse_subtrees repo_name =
206206+ let my_info = subtree_info ~fs ~monorepo_path:my_mono ~prefix:repo_name () in
207207+ let checkout_path = Fpath.(checkouts / repo_name) in
208208+ let others_with_repo =
209209+ try Hashtbl.find verse_subtrees repo_name with Not_found -> []
210210+ in
211211+ if others_with_repo = [] then None
212212+ else begin
213213+ let others =
214214+ List.map
215215+ (fun (handle, their_mono) ->
216216+ let their_info =
217217+ subtree_info ~fs ~monorepo_path:their_mono ~prefix:repo_name ()
218218+ in
219219+ let rel =
220220+ compare_commits ~fs ~checkout_path
221221+ ~my_commit:my_info.upstream_commit
222222+ ~their_commit:their_info.upstream_commit ()
223223+ in
224224+ (handle, their_info, rel))
225225+ others_with_repo
226226+ in
227227+ Some { repo_name; my_info = Some my_info; others }
228228+ end
229229+230230+let other_repos ~my_subtrees ~verse_subtrees =
231231+ let my_subtrees_set = Hashtbl.create 64 in
232232+ List.iter (fun s -> Hashtbl.add my_subtrees_set s ()) my_subtrees;
233233+ Hashtbl.fold
234234+ (fun repo_name handles_and_paths acc ->
235235+ if Hashtbl.mem my_subtrees_set repo_name then acc
236236+ else
237237+ let handles = List.map fst handles_and_paths in
238238+ (repo_name, handles) :: acc)
239239+ verse_subtrees []
240240+ |> List.sort (fun (a, _) (b, _) -> String.compare a b)
241241+207242let compute ~fs ~verse_config ~monopam_config () =
208243 let my_mono = Verse_config.mono_path verse_config in
209244 let checkouts = Config.Paths.checkouts monopam_config in
210210-211211- (* Get my subtrees *)
212245 let my_subtrees = Verse.scan_subtrees ~fs my_mono in
213213-214214- (* Get verse subtrees (map: repo_name -> [(handle, monorepo_path)]) *)
215246 let verse_subtrees = Verse.subtrees ~fs ~config:verse_config () in
216216-217217- (* Build comparisons for repos I have *)
218247 let my_repos =
219248 List.filter_map
220220- (fun repo_name ->
221221- let my_info =
222222- subtree_info ~fs ~monorepo_path:my_mono ~prefix:repo_name ()
223223- in
224224- let checkout_path = Fpath.(checkouts / repo_name) in
225225-226226- (* Find others who have this repo *)
227227- let others_with_repo =
228228- try Hashtbl.find verse_subtrees repo_name with Not_found -> []
229229- in
230230-231231- if others_with_repo = [] then None (* No one else has this repo, skip *)
232232- else begin
233233- let others =
234234- List.map
235235- (fun (handle, their_mono) ->
236236- let their_info =
237237- subtree_info ~fs ~monorepo_path:their_mono ~prefix:repo_name
238238- ()
239239- in
240240- let rel =
241241- compare_commits ~fs ~checkout_path
242242- ~my_commit:my_info.upstream_commit
243243- ~their_commit:their_info.upstream_commit ()
244244- in
245245- (handle, their_info, rel))
246246- others_with_repo
247247- in
248248- Some { repo_name; my_info = Some my_info; others }
249249- end)
249249+ (compare_my_repo ~fs ~my_mono ~checkouts ~verse_subtrees)
250250 my_subtrees
251251 in
252252-253253- (* Find repos others have that I don't *)
254254- let my_subtrees_set = Hashtbl.create 64 in
255255- List.iter (fun s -> Hashtbl.add my_subtrees_set s ()) my_subtrees;
256256-257257- let other_repos =
258258- Hashtbl.fold
259259- (fun repo_name handles_and_paths acc ->
260260- if Hashtbl.mem my_subtrees_set repo_name then acc
261261- else
262262- let handles = List.map fst handles_and_paths in
263263- (repo_name, handles) :: acc)
264264- verse_subtrees []
265265- |> List.sort (fun (a, _) (b, _) -> String.compare a b)
266266- in
267267-252252+ let other_repos = other_repos ~my_subtrees ~verse_subtrees in
268253 { my_repos; other_repos }
+100-101
lib/diff.ml
···111111112112(** {1 Diff Operations} *)
113113114114+let check_source ~fs ~checkouts_path ~patch ~repo_name (handle, _src, rel) =
115115+ let checkout_path = Fpath.(checkouts_path / repo_name) in
116116+ if not (Git.Repository.is_repo ~fs checkout_path) then None
117117+ else begin
118118+ let repo = Git.Repository.open_repo ~fs checkout_path in
119119+ let remote_name = "verse/" ^ handle in
120120+ let my_ref = "origin/main" in
121121+ let their_ref = remote_name ^ "/main" in
122122+ match
123123+ Git.Repository.log_range_refs repo ~base:my_ref ~tip:their_ref
124124+ ~max_count:20 ()
125125+ with
126126+ | Error _ -> None
127127+ | Ok commits when commits = [] -> None
128128+ | Ok commits ->
129129+ let patches =
130130+ if patch then
131131+ List.filter_map
132132+ (fun (c : Git.Repository.log_entry) ->
133133+ match Git.Repository.show_patch repo ~commit:c.hash with
134134+ | Ok p -> Some (c.hash, p)
135135+ | Error _ -> None)
136136+ commits
137137+ else []
138138+ in
139139+ Some { repo_name; handle; relationship = rel; commits; patches }
140140+ end
141141+142142+let check_repo ~fs ~checkouts_path ~patch (r : Forks.repo_analysis) =
143143+ let actionable =
144144+ List.filter
145145+ (fun (_, _, rel) ->
146146+ match rel with
147147+ | Forks.I_am_behind _ -> true
148148+ | Forks.Diverged _ -> true
149149+ | _ -> false)
150150+ r.verse_sources
151151+ in
152152+ match actionable with
153153+ | [] -> None
154154+ | sources -> (
155155+ let entries =
156156+ List.filter_map
157157+ (check_source ~fs ~checkouts_path ~patch ~repo_name:r.repo_name)
158158+ sources
159159+ in
160160+ match entries with [] -> None | _ -> Some entries)
161161+114162let compute ~proc ~fs ~config ~verse_config ?repo ?(refresh = false)
115163 ?(patch = false) () =
116164 let checkouts_path = Config.Paths.checkouts config in
···123171 | Some name -> List.filter (fun r -> r.Forks.repo_name = name) forks.repos
124172 in
125173 let entries =
126126- List.filter_map
127127- (fun (r : Forks.repo_analysis) ->
128128- let actionable =
129129- List.filter
130130- (fun (_, _, rel) ->
131131- match rel with
132132- | Forks.I_am_behind _ -> true
133133- | Forks.Diverged _ -> true
134134- | _ -> false)
135135- r.verse_sources
136136- in
137137- match actionable with
138138- | [] -> None
139139- | sources -> (
140140- let entries =
141141- List.filter_map
142142- (fun (handle, _src, rel) ->
143143- let checkout_path = Fpath.(checkouts_path / r.repo_name) in
144144- if not (Git.Repository.is_repo ~fs checkout_path) then None
145145- else begin
146146- let repo = Git.Repository.open_repo ~fs checkout_path in
147147- let remote_name = "verse/" ^ handle in
148148- let my_ref = "origin/main" in
149149- let their_ref = remote_name ^ "/main" in
150150- match
151151- Git.Repository.log_range_refs repo ~base:my_ref
152152- ~tip:their_ref ~max_count:20 ()
153153- with
154154- | Error _ -> None
155155- | Ok commits when commits = [] -> None
156156- | Ok commits ->
157157- let patches =
158158- if patch then
159159- List.filter_map
160160- (fun (c : Git.Repository.log_entry) ->
161161- match
162162- Git.Repository.show_patch repo ~commit:c.hash
163163- with
164164- | Ok p -> Some (c.hash, p)
165165- | Error _ -> None)
166166- commits
167167- else []
168168- in
169169- Some
170170- {
171171- repo_name = r.repo_name;
172172- handle;
173173- relationship = rel;
174174- commits;
175175- patches;
176176- }
177177- end)
178178- sources
179179- in
180180- match entries with [] -> None | _ -> Some entries))
181181- repos_to_check
174174+ List.filter_map (check_repo ~fs ~checkouts_path ~patch) repos_to_check
182175 |> List.flatten
183176 in
184177 { entries; forks }
···237230238231(** {1 Pull from Handle} *)
239232233233+type pull_action =
234234+ | Pulled of string * int
235235+ | Skipped of string
236236+ | Failed of string * string
237237+238238+let pull_one_repo ~fs ~checkouts_path ~handle (r : Forks.repo_analysis) =
239239+ let handle_source =
240240+ List.find_opt (fun (h, _, _) -> h = handle) r.verse_sources
241241+ in
242242+ match handle_source with
243243+ | None -> []
244244+ | Some (_, _, rel) ->
245245+ let checkout_path = Fpath.(checkouts_path / r.repo_name) in
246246+ if not (Git.Repository.is_repo ~fs checkout_path) then
247247+ [ Skipped r.repo_name ]
248248+ else begin
249249+ let git_repo = Git.Repository.open_repo ~fs checkout_path in
250250+ match rel with
251251+ | Forks.Same_url | Forks.Same_commit | Forks.I_am_ahead _
252252+ | Forks.Not_fetched | Forks.Unrelated ->
253253+ [ Skipped r.repo_name ]
254254+ | Forks.I_am_behind count -> (
255255+ let remote_ref = "verse/" ^ handle ^ "/main" in
256256+ match
257257+ Git.Repository.merge git_repo ~ref_name:remote_ref ~ff_only:true
258258+ with
259259+ | Ok () -> [ Pulled (r.repo_name, count) ]
260260+ | Error (`Msg msg) -> [ Failed (r.repo_name, msg) ])
261261+ | Forks.Diverged { their_ahead; _ } -> (
262262+ let remote_ref = "verse/" ^ handle ^ "/main" in
263263+ match
264264+ Git.Repository.merge git_repo ~ref_name:remote_ref ~ff_only:false
265265+ with
266266+ | Ok () -> [ Pulled (r.repo_name, their_ahead) ]
267267+ | Error (`Msg msg) -> [ Failed (r.repo_name, msg) ])
268268+ end
269269+240270let pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo
241271 ?(refresh = false) () =
242272 let checkouts_path = Config.Paths.checkouts config in
···248278 | None -> forks.repos
249279 | Some name -> List.filter (fun r -> r.Forks.repo_name = name) forks.repos
250280 in
251251- let repos_pulled = ref [] in
252252- let repos_skipped = ref [] in
253253- let repos_failed = ref [] in
254254- List.iter
255255- (fun (r : Forks.repo_analysis) ->
256256- let handle_source =
257257- List.find_opt (fun (h, _, _) -> h = handle) r.verse_sources
258258- in
259259- match handle_source with
260260- | None -> ()
261261- | Some (_, _, rel) ->
262262- let checkout_path = Fpath.(checkouts_path / r.repo_name) in
263263- if not (Git.Repository.is_repo ~fs checkout_path) then
264264- repos_skipped := r.repo_name :: !repos_skipped
265265- else begin
266266- let git_repo = Git.Repository.open_repo ~fs checkout_path in
267267- match rel with
268268- | Forks.Same_url | Forks.Same_commit | Forks.I_am_ahead _ ->
269269- repos_skipped := r.repo_name :: !repos_skipped
270270- | Forks.Not_fetched | Forks.Unrelated ->
271271- repos_skipped := r.repo_name :: !repos_skipped
272272- | Forks.I_am_behind count -> (
273273- let remote_ref = "verse/" ^ handle ^ "/main" in
274274- match
275275- Git.Repository.merge git_repo ~ref_name:remote_ref
276276- ~ff_only:true
277277- with
278278- | Ok () -> repos_pulled := (r.repo_name, count) :: !repos_pulled
279279- | Error (`Msg msg) ->
280280- repos_failed := (r.repo_name, msg) :: !repos_failed)
281281- | Forks.Diverged { their_ahead; _ } -> (
282282- let remote_ref = "verse/" ^ handle ^ "/main" in
283283- match
284284- Git.Repository.merge git_repo ~ref_name:remote_ref
285285- ~ff_only:false
286286- with
287287- | Ok () ->
288288- repos_pulled := (r.repo_name, their_ahead) :: !repos_pulled
289289- | Error (`Msg msg) ->
290290- repos_failed := (r.repo_name, msg) :: !repos_failed)
291291- end)
292292- repos_to_check;
281281+ let actions =
282282+ List.concat_map (pull_one_repo ~fs ~checkouts_path ~handle) repos_to_check
283283+ in
284284+ let repos_pulled, repos_skipped, repos_failed =
285285+ List.fold_left
286286+ (fun (pulled, skipped, failed) -> function
287287+ | Pulled (name, count) -> ((name, count) :: pulled, skipped, failed)
288288+ | Skipped name -> (pulled, name :: skipped, failed)
289289+ | Failed (name, msg) -> (pulled, skipped, (name, msg) :: failed))
290290+ ([], [], []) actions
291291+ in
293292 Ok
294293 {
295295- repos_pulled = List.rev !repos_pulled;
296296- repos_skipped = List.rev !repos_skipped;
297297- repos_failed = List.rev !repos_failed;
294294+ repos_pulled = List.rev repos_pulled;
295295+ repos_skipped = List.rev repos_skipped;
296296+ repos_failed = List.rev repos_failed;
298297 }
299298300299(** {1 Cherry-pick} *)
+121-119
lib/doctor.ml
···566566 ],
567567 Meta.none )
568568569569+let prop name schema =
570570+ let open Jsont in
571571+ ((name, Meta.none), schema)
572572+573573+let commit_schema () =
574574+ object_with_props
575575+ [
576576+ prop "hash" string_type;
577577+ prop "subject" string_type;
578578+ prop "author" string_type;
579579+ prop "date" string_type;
580580+ prop "category" string_type;
581581+ prop "priority" string_type;
582582+ prop "recommendation" string_type;
583583+ prop "conflict_risk" string_type;
584584+ prop "summary" string_type;
585585+ ]
586586+587587+let verse_schema () =
588588+ object_with_props
589589+ [
590590+ prop "handle" string_type;
591591+ prop "commits" (array_of (commit_schema ()));
592592+ prop "suggested_action" string_type;
593593+ ]
594594+595595+let repo_schema () =
596596+ object_with_props
597597+ [
598598+ prop "name" string_type;
599599+ prop "verse_analyses" (array_of (verse_schema ()));
600600+ ]
601601+602602+let action_schema () =
603603+ object_with_props
604604+ [
605605+ prop "priority" string_type;
606606+ prop "action" string_type;
607607+ prop "command" string_type;
608608+ ]
609609+569610(** JSON schema for doctor output *)
570611let output_schema () =
571612 let open Jsont in
572572- let prop name schema = ((name, Meta.none), schema) in
573573- let commit_schema =
574574- object_with_props
575575- [
576576- prop "hash" string_type;
577577- prop "subject" string_type;
578578- prop "author" string_type;
579579- prop "date" string_type;
580580- prop "category" string_type;
581581- prop "priority" string_type;
582582- prop "recommendation" string_type;
583583- prop "conflict_risk" string_type;
584584- prop "summary" string_type;
585585- ]
586586- in
587587- let verse_schema =
588588- object_with_props
589589- [
590590- prop "handle" string_type;
591591- prop "commits" (array_of commit_schema);
592592- prop "suggested_action" string_type;
593593- ]
594594- in
595595- let repo_schema =
596596- object_with_props
597597- [ prop "name" string_type; prop "verse_analyses" (array_of verse_schema) ]
598598- in
599599- let action_schema =
600600- object_with_props
601601- [
602602- prop "priority" string_type;
603603- prop "action" string_type;
604604- prop "command" string_type;
605605- ]
606606- in
607613 Object
608614 ( [
609615 (("type", Meta.none), String ("object", Meta.none));
610616 ( ("properties", Meta.none),
611617 Object
612618 ( [
613613- prop "repos" (array_of repo_schema);
614614- prop "recommendations" (array_of action_schema);
619619+ prop "repos" (array_of (repo_schema ()));
620620+ prop "recommendations" (array_of (action_schema ()));
615621 prop "warnings" (array_of string_type);
616622 ],
617623 Meta.none ) );
···882888 (priority_order b.action_priority))
883889 !recommendations
884890885885-(** Run the doctor analysis *)
886886-let analyze ~proc ~fs ~config ~verse_config ~clock ?package ?(no_sync = false)
887887- () =
888888- let _ = no_sync in
889889- let now = Eio.Time.now clock in
890890- let now_ptime =
891891- Ptime.of_float_s now |> Option.value ~default:(Ptime.v (0, 0L))
892892- in
893893- let timestamp = Ptime.to_rfc3339 now_ptime ~tz_offset_s:0 in
894894- let workspace = Fpath.to_string (Verse_config.root verse_config) in
895895-891891+let compute_statuses ~fs ~config ?package () =
896892 let packages =
897893 match
898894 Opam_repo.scan ~fs:(fs :> _ Eio.Path.t) (Config.Paths.opam_repo config)
···901897 | Error _ -> []
902898 in
903899 let statuses = Status.compute_all ~fs ~config packages in
904904- let statuses =
905905- match package with
906906- | None -> statuses
907907- | Some name ->
908908- List.filter
909909- (fun (s : Status.t) -> Package.name s.package = name)
910910- statuses
911911- in
912912-913913- let warnings = check_dirty_repos ~fs ~config in
900900+ match package with
901901+ | None -> statuses
902902+ | Some name ->
903903+ List.filter (fun (s : Status.t) -> Package.name s.package = name) statuses
914904915915- Log.app (fun m ->
916916- m "Analyzing remotes for %d repositories..." (List.length statuses));
905905+let collect_remotes_by_repo ~fs ~config statuses =
917906 let checkouts_root = Config.Paths.checkouts config in
918918- let remotes_by_repo =
919919- List.filter_map
920920- (fun (status : Status.t) ->
921921- let name = Package.repo_name status.package in
922922- let checkout_dir = Fpath.(checkouts_root / name) in
923923- match status.checkout with
924924- | Status.Missing | Status.Not_a_repo -> None
925925- | _ -> Some (name, analyze_checkout_remotes ~fs ~checkout_dir))
926926- statuses
927927- in
928928-929929- let repos_with_incoming =
930930- List.filter
931931- (fun (_name, remotes) -> List.exists (fun r -> r.behind > 0) remotes)
932932- remotes_by_repo
933933- in
907907+ List.filter_map
908908+ (fun (status : Status.t) ->
909909+ let name = Package.repo_name status.package in
910910+ let checkout_dir = Fpath.(checkouts_root / name) in
911911+ match status.checkout with
912912+ | Status.Missing | Status.Not_a_repo -> None
913913+ | _ -> Some (name, analyze_checkout_remotes ~fs ~checkout_dir))
914914+ statuses
934915935935- let base_repos = build_base_repos statuses in
916916+let merge_claude_repos ~base_repos claude_repos =
917917+ List.map
918918+ (fun base_repo ->
919919+ match List.find_opt (fun cr -> cr.name = base_repo.name) claude_repos with
920920+ | Some cr -> { base_repo with verse_analyses = cr.verse_analyses }
921921+ | None -> base_repo)
922922+ base_repos
936923937937- let repos, claude_recommendations, claude_warnings =
938938- if repos_with_incoming <> [] then begin
939939- Log.app (fun m ->
940940- m "Found %d repos with incoming changes, analyzing with Claude..."
941941- (List.length repos_with_incoming));
942942- let status_summary = build_status_summary statuses in
943943- let incoming_summary = build_incoming_summary remotes_by_repo in
944944- match
945945- Eio.Switch.run (fun sw ->
946946- analyze_with_claude ~sw ~process_mgr:proc ~clock ~status_summary
947947- ~incoming_summary)
948948- with
949949- | Some json ->
950950- let claude_repos, recs, warns = parse_claude_response json in
951951- let merged_repos =
952952- List.map
953953- (fun base_repo ->
954954- match
955955- List.find_opt
956956- (fun cr -> cr.name = base_repo.name)
957957- claude_repos
958958- with
959959- | Some cr ->
960960- { base_repo with verse_analyses = cr.verse_analyses }
961961- | None -> base_repo)
962962- base_repos
963963- in
964964- (merged_repos, recs, warns)
965965- | None ->
966966- Log.warn (fun m -> m "Claude analysis failed, using basic status");
967967- (base_repos, [], [])
968968- end
969969- else begin
970970- Log.app (fun m -> m "No incoming changes from remotes");
971971- (base_repos, [], [])
972972- end
973973- in
924924+let analyze_incoming ~proc ~clock ~statuses ~remotes_by_repo ~base_repos
925925+ repos_with_incoming =
926926+ if repos_with_incoming <> [] then begin
927927+ Log.app (fun m ->
928928+ m "Found %d repos with incoming changes, analyzing with Claude..."
929929+ (List.length repos_with_incoming));
930930+ let status_summary = build_status_summary statuses in
931931+ let incoming_summary = build_incoming_summary remotes_by_repo in
932932+ match
933933+ Eio.Switch.run (fun sw ->
934934+ analyze_with_claude ~sw ~process_mgr:proc ~clock ~status_summary
935935+ ~incoming_summary)
936936+ with
937937+ | Some json ->
938938+ let claude_repos, recs, warns = parse_claude_response json in
939939+ (merge_claude_repos ~base_repos claude_repos, recs, warns)
940940+ | None ->
941941+ Log.warn (fun m -> m "Claude analysis failed, using basic status");
942942+ (base_repos, [], [])
943943+ end
944944+ else begin
945945+ Log.app (fun m -> m "No incoming changes from remotes");
946946+ (base_repos, [], [])
947947+ end
974948949949+let build_report_summary repos =
975950 let repos_need_sync =
976951 List.length (List.filter (fun r -> r.local_sync <> `In_sync) repos)
977952 in
···981956 let verse_divergences =
982957 List.fold_left (fun acc r -> acc + List.length r.verse_analyses) 0 repos
983958 in
984984- let report_summary =
985985- {
959959+ ( {
986960 repos_total = List.length repos;
987961 repos_need_sync;
988962 repos_behind_upstream;
989963 verse_divergences;
990990- }
991991- in
964964+ },
965965+ repos_need_sync,
966966+ repos_behind_upstream )
992967968968+(** Run the doctor analysis *)
969969+let analyze ~proc ~fs ~config ~verse_config ~clock ?package ?(no_sync = false)
970970+ () =
971971+ let _ = no_sync in
972972+ let now = Eio.Time.now clock in
973973+ let now_ptime =
974974+ Ptime.of_float_s now |> Option.value ~default:(Ptime.v (0, 0L))
975975+ in
976976+ let timestamp = Ptime.to_rfc3339 now_ptime ~tz_offset_s:0 in
977977+ let workspace = Fpath.to_string (Verse_config.root verse_config) in
978978+ let statuses = compute_statuses ~fs ~config ?package () in
979979+ let warnings = check_dirty_repos ~fs ~config in
980980+ Log.app (fun m ->
981981+ m "Analyzing remotes for %d repositories..." (List.length statuses));
982982+ let remotes_by_repo = collect_remotes_by_repo ~fs ~config statuses in
983983+ let repos_with_incoming =
984984+ List.filter
985985+ (fun (_name, remotes) -> List.exists (fun r -> r.behind > 0) remotes)
986986+ remotes_by_repo
987987+ in
988988+ let base_repos = build_base_repos statuses in
989989+ let repos, claude_recommendations, claude_warnings =
990990+ analyze_incoming ~proc ~clock ~statuses ~remotes_by_repo ~base_repos
991991+ repos_with_incoming
992992+ in
993993+ let report_summary, repos_need_sync, repos_behind_upstream =
994994+ build_report_summary repos
995995+ in
993996 let recommendations =
994997 build_recommendations ~repos_need_sync ~repos_behind_upstream
995998 claude_recommendations
996999 in
997997-9981000 {
9991001 timestamp;
10001002 workspace;
+183-208
lib/fork_join.ml
···678678 in
679679 base_actions @ sources_actions
680680681681-(** Build a join plan - handles both URL and local path *)
682682-let plan_join ~proc ~fs ~config ~source ?name ?upstream ?(dry_run = false) () =
683683- let is_local = is_local_path source in
684684- let name = match name with Some n -> n | None -> name_from_url source in
685685- let monorepo = Verse_config.mono_path config in
686686- let checkouts = Verse_config.src_path config in
687687- let prefix = name in
688688- let src_path = Fpath.(checkouts / name) in
689689- let branch = Verse_config.default_branch in
690690-681681+let join_discovery ~fs ~is_local ~source ~monorepo ~prefix ~src_path =
691682 let subtree_exists = is_directory ~fs Fpath.(monorepo / prefix) in
692683 let src_exists = is_directory ~fs src_path in
693684 let local_is_repo =
···697688 | Error _ -> Some false
698689 else None
699690 in
700700- let discovery =
701701- {
691691+ ( {
702692 mono_exists = subtree_exists;
703693 src_exists;
704694 has_subtree_history = false;
705695 remote_accessible = None;
706696 opam_files = [];
707697 local_path_is_repo = local_is_repo;
708708- }
698698+ },
699699+ subtree_exists,
700700+ local_is_repo )
701701+702702+let join_select_actions ~is_local ~source ~local_is_repo ~checkouts ~monorepo
703703+ ~src_path ~prefix ~name ~upstream ~branch =
704704+ if is_local then
705705+ match Fpath.of_string source with
706706+ | Error (`Msg msg) -> raise (Invalid_argument msg)
707707+ | Ok local_path ->
708708+ if Option.value ~default:false local_is_repo then
709709+ join_local_repo_actions ~checkouts ~monorepo ~local_path ~src_path
710710+ ~prefix ~branch
711711+ else
712712+ join_local_dir_actions ~checkouts ~monorepo ~local_path ~src_path
713713+ ~prefix ~name ~branch
714714+ else
715715+ join_url_actions ~checkouts ~monorepo ~src_path ~prefix ~source ~upstream
716716+ ~name ~branch
717717+718718+(** Build a join plan - handles both URL and local path *)
719719+let plan_join ~proc ~fs ~config ~source ?name ?upstream ?(dry_run = false) () =
720720+ let is_local = is_local_path source in
721721+ let name = match name with Some n -> n | None -> name_from_url source in
722722+ let monorepo = Verse_config.mono_path config in
723723+ let checkouts = Verse_config.src_path config in
724724+ let prefix = name in
725725+ let src_path = Fpath.(checkouts / name) in
726726+ let branch = Verse_config.default_branch in
727727+ let discovery, subtree_exists, local_is_repo =
728728+ join_discovery ~fs ~is_local ~source ~monorepo ~prefix ~src_path
709729 in
710710-711730 if subtree_exists then Error (Subtree_already_exists name)
712731 else begin
713732 let actions =
714714- if is_local then
715715- match Fpath.of_string source with
716716- | Error (`Msg msg) -> raise (Invalid_argument msg)
717717- | Ok local_path ->
718718- if Option.value ~default:false local_is_repo then
719719- join_local_repo_actions ~checkouts ~monorepo ~local_path ~src_path
720720- ~prefix ~branch
721721- else
722722- join_local_dir_actions ~checkouts ~monorepo ~local_path ~src_path
723723- ~prefix ~name ~branch
724724- else
725725- join_url_actions ~checkouts ~monorepo ~src_path ~prefix ~source
726726- ~upstream ~name ~branch
733733+ join_select_actions ~is_local ~source ~local_is_repo ~checkouts ~monorepo
734734+ ~src_path ~prefix ~name ~upstream ~branch
727735 in
728736 let opam_preview =
729737 if is_local then
···750758 }
751759 end
752760753753-(** Build a rejoin plan - add existing src/<name> back into mono/<name> *)
754754-let plan_rejoin ~proc ~fs ~config ~name ?(dry_run = false) () =
755755- let monorepo = Verse_config.mono_path config in
756756- let checkouts = Verse_config.src_path config in
757757- let prefix = name in
758758- let src_path = Fpath.(checkouts / name) in
759759-760760- (* Gather discovery information *)
761761+let rejoin_discovery ~fs ~monorepo ~prefix ~src_path =
761762 let subtree_exists = is_directory ~fs Fpath.(monorepo / prefix) in
762763 let src_exists = is_directory ~fs src_path in
763764 let src_is_repo =
764765 if src_exists then Git.Repository.is_repo ~fs src_path else false
765766 in
766766- let opam_files = if src_exists then opam_files ~fs src_path else [] in
767767-767767+ let opam_files_list = if src_exists then opam_files ~fs src_path else [] in
768768 let discovery =
769769 {
770770 mono_exists = subtree_exists;
771771 src_exists;
772772 has_subtree_history = false;
773773 remote_accessible = None;
774774- opam_files;
774774+ opam_files = opam_files_list;
775775 local_path_is_repo = Some src_is_repo;
776776 }
777777 in
778778+ (discovery, subtree_exists, src_exists, src_is_repo, opam_files_list)
778779779779- (* Validation *)
780780+(** Build a rejoin plan - add existing src/<name> back into mono/<name> *)
781781+let plan_rejoin ~proc ~fs ~config ~name ?(dry_run = false) () =
782782+ let monorepo = Verse_config.mono_path config in
783783+ let checkouts = Verse_config.src_path config in
784784+ let prefix = name in
785785+ let src_path = Fpath.(checkouts / name) in
786786+ let discovery, subtree_exists, src_exists, src_is_repo, opam_files_list =
787787+ rejoin_discovery ~fs ~monorepo ~prefix ~src_path
788788+ in
780789 if subtree_exists then Error (Subtree_already_exists name)
781790 else if not src_exists then Error (Src_not_found name)
782791 else if not src_is_repo then
···795804 };
796805 ]
797806 in
798798-799807 let result =
800808 {
801809 name;
802810 source_url = Fpath.to_string src_path;
803811 upstream_url = None;
804804- packages_added = opam_files;
812812+ packages_added = opam_files_list;
805813 from_handle = None;
806814 }
807815 in
808808-809816 Ok { discovery; actions; result; dry_run }
810817 end
811818···990997991998(** {1 Legacy API (using plans internally)} *)
99299910001000+let fork_update_sources ~fs ~monorepo ~name ~push_url =
10011001+ match push_url with
10021002+ | Some url -> (
10031003+ let sources_path = Fpath.(monorepo / "sources.toml") in
10041004+ let sources =
10051005+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
10061006+ | Ok s -> s
10071007+ | Error _ -> Sources_registry.empty
10081008+ in
10091009+ let entry =
10101010+ Sources_registry.
10111011+ {
10121012+ url = normalize_git_url url;
10131013+ upstream = None;
10141014+ branch = Some "main";
10151015+ reason = None;
10161016+ origin = Some Fork;
10171017+ }
10181018+ in
10191019+ let sources = Sources_registry.add sources ~subtree:name entry in
10201020+ match
10211021+ Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources
10221022+ with
10231023+ | Ok () -> ()
10241024+ | Error msg ->
10251025+ Logs.warn (fun m -> m "Failed to update sources.toml: %s" msg))
10261026+ | None -> ()
10271027+10281028+let fork_add_push_remote ~fs ~src_path ~push_url =
10291029+ let checkout_repo = Git.Repository.open_repo ~fs src_path in
10301030+ match push_url with
10311031+ | Some url ->
10321032+ Git.Repository.add_remote checkout_repo ~name:"origin" ~url ()
10331033+ |> Result.map_error (fun (`Msg msg) -> Git_error (Git_cli.Io_error msg))
10341034+ | None -> Ok ()
10351035+10361036+let fork_init_and_push ~proc ~fs ~monorepo ~checkouts ~src_path ~split_commit =
10371037+ ensure_dir ~fs checkouts;
10381038+ let git_repo = Git.Repository.init ~fs src_path in
10391039+ let mono_str = Fpath.to_string monorepo in
10401040+ match Git.Repository.add_remote git_repo ~name:"mono" ~url:mono_str () with
10411041+ | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
10421042+ | Ok () -> (
10431043+ let ref_spec = split_commit ^ ":refs/heads/main" in
10441044+ match
10451045+ Git_cli.push_ref ~proc ~fs ~repo:monorepo
10461046+ ~target:(Fpath.to_string src_path) ~ref_spec ()
10471047+ with
10481048+ | Error e -> Error (Git_error e)
10491049+ | Ok () ->
10501050+ let checkout_repo = Git.Repository.open_repo ~fs src_path in
10511051+ Git.Repository.checkout_ref checkout_repo "main"
10521052+ |> Result.map_error (fun (`Msg msg) ->
10531053+ Git_error (Git_cli.Io_error msg)))
10541054+9931055let fork ~proc ~fs ~config ~name ?push_url ?(dry_run = false) () =
9941056 let monorepo = Verse_config.mono_path config in
9951057 let checkouts = Verse_config.src_path config in
9961058 let prefix = name in
9971059 let subtree_path = Fpath.(monorepo / prefix) in
9981060 let src_path = Fpath.(checkouts / name) in
999999- (* Validate: mono/<name>/ must exist *)
10001061 if not (is_directory ~fs Fpath.(monorepo / prefix)) then
10011001- Error (Subtree_not_found name) (* Validate: src/<name>/ must not exist *)
10621062+ Error (Subtree_not_found name)
10021063 else if is_directory ~fs src_path then Error (Src_already_exists name)
10031064 else begin
10041004- (* Find .opam files in subtree *)
10051065 let packages = opam_files ~fs subtree_path in
10061066 if packages = [] then Error (No_opam_files name)
10071067 else if dry_run then
···10141074 packages_created = packages;
10151075 }
10161076 else begin
10171017- (* Split the subtree to get history *)
10181077 let git_repo = Git.Repository.open_repo ~fs monorepo in
10191078 match Git.Repository.read_ref git_repo "HEAD" with
10201079 | None -> Error (Git_error (Git_cli.Io_error "no HEAD ref found"))
···10241083 | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
10251084 | Ok (Some split_hash) -> (
10261085 let split_commit = Git.Hash.to_hex split_hash in
10271027- (* Ensure src/ exists *)
10281028- ensure_dir ~fs checkouts;
10291029- (* Initialize new git repo at src/<name>/ *)
10301030- let git_repo = Git.Repository.init ~fs src_path in
10311031- (* Add 'origin' remote pointing to monorepo path temporarily *)
10321032- let mono_str = Fpath.to_string monorepo in
10331086 match
10341034- Git.Repository.add_remote git_repo ~name:"mono" ~url:mono_str ()
10871087+ fork_init_and_push ~proc ~fs ~monorepo ~checkouts ~src_path
10881088+ ~split_commit
10351089 with
10361036- | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
10901090+ | Error _ as e -> e
10371091 | Ok () -> (
10381038- (* Push split commit to local repo *)
10391039- let ref_spec = split_commit ^ ":refs/heads/main" in
10401040- match
10411041- Git_cli.push_ref ~proc ~fs ~repo:monorepo
10421042- ~target:(Fpath.to_string src_path) ~ref_spec ()
10431043- with
10441044- | Error e -> Error (Git_error e)
10451045- | Ok () -> (
10461046- (* Checkout main branch *)
10471047- let checkout_repo =
10481048- Git.Repository.open_repo ~fs src_path
10491049- in
10501050- match
10511051- Git.Repository.checkout_ref checkout_repo "main"
10521052- with
10531053- | Error (`Msg msg) ->
10541054- Error (Git_error (Git_cli.Io_error msg))
10551055- | Ok () -> (
10561056- (* Set push URL if provided *)
10571057- let push_result =
10581058- match push_url with
10591059- | Some url ->
10601060- Git.Repository.add_remote checkout_repo
10611061- ~name:"origin" ~url ()
10621062- |> Result.map_error (fun (`Msg msg) ->
10631063- Git_error (Git_cli.Io_error msg))
10641064- | None -> Ok ()
10651065- in
10661066- match push_result with
10671067- | Error _ as e -> e
10681068- | Ok () ->
10691069- (* Only update sources.toml if there's a push URL *)
10701070- (match push_url with
10711071- | Some url -> (
10721072- let sources_path =
10731073- Fpath.(monorepo / "sources.toml")
10741074- in
10751075- let sources =
10761076- match
10771077- Sources_registry.load
10781078- ~fs:(fs :> _ Eio.Path.t)
10791079- sources_path
10801080- with
10811081- | Ok s -> s
10821082- | Error _ -> Sources_registry.empty
10831083- in
10841084- let entry =
10851085- Sources_registry.
10861086- {
10871087- url = normalize_git_url url;
10881088- upstream = None;
10891089- branch = Some "main";
10901090- reason = None;
10911091- origin = Some Fork;
10921092- }
10931093- in
10941094- let sources =
10951095- Sources_registry.add sources ~subtree:name
10961096- entry
10971097- in
10981098- match
10991099- Sources_registry.save
11001100- ~fs:(fs :> _ Eio.Path.t)
11011101- sources_path sources
11021102- with
11031103- | Ok () -> ()
11041104- | Error msg ->
11051105- Logs.warn (fun m ->
11061106- m "Failed to update sources.toml: %s"
11071107- msg))
11081108- | None -> ());
11091109- Ok
11101110- {
11111111- name;
11121112- split_commit;
11131113- src_path;
11141114- push_url;
11151115- packages_created = packages;
11161116- })))))
10921092+ match fork_add_push_remote ~fs ~src_path ~push_url with
10931093+ | Error _ as e -> e
10941094+ | Ok () ->
10951095+ fork_update_sources ~fs ~monorepo ~name ~push_url;
10961096+ Ok
10971097+ {
10981098+ name;
10991099+ split_commit;
11001100+ src_path;
11011101+ push_url;
11021102+ packages_created = packages;
11031103+ })))
11171104 end
11181105 end
1119110611071107+let join_update_sources ~fs ~monorepo ~name ~url ~upstream ~branch =
11081108+ match upstream with
11091109+ | Some _ -> (
11101110+ let sources_path = Fpath.(monorepo / "sources.toml") in
11111111+ let sources =
11121112+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
11131113+ | Ok s -> s
11141114+ | Error _ -> Sources_registry.empty
11151115+ in
11161116+ let entry =
11171117+ Sources_registry.
11181118+ {
11191119+ url = normalize_git_url url;
11201120+ upstream = Option.map normalize_git_url upstream;
11211121+ branch = Some branch;
11221122+ reason = None;
11231123+ origin = Some Join;
11241124+ }
11251125+ in
11261126+ let sources = Sources_registry.add sources ~subtree:name entry in
11271127+ match
11281128+ Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources
11291129+ with
11301130+ | Ok () -> ()
11311131+ | Error msg ->
11321132+ Logs.warn (fun m -> m "Failed to update sources.toml: %s" msg))
11331133+ | None -> ()
11341134+11351135+let join_add_subtree ~proc ~fs ~monorepo ~prefix ~url ~branch =
11361136+ let uri = Uri.of_string url in
11371137+ match Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url:uri ~branch () with
11381138+ | Error e -> Error (Git_error e)
11391139+ | Ok hash_hex ->
11401140+ let git_repo = Git.Repository.open_repo ~fs monorepo in
11411141+ let commit = Git.Hash.of_hex hash_hex in
11421142+ let user =
11431143+ match Git_cli.global_git_user ~fs () with
11441144+ | Some u -> u
11451145+ | None ->
11461146+ Git.User.v ~name:"monopam" ~email:"monopam@localhost"
11471147+ ~date:(Int64.of_float (Unix.time ()))
11481148+ ()
11491149+ in
11501150+ let message =
11511151+ Fmt.str "Add '%s' from %s\n\ngit-subtree-dir: %s\n" prefix url prefix
11521152+ in
11531153+ Git.Subtree.add git_repo ~prefix ~commit ~author:user ~committer:user
11541154+ ~message ()
11551155+ |> Result.map_error (fun (`Msg msg) -> Git_error (Git_cli.Io_error msg))
11561156+11201157let join ~proc ~fs ~config ~url ?name ?upstream ?(dry_run = false) () =
11211158 let name = match name with Some n -> n | None -> name_from_url url in
11221159 let monorepo = Verse_config.mono_path config in
···11241161 let prefix = name in
11251162 let subtree_path = Fpath.(monorepo / prefix) in
11261163 let src_path = Fpath.(checkouts / name) in
11271127- (* Validate: mono/<name>/ must not exist *)
11281164 if is_directory ~fs Fpath.(monorepo / prefix) then
11291165 Error (Subtree_already_exists name)
11301166 else if dry_run then
···11371173 from_handle = None;
11381174 }
11391175 else begin
11401140- (* Ensure src/ exists *)
11411176 ensure_dir ~fs checkouts;
11421142- (* Clone to src/<name>/ *)
11431177 let branch = Verse_config.default_branch in
11441178 let uri = Uri.of_string url in
11451179 match Git_cli.clone ~proc ~fs ~url:uri ~branch src_path with
11461180 | Error e -> Error (Git_error e)
11471181 | Ok () -> (
11481148- (* Add subtree to monorepo - first fetch to get the commit *)
11491149- match
11501150- Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url:uri ~branch ()
11511151- with
11521152- | Error e -> Error (Git_error e)
11531153- | Ok hash_hex -> (
11541154- let git_repo = Git.Repository.open_repo ~fs monorepo in
11551155- let commit = Git.Hash.of_hex hash_hex in
11561156- let user =
11571157- match Git_cli.global_git_user ~fs () with
11581158- | Some u -> u
11591159- | None ->
11601160- Git.User.v ~name:"monopam" ~email:"monopam@localhost"
11611161- ~date:(Int64.of_float (Unix.time ()))
11621162- ()
11631163- in
11641164- let message =
11651165- Fmt.str "Add '%s' from %s\n\ngit-subtree-dir: %s\n" prefix url
11661166- prefix
11671167- in
11681168- match
11691169- Git.Subtree.add git_repo ~prefix ~commit ~author:user
11701170- ~committer:user ~message ()
11711171- with
11721172- | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
11731173- | Ok _ ->
11741174- (* Find .opam files in the new subtree *)
11751175- let packages = opam_files ~fs subtree_path in
11761176- (* Only update sources.toml if there's an upstream to track *)
11771177- (match upstream with
11781178- | Some _ -> (
11791179- let sources_path = Fpath.(monorepo / "sources.toml") in
11801180- let sources =
11811181- match
11821182- Sources_registry.load
11831183- ~fs:(fs :> _ Eio.Path.t)
11841184- sources_path
11851185- with
11861186- | Ok s -> s
11871187- | Error _ -> Sources_registry.empty
11881188- in
11891189- let entry =
11901190- Sources_registry.
11911191- {
11921192- url = normalize_git_url url;
11931193- upstream = Option.map normalize_git_url upstream;
11941194- branch = Some branch;
11951195- reason = None;
11961196- origin = Some Join;
11971197- }
11981198- in
11991199- let sources =
12001200- Sources_registry.add sources ~subtree:name entry
12011201- in
12021202- match
12031203- Sources_registry.save
12041204- ~fs:(fs :> _ Eio.Path.t)
12051205- sources_path sources
12061206- with
12071207- | Ok () -> ()
12081208- | Error msg ->
12091209- Logs.warn (fun m ->
12101210- m "Failed to update sources.toml: %s" msg))
12111211- | None -> ());
12121212- Ok
12131213- {
12141214- name;
12151215- source_url = url;
12161216- upstream_url = upstream;
12171217- packages_added = packages;
12181218- from_handle = None;
12191219- }))
11821182+ match join_add_subtree ~proc ~fs ~monorepo ~prefix ~url ~branch with
11831183+ | Error _ as e -> e
11841184+ | Ok _ ->
11851185+ let packages = opam_files ~fs subtree_path in
11861186+ join_update_sources ~fs ~monorepo ~name ~url ~upstream ~branch;
11871187+ Ok
11881188+ {
11891189+ name;
11901190+ source_url = url;
11911191+ upstream_url = upstream;
11921192+ packages_added = packages;
11931193+ from_handle = None;
11941194+ })
12201195 end
1221119612221197let join_from_verse ~proc ~fs ~config ~verse_config ~package ~handle ~fork_url
+54-68
lib/forks.ml
···686686 their_ahead;
687687 }))
688688689689+let compute_source_rel ~proc ~fs ~my_source ~checkout_path ~have_checkout
690690+ ~refresh src =
691691+ let rel =
692692+ match my_source with
693693+ | Some my when urls_equal my.url src.url -> Same_url
694694+ | _ when not have_checkout -> Not_fetched
695695+ | _ -> (
696696+ let remote_name = verse_remote_name src.handle in
697697+ if not (remote_exists ~proc ~fs ~repo:checkout_path remote_name) then begin
698698+ Log.info (fun m ->
699699+ m "Adding remote %s -> %a" remote_name Uri.pp src.url);
700700+ ignore
701701+ (add_remote ~proc ~fs ~repo:checkout_path ~name:remote_name
702702+ ~url:src.url ())
703703+ end;
704704+ match
705705+ fetch_remote ~proc ~fs ~repo:checkout_path ~remote:remote_name
706706+ ~refresh ()
707707+ with
708708+ | Error _ -> Not_fetched
709709+ | Ok () ->
710710+ let my_ref = "origin/main" in
711711+ let their_ref = remote_name ^ "/main" in
712712+ compare_refs ~fs ~repo:checkout_path ~my_ref ~their_ref ())
713713+ in
714714+ (src.handle, src, rel)
715715+716716+let analyze_repo ~proc ~fs ~checkouts_path ~refresh ~my_repos ~verse_repos
717717+ repo_name acc =
718718+ let my_source =
719719+ match Hashtbl.find_opt my_repos repo_name with
720720+ | None -> None
721721+ | Some (url, pkgs) -> Some { handle = "me"; url; packages = pkgs }
722722+ in
723723+ let verse_sources =
724724+ match Hashtbl.find_opt verse_repos repo_name with
725725+ | None -> []
726726+ | Some sources -> sources
727727+ in
728728+ if verse_sources = [] then acc
729729+ else begin
730730+ let checkout_path = Fpath.(checkouts_path / repo_name) in
731731+ let have_checkout = Git.Repository.is_repo ~fs checkout_path in
732732+ let verse_with_rel =
733733+ List.map
734734+ (compute_source_rel ~proc ~fs ~my_source ~checkout_path ~have_checkout
735735+ ~refresh)
736736+ verse_sources
737737+ in
738738+ { repo_name; my_source; verse_sources = verse_with_rel } :: acc
739739+ end
740740+689741(** Compute fork analysis for all repos *)
690742let compute ~proc ~fs ~verse_config ~monopam_config ?(refresh = false) () =
691743 let verse_path = Verse_config.verse_path verse_config in
692744 let opam_repo_path = Config.Paths.opam_repo monopam_config in
693745 let checkouts_path = Config.Paths.checkouts monopam_config in
694694-695695- (* Scan my opam repo *)
696746 Log.info (fun m -> m "Scanning my opam repo");
697747 let my_repos = scan_my_opam_repo ~fs ~opam_repo_path () in
698698-699699- (* Scan verse opam repos *)
700748 Log.info (fun m -> m "Scanning verse opam repos");
701749 let verse_repos =
702750 scan_all_verse_opam_repos ~proc ~fs ~verse_path ~refresh ()
703751 in
704704-705705- (* Build combined list of all repo names *)
706752 let all_repos = Hashtbl.create 64 in
707753 Hashtbl.iter (fun repo _ -> Hashtbl.replace all_repos repo ()) my_repos;
708754 Hashtbl.iter (fun repo _ -> Hashtbl.replace all_repos repo ()) verse_repos;
709709-710710- (* Analyze each repo *)
711755 let analyses =
712756 Hashtbl.fold
713757 (fun repo_name () acc ->
714714- let my_source =
715715- match Hashtbl.find_opt my_repos repo_name with
716716- | None -> None
717717- | Some (url, pkgs) -> Some { handle = "me"; url; packages = pkgs }
718718- in
719719- let verse_sources =
720720- match Hashtbl.find_opt verse_repos repo_name with
721721- | None -> []
722722- | Some sources -> sources
723723- in
724724- (* Skip if no verse sources *)
725725- if verse_sources = [] then acc
726726- else begin
727727- (* Check if we have a local checkout *)
728728- let checkout_path = Fpath.(checkouts_path / repo_name) in
729729- let have_checkout = Git.Repository.is_repo ~fs checkout_path in
730730-731731- (* Process each verse source *)
732732- let verse_with_rel =
733733- List.map
734734- (fun src ->
735735- (* Check if URL is same as mine *)
736736- let rel =
737737- match my_source with
738738- | Some my when urls_equal my.url src.url -> Same_url
739739- | _ when not have_checkout -> Not_fetched
740740- | _ -> (
741741- let remote_name = verse_remote_name src.handle in
742742- (* Add remote if needed *)
743743- if
744744- not
745745- (remote_exists ~proc ~fs ~repo:checkout_path
746746- remote_name)
747747- then begin
748748- Log.info (fun m ->
749749- m "Adding remote %s -> %a" remote_name Uri.pp
750750- src.url);
751751- ignore
752752- (add_remote ~proc ~fs ~repo:checkout_path
753753- ~name:remote_name ~url:src.url ())
754754- end;
755755- (* Fetch remote (respecting cache unless refresh) *)
756756- match
757757- fetch_remote ~proc ~fs ~repo:checkout_path
758758- ~remote:remote_name ~refresh ()
759759- with
760760- | Error _ -> Not_fetched
761761- | Ok () ->
762762- (* Compare refs *)
763763- let my_ref = "origin/main" in
764764- let their_ref = remote_name ^ "/main" in
765765- compare_refs ~fs ~repo:checkout_path ~my_ref
766766- ~their_ref ())
767767- in
768768- (src.handle, src, rel))
769769- verse_sources
770770- in
771771- { repo_name; my_source; verse_sources = verse_with_rel } :: acc
772772- end)
758758+ analyze_repo ~proc ~fs ~checkouts_path ~refresh ~my_repos ~verse_repos
759759+ repo_name acc)
773760 all_repos []
774761 in
775775- (* Sort by repo name *)
776762 let repos =
777763 List.sort (fun a b -> String.compare a.repo_name b.repo_name) analyses
778764 in
+75-65
lib/init.ml
···991010(** {1 Content Templates} *)
11111212-let claude_md_content =
1212+let claude_md_header =
1313 {|# Monorepo Development Guide
14141515This is a monorepo managed by `monopam`. Each subdirectory is a git subtree
···2828| Sync one repo | `monopam sync <repo-name>` |
2929| Build | `opam exec -- dune build` |
3030| Test | `opam exec -- dune test` |
3131+|}
31323232-## Daily Workflow
3333+let claude_md_workflow =
3434+ {|## Daily Workflow
33353436```bash
3537# 1. Check what needs syncing
···7274- **Always commit before sync**: `monopam sync` only exports committed changes
7375- **Check status first**: Run `monopam status` to see what needs attention
7476- **One repo per directory**: Each subdirectory maps to exactly one git remote
7777+|}
75787676-## Troubleshooting
7979+let claude_md_troubleshooting =
8080+ {|## Troubleshooting
77817882### "Dirty packages" Error
7983Commit your changes first:
···109113monopam status --help # Status command help
110114```
111115|}
116116+117117+let claude_md_content =
118118+ String.concat "\n"
119119+ [ claude_md_header; claude_md_workflow; claude_md_troubleshooting ]
112120113121let gitignore_content = {|_build
114122*.install
···305313306314(** {1 Monorepo Initialization} *)
307315308308-let ensure ~proc ~fs ~config =
309309- let monorepo = Config.Paths.monorepo config in
310310- let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
311311- let init_and_commit () =
312312- Log.info (fun m -> m "Initializing monorepo at %a" Fpath.pp monorepo);
313313- let (_ : Git.Repository.t) = Git.Repository.init ~fs monorepo in
314314- let dune_project = Eio.Path.(monorepo_eio / "dune-project") in
315315- Log.debug (fun m -> m "Creating dune-project file");
316316- Eio.Path.save ~create:(`Or_truncate 0o644) dune_project "(lang dune 3.20)\n";
317317- let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in
318318- Log.debug (fun m -> m "Creating CLAUDE.md");
319319- Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content;
320320- let gitignore = Eio.Path.(monorepo_eio / ".gitignore") in
321321- Log.debug (fun m -> m "Creating .gitignore");
322322- Eio.Path.save ~create:(`Or_truncate 0o644) gitignore gitignore_content;
323323- Log.debug (fun m -> m "Staging and committing initial files");
324324- let repo = Git.Repository.open_repo ~fs monorepo in
325325- Result.bind
326326- (Git.Repository.add_to_index repo
327327- [ "dune-project"; "CLAUDE.md"; ".gitignore" ]
316316+let create_and_commit ~fs ~monorepo ~monorepo_eio =
317317+ Log.info (fun m -> m "Initializing monorepo at %a" Fpath.pp monorepo);
318318+ let (_ : Git.Repository.t) = Git.Repository.init ~fs monorepo in
319319+ let dune_project = Eio.Path.(monorepo_eio / "dune-project") in
320320+ Log.debug (fun m -> m "Creating dune-project file");
321321+ Eio.Path.save ~create:(`Or_truncate 0o644) dune_project "(lang dune 3.20)\n";
322322+ let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in
323323+ Log.debug (fun m -> m "Creating CLAUDE.md");
324324+ Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content;
325325+ let gitignore = Eio.Path.(monorepo_eio / ".gitignore") in
326326+ Log.debug (fun m -> m "Creating .gitignore");
327327+ Eio.Path.save ~create:(`Or_truncate 0o644) gitignore gitignore_content;
328328+ Log.debug (fun m -> m "Staging and committing initial files");
329329+ let repo = Git.Repository.open_repo ~fs monorepo in
330330+ Result.bind
331331+ (Git.Repository.add_to_index repo
332332+ [ "dune-project"; "CLAUDE.md"; ".gitignore" ]
333333+ |> Result.map_error (fun (`Msg msg) -> Ctx.Git_error (Git_cli.Io_error msg))
334334+ )
335335+ (fun () ->
336336+ let user =
337337+ match Git_cli.global_git_user ~fs () with
338338+ | Some u -> u
339339+ | None ->
340340+ Git.User.v ~name:"monopam" ~email:"monopam@localhost"
341341+ ~date:(Int64.of_float (Unix.time ()))
342342+ ()
343343+ in
344344+ Git.Repository.commit_index repo ~author:user ~committer:user
345345+ ~message:"Initial commit with dune-project, CLAUDE.md, and .gitignore"
346346+ ()
347347+ |> Result.map ignore
328348 |> Result.map_error (fun (`Msg msg) ->
329349 Ctx.Git_error (Git_cli.Io_error msg)))
330330- (fun () ->
331331- let user =
332332- match Git_cli.global_git_user ~fs () with
333333- | Some u -> u
334334- | None ->
335335- Git.User.v ~name:"monopam" ~email:"monopam@localhost"
336336- ~date:(Int64.of_float (Unix.time ()))
337337- ()
338338- in
339339- Git.Repository.commit_index repo ~author:user ~committer:user
340340- ~message:"Initial commit with dune-project, CLAUDE.md, and .gitignore"
341341- ()
342342- |> Result.map ignore
343343- |> Result.map_error (fun (`Msg msg) ->
344344- Ctx.Git_error (Git_cli.Io_error msg)))
350350+351351+let ensure_file ~proc ~monorepo_eio ~filename ~content =
352352+ let file_path = Eio.Path.(monorepo_eio / filename) in
353353+ let exists =
354354+ match Eio.Path.kind ~follow:true file_path with
355355+ | `Regular_file -> true
356356+ | _ | (exception Eio.Io _) -> false
345357 in
346346- let ensure_file ~filename ~content =
347347- let file_path = Eio.Path.(monorepo_eio / filename) in
348348- let exists =
349349- match Eio.Path.kind ~follow:true file_path with
350350- | `Regular_file -> true
351351- | _ | (exception Eio.Io _) -> false
352352- in
353353- if not exists then begin
354354- Log.info (fun m -> m "Adding %s to monorepo" filename);
355355- Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
356356- Eio.Switch.run (fun sw ->
357357- let child =
358358- Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
359359- [ "git"; "add"; filename ]
360360- in
361361- ignore (Eio.Process.await child));
362362- Eio.Switch.run (fun sw ->
363363- let child =
364364- Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
365365- [ "git"; "commit"; "-m"; "Add " ^ filename ]
366366- in
367367- ignore (Eio.Process.await child))
368368- end
369369- in
358358+ if not exists then begin
359359+ Log.info (fun m -> m "Adding %s to monorepo" filename);
360360+ Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
361361+ Eio.Switch.run (fun sw ->
362362+ let child =
363363+ Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
364364+ [ "git"; "add"; filename ]
365365+ in
366366+ ignore (Eio.Process.await child));
367367+ Eio.Switch.run (fun sw ->
368368+ let child =
369369+ Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
370370+ [ "git"; "commit"; "-m"; "Add " ^ filename ]
371371+ in
372372+ ignore (Eio.Process.await child))
373373+ end
374374+375375+let ensure ~proc ~fs ~config =
376376+ let monorepo = Config.Paths.monorepo config in
377377+ let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
370378 let is_directory =
371379 match Eio.Path.kind ~follow:true monorepo_eio with
372380 | `Directory -> true
···376384 if is_directory && Git.Repository.is_repo ~fs monorepo then begin
377385 Log.debug (fun m ->
378386 m "Monorepo already initialized at %a" Fpath.pp monorepo);
379379- ensure_file ~filename:"CLAUDE.md" ~content:claude_md_content;
380380- ensure_file ~filename:".gitignore" ~content:gitignore_content;
387387+ ensure_file ~proc ~monorepo_eio ~filename:"CLAUDE.md"
388388+ ~content:claude_md_content;
389389+ ensure_file ~proc ~monorepo_eio ~filename:".gitignore"
390390+ ~content:gitignore_content;
381391 Ok ()
382392 end
383393 else begin
···385395 Log.debug (fun m -> m "Creating monorepo directory %a" Fpath.pp monorepo);
386396 Ctx.mkdirs monorepo_eio
387397 end;
388388- init_and_commit ()
398398+ create_and_commit ~fs ~monorepo ~monorepo_eio
389399 end
+87-83
lib/opam_sync.ml
···126126 orphaned;
127127 orphaned
128128129129+let filter_packages ~packages all_pkgs =
130130+ match packages with
131131+ | [] -> all_pkgs
132132+ | names ->
133133+ List.filter
134134+ (fun p -> List.exists (fun n -> Pkg.matches_name n p) names)
135135+ all_pkgs
136136+137137+let sync_packages_with_progress ~fs ~opam_repo ~label pkgs =
138138+ let total = List.length pkgs in
139139+ let progress = Tty.Progress.create ~total label in
140140+ let sync_results =
141141+ List.mapi
142142+ (fun i pkg ->
143143+ Tty.Progress.message progress (Pkg.name pkg);
144144+ Tty.Progress.set progress (i + 1);
145145+ sync_package ~fs ~opam_repo pkg)
146146+ pkgs
147147+ in
148148+ Tty.Progress.finish progress;
149149+ sync_results
150150+151151+let commit_sync_result ~fs ~opam_repo result =
152152+ if result.synced <> [] || result.orphaned <> [] then begin
153153+ let repo = Git.Repository.open_repo ~fs opam_repo in
154154+ let msg = commit_message result in
155155+ match Git_cli.global_git_user ~fs () with
156156+ | Some user -> (
157157+ match
158158+ Git.Repository.commit_index repo ~author:user ~committer:user
159159+ ~message:msg ()
160160+ with
161161+ | Ok _ -> ()
162162+ | Error (`Msg e) -> Log.warn (fun m -> m "Failed to commit: %s" e))
163163+ | None -> Log.warn (fun m -> m "No git user config found, skipping commit")
164164+ end
165165+129166let run ~fs ~config ?(packages = []) () =
130167 let monorepo = Config.Paths.monorepo config in
131168 let sources = load_sources ~fs ~dir:monorepo in
132169 match Pkg.discover ~fs ~config ~sources () with
133170 | Error (`Config_error e) -> Error (`Config_error e)
134171 | Ok all_pkgs ->
135135- let pkgs =
136136- match packages with
137137- | [] -> all_pkgs
138138- | names ->
139139- List.filter
140140- (fun p -> List.exists (fun n -> Pkg.matches_name n p) names)
141141- all_pkgs
142142- in
143143- let total = List.length pkgs in
144144- let progress = Tty.Progress.create ~total "Split" in
172172+ let pkgs = filter_packages ~packages all_pkgs in
145173 let opam_repo = Config.Paths.opam_repo config in
146174 let sync_results =
147147- List.mapi
148148- (fun i pkg ->
149149- Tty.Progress.message progress (Pkg.name pkg);
150150- Tty.Progress.set progress (i + 1);
151151- sync_package ~fs ~opam_repo pkg)
152152- pkgs
175175+ sync_packages_with_progress ~fs ~opam_repo ~label:"Split" pkgs
153176 in
154154- Tty.Progress.finish progress;
155177 let synced, unchanged =
156178 List.fold_left
157179 (fun (s, u) r ->
···173195 orphaned = deleted;
174196 }
175197 in
176176- if result.synced <> [] || result.orphaned <> [] then begin
177177- let repo = Git.Repository.open_repo ~fs opam_repo in
178178- let msg = commit_message result in
179179- match Git_cli.global_git_user ~fs () with
180180- | Some user -> (
181181- match
182182- Git.Repository.commit_index repo ~author:user ~committer:user
183183- ~message:msg ()
184184- with
185185- | Ok _ -> ()
186186- | Error (`Msg e) -> Log.warn (fun m -> m "Failed to commit: %s" e))
187187- | None ->
188188- Log.warn (fun m -> m "No git user config found, skipping commit")
189189- end;
198198+ commit_sync_result ~fs ~opam_repo result;
190199 Ok result
191200192201(** {1 CWD-based Export} *)
···367376 | None -> Log.warn (fun m -> m "No git user config found, skipping commit")
368377 end
369378379379+let delete_orphaned_at ~fs ~target ~packages ~generated_names ~dry_run =
380380+ if packages = [] && not dry_run then begin
381381+ let existing = list_opam_repo_packages_at ~fs target in
382382+ let orphaned =
383383+ List.filter (fun name -> not (List.mem name generated_names)) existing
384384+ in
385385+ List.iter
386386+ (fun name ->
387387+ Log.info (fun m -> m "Removing orphaned package: %s" name);
388388+ ignore (delete_opam_repo_package_at ~fs target name))
389389+ orphaned;
390390+ orphaned
391391+ end
392392+ else []
393393+394394+let export_packages ~fs ~target ~packages ~dry_run ~no_commit pkgs =
395395+ let total = List.length pkgs in
396396+ let progress = Tty.Progress.create ~total "Export" in
397397+ let sync_results =
398398+ List.mapi
399399+ (fun i pkg ->
400400+ Tty.Progress.message progress (Pkg.name pkg);
401401+ Tty.Progress.set progress (i + 1);
402402+ sync_package_to ~fs ~opam_repo:target ~dry_run pkg)
403403+ pkgs
404404+ in
405405+ Tty.Progress.finish progress;
406406+ let synced, unchanged = partition_sync_results sync_results in
407407+ let generated_names =
408408+ List.map Pkg.name pkgs |> List.sort_uniq String.compare
409409+ in
410410+ let deleted =
411411+ delete_orphaned_at ~fs ~target ~packages ~generated_names ~dry_run
412412+ in
413413+ let result =
414414+ {
415415+ synced = List.rev synced;
416416+ unchanged = List.rev unchanged;
417417+ missing = [];
418418+ orphaned = deleted;
419419+ }
420420+ in
421421+ commit_if_needed ~fs ~target ~no_commit ~dry_run result;
422422+ Ok result
423423+370424let run_from_cwd ~fs ~proc:_ ~source ~target ?(packages = [])
371425 ?(no_commit = false) ?(dry_run = false) () =
372426 match discover_from_cwd ~fs ~source with
373427 | Error e -> Error e
374428 | Ok all_pkgs ->
375375- let pkgs =
376376- match packages with
377377- | [] -> all_pkgs
378378- | names ->
379379- List.filter
380380- (fun p -> List.exists (fun n -> Pkg.matches_name n p) names)
381381- all_pkgs
382382- in
429429+ let pkgs = filter_packages ~packages all_pkgs in
383430 if pkgs = [] then begin
384431 Log.info (fun m -> m "No packages found to export");
385432 Ok { synced = []; unchanged = []; missing = []; orphaned = [] }
386433 end
387387- else begin
388388- let total = List.length pkgs in
389389- let progress = Tty.Progress.create ~total "Export" in
390390- let sync_results =
391391- List.mapi
392392- (fun i pkg ->
393393- Tty.Progress.message progress (Pkg.name pkg);
394394- Tty.Progress.set progress (i + 1);
395395- sync_package_to ~fs ~opam_repo:target ~dry_run pkg)
396396- pkgs
397397- in
398398- Tty.Progress.finish progress;
399399- let synced, unchanged = partition_sync_results sync_results in
400400- let generated_names =
401401- List.map Pkg.name pkgs |> List.sort_uniq String.compare
402402- in
403403- let deleted =
404404- if packages = [] && not dry_run then begin
405405- let existing = list_opam_repo_packages_at ~fs target in
406406- let orphaned =
407407- List.filter
408408- (fun name -> not (List.mem name generated_names))
409409- existing
410410- in
411411- List.iter
412412- (fun name ->
413413- Log.info (fun m -> m "Removing orphaned package: %s" name);
414414- ignore (delete_opam_repo_package_at ~fs target name))
415415- orphaned;
416416- orphaned
417417- end
418418- else []
419419- in
420420- let result =
421421- {
422422- synced = List.rev synced;
423423- unchanged = List.rev unchanged;
424424- missing = [];
425425- orphaned = deleted;
426426- }
427427- in
428428- commit_if_needed ~fs ~target ~no_commit ~dry_run result;
429429- Ok result
430430- end
434434+ else export_packages ~fs ~target ~packages ~dry_run ~no_commit pkgs
+121-139
lib/pull.ml
···17171818(** {1 Subtree Operations} *)
19192020+let subtree_merge_or_add ~git_repo ~prefix ~commit ~user ~url ~hash_hex
2121+ ~subtree_exists =
2222+ let verb, fn, added =
2323+ if subtree_exists then ("Merge", Git.Subtree.merge, false)
2424+ else ("Add", Git.Subtree.add, true)
2525+ in
2626+ let message =
2727+ Fmt.str
2828+ "%s '%s/' from %s\n\ngit-subtree-dir: %s\ngit-subtree-mainline: %s\n" verb
2929+ prefix (Uri.to_string url) prefix hash_hex
3030+ in
3131+ match
3232+ fn git_repo ~prefix ~commit ~author:user ~committer:user ~message ()
3333+ with
3434+ | Ok _ -> Ok added
3535+ | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg))
3636+2037let subtree ~proc ~fs ~config pkg =
2138 let fs = Ctx.fs_typed fs in
2239 let monorepo = Config.Paths.monorepo config in
···3956 ~date:(Int64.of_float (Unix.time ()))
4057 ()
4158 in
4242- if subtree_exists then begin
4343- Log.info (fun m ->
4444- m "Pulling subtree %s from %a" prefix Fpath.pp checkout_dir);
4545- let message =
4646- Fmt.str
4747- "Merge '%s/' from %s\n\n\
4848- git-subtree-dir: %s\n\
4949- git-subtree-mainline: %s\n"
5050- prefix (Uri.to_string url) prefix hash_hex
5151- in
5252- match
5353- Git.Subtree.merge git_repo ~prefix ~commit ~author:user
5454- ~committer:user ~message ()
5555- with
5656- | Ok _ -> Ok false
5757- | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg))
5858- end
5959- else begin
6060- Log.info (fun m ->
6161- m "Adding subtree %s from %a" prefix Fpath.pp checkout_dir);
6262- let message =
6363- Fmt.str
6464- "Add '%s/' from %s\n\n\
6565- git-subtree-dir: %s\n\
6666- git-subtree-mainline: %s\n"
6767- prefix (Uri.to_string url) prefix hash_hex
6868- in
6969- match
7070- Git.Subtree.add git_repo ~prefix ~commit ~author:user ~committer:user
7171- ~message ()
7272- with
7373- | Ok _ -> Ok true
7474- | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg))
7575- end
5959+ Log.info (fun m ->
6060+ m "%s subtree %s from %a"
6161+ (if subtree_exists then "Pulling" else "Adding")
6262+ prefix Fpath.pp checkout_dir);
6363+ subtree_merge_or_add ~git_repo ~prefix ~commit ~user ~url ~hash_hex
6464+ ~subtree_exists
76657766(** {1 Main Pull Operation} *)
78677979-let run ~proc ~fs ~config ?(packages = []) ?opam_repo_url () =
8080- let fs_t = Ctx.fs_typed fs in
8181- let opam_repo = Config.Paths.opam_repo config in
8282- if Git.Repository.is_repo ~fs:fs_t opam_repo then begin
6868+let ensure_opam_repo ~proc ~fs ~opam_repo ~opam_repo_url =
6969+ if Git.Repository.is_repo ~fs opam_repo then begin
8370 Log.info (fun m -> m "Updating opam repo at %a" Fpath.pp opam_repo);
8471 let result =
8572 let ( let* ) = Result.bind in
8686- let* () = Git_cli.fetch ~proc ~fs:fs_t opam_repo in
8787- Git_cli.merge_ff ~proc ~fs:fs_t opam_repo
7373+ let* () = Git_cli.fetch ~proc ~fs opam_repo in
7474+ Git_cli.merge_ff ~proc ~fs opam_repo
8875 in
8976 match result with
9077 | Ok () -> ()
···9986 m "Cloning opam repo from %s to %a" url Fpath.pp opam_repo);
10087 let url = Uri.of_string url in
10188 let branch = Config.default_branch in
102102- match Git_cli.clone ~proc ~fs:fs_t ~url ~branch opam_repo with
8989+ match Git_cli.clone ~proc ~fs ~url ~branch opam_repo with
10390 | Ok () -> Log.info (fun m -> m "Opam repo cloned successfully")
10491 | Error e ->
10592 Log.warn (fun m ->
···10895 Log.info (fun m ->
10996 m "Opam repo at %a does not exist and no URL provided" Fpath.pp
11097 opam_repo)
111111- end;
9898+ end
9999+100100+let clone_repos ~proc ~fs ~config repos =
101101+ let total = List.length repos in
102102+ let progress = Tty.Progress.create ~total "Fetch" in
103103+ let rec loop acc = function
104104+ | [] ->
105105+ Tty.Progress.clear progress;
106106+ Ok (List.rev acc)
107107+ | pkg :: rest -> (
108108+ let repo_name = Package.repo_name pkg in
109109+ Tty.Progress.message progress
110110+ (Fmt.str "Fetch: %s (%d/%d)" repo_name (List.length acc + 1) total);
111111+ Log.info (fun m -> m "Fetching repo %s" repo_name);
112112+ let existed = Ctx.checkout_exists ~fs ~config pkg in
113113+ let behind_before = if existed then Ctx.behind ~fs ~config pkg else 0 in
114114+ match Ctx.ensure_checkout ~proc ~fs ~config pkg with
115115+ | Error e ->
116116+ Tty.Progress.clear progress;
117117+ Error (Ctx.Git_error e)
118118+ | Ok () ->
119119+ Tty.Progress.tick progress;
120120+ let result =
121121+ {
122122+ repo_name;
123123+ cloned = not existed;
124124+ commits_pulled = behind_before;
125125+ subtree_added = false;
126126+ }
127127+ in
128128+ loop (result :: acc) rest)
129129+ in
130130+ loop [] repos
131131+132132+let process_subtrees ~proc ~fs ~config repos checkout_results =
133133+ let total = List.length repos in
134134+ let progress = Tty.Progress.create ~total "Subtree" in
135135+ let rec loop results_acc repos_left checkout_results_left =
136136+ match (repos_left, checkout_results_left) with
137137+ | [], [] ->
138138+ Tty.Progress.clear progress;
139139+ Ok (List.rev results_acc)
140140+ | pkg :: rest_repos, cr :: rest_cr -> (
141141+ let name = Package.subtree_prefix pkg in
142142+ Tty.Progress.message progress
143143+ (Fmt.str "Subtree: %s (%d/%d)" name
144144+ (List.length results_acc + 1)
145145+ total);
146146+ Log.info (fun m -> m "Subtree %s" name);
147147+ match subtree ~proc ~fs ~config pkg with
148148+ | Ok subtree_added ->
149149+ Tty.Progress.tick progress;
150150+ let result = { cr with subtree_added } in
151151+ loop (result :: results_acc) rest_repos rest_cr
152152+ | Error e ->
153153+ Tty.Progress.clear progress;
154154+ Error e)
155155+ | _ ->
156156+ Tty.Progress.clear progress;
157157+ Ok (List.rev results_acc)
158158+ in
159159+ loop [] repos checkout_results
160160+161161+let log_pull_results results =
162162+ let cloned = List.filter (fun r -> r.cloned) results in
163163+ let updated =
164164+ List.filter (fun r -> (not r.cloned) && r.commits_pulled > 0) results
165165+ in
166166+ let added = List.filter (fun r -> r.subtree_added) results in
167167+ List.iter (fun r -> Log.app (fun m -> m " + %s (cloned)" r.repo_name)) cloned;
168168+ List.iter
169169+ (fun r ->
170170+ Log.app (fun m -> m " ✓ %s (%d commits)" r.repo_name r.commits_pulled))
171171+ updated;
172172+ List.iter (fun r -> Log.app (fun m -> m " + %s (added)" r.repo_name)) added;
173173+ let unchanged =
174174+ List.length results - List.length cloned - List.length updated
175175+ - List.length added
176176+ in
177177+ if cloned = [] && updated = [] && added = [] then
178178+ Log.app (fun m ->
179179+ m " All %d repositories up to date." (List.length results))
180180+ else if unchanged > 0 then Log.app (fun m -> m " %d unchanged." unchanged)
181181+182182+let run ~proc ~fs ~config ?(packages = []) ?opam_repo_url () =
183183+ let fs_t = Ctx.fs_typed fs in
184184+ let opam_repo = Config.Paths.opam_repo config in
185185+ ensure_opam_repo ~proc ~fs:fs_t ~opam_repo ~opam_repo_url;
112186 Ctx.ensure_checkouts_dir ~fs:fs_t ~config;
113187 match Init.ensure ~proc ~fs:fs_t ~config with
114188 | Error e -> Error e
···141215 Log.info (fun m ->
142216 m "Cloning/fetching %d unique repositories"
143217 (List.length repos));
144144- let clone_repos () =
145145- let total = List.length repos in
146146- let progress = Tty.Progress.create ~total "Fetch" in
147147- let rec loop acc = function
148148- | [] ->
149149- Tty.Progress.clear progress;
150150- Ok (List.rev acc)
151151- | pkg :: rest -> (
152152- let repo_name = Package.repo_name pkg in
153153- Tty.Progress.message progress
154154- (Fmt.str "Fetch: %s (%d/%d)" repo_name
155155- (List.length acc + 1)
156156- total);
157157- Log.info (fun m -> m "Fetching repo %s" repo_name);
158158- let existed = Ctx.checkout_exists ~fs:fs_t ~config pkg in
159159- let behind_before =
160160- if existed then Ctx.behind ~fs:fs_t ~config pkg else 0
161161- in
162162- match Ctx.ensure_checkout ~proc ~fs:fs_t ~config pkg with
163163- | Error e ->
164164- Tty.Progress.clear progress;
165165- Error (Ctx.Git_error e)
166166- | Ok () ->
167167- Tty.Progress.tick progress;
168168- let result =
169169- {
170170- repo_name;
171171- cloned = not existed;
172172- commits_pulled = behind_before;
173173- subtree_added = false;
174174- }
175175- in
176176- loop (result :: acc) rest)
177177- in
178178- loop [] repos
179179- in
180180- match clone_repos () with
218218+ match clone_repos ~proc ~fs:fs_t ~config repos with
181219 | Error e -> Error e
182220 | Ok checkout_results -> (
183221 Log.info (fun m ->
184222 m "Processing %d unique subtrees" (List.length repos));
185185- let total = List.length repos in
186186- let progress = Tty.Progress.create ~total "Subtree" in
187187- let rec loop results_acc repos_left checkout_results_left =
188188- match (repos_left, checkout_results_left) with
189189- | [], [] ->
190190- Tty.Progress.clear progress;
191191- Ok (List.rev results_acc)
192192- | pkg :: rest_repos, cr :: rest_cr -> (
193193- let name = Package.subtree_prefix pkg in
194194- Tty.Progress.message progress
195195- (Fmt.str "Subtree: %s (%d/%d)" name
196196- (List.length results_acc + 1)
197197- total);
198198- Log.info (fun m -> m "Subtree %s" name);
199199- match subtree ~proc ~fs ~config pkg with
200200- | Ok subtree_added ->
201201- Tty.Progress.tick progress;
202202- let result = { cr with subtree_added } in
203203- loop (result :: results_acc) rest_repos rest_cr
204204- | Error e ->
205205- Tty.Progress.clear progress;
206206- Error e)
207207- | _ ->
208208- Tty.Progress.clear progress;
209209- Ok (List.rev results_acc)
210210- in
211211- match loop [] repos checkout_results with
223223+ match
224224+ process_subtrees ~proc ~fs ~config repos checkout_results
225225+ with
212226 | Error e -> Error e
213227 | Ok results ->
214214- let cloned = List.filter (fun r -> r.cloned) results in
215215- let updated =
216216- List.filter
217217- (fun r -> (not r.cloned) && r.commits_pulled > 0)
218218- results
219219- in
220220- let added =
221221- List.filter (fun r -> r.subtree_added) results
222222- in
223223- List.iter
224224- (fun r ->
225225- Log.app (fun m -> m " + %s (cloned)" r.repo_name))
226226- cloned;
227227- List.iter
228228- (fun r ->
229229- Log.app (fun m ->
230230- m " ✓ %s (%d commits)" r.repo_name
231231- r.commits_pulled))
232232- updated;
233233- List.iter
234234- (fun r ->
235235- Log.app (fun m -> m " + %s (added)" r.repo_name))
236236- added;
237237- let unchanged =
238238- List.length results - List.length cloned
239239- - List.length updated - List.length added
240240- in
241241- if cloned = [] && updated = [] && added = [] then
242242- Log.app (fun m ->
243243- m " All %d repositories up to date."
244244- (List.length results))
245245- else if unchanged > 0 then
246246- Log.app (fun m -> m " %d unchanged." unchanged);
228228+ log_pull_results results;
247229 Init.write_readme ~proc ~fs:fs_t ~config all_pkgs;
248230 Init.write_claude_md ~proc ~fs:fs_t ~config;
249231 Init.write_dune_project ~proc ~fs:fs_t ~config all_pkgs;
+147-149
lib/push.ml
···991010(** {1 Single Package Push} *)
11111212+let checkout_tree_hash ~fs checkout_dir =
1313+ let checkout_repo = Git.Repository.open_repo ~fs checkout_dir in
1414+ match Git.Repository.head checkout_repo with
1515+ | None -> None
1616+ | Some h -> (
1717+ match Git.Repository.read checkout_repo h with
1818+ | Ok (Git.Value.Commit c) -> Some (Git.Commit.tree c)
1919+ | _ -> None)
2020+2121+let split_and_push ~proc ~fs ~monorepo ~git_repo ~prefix ~checkout_url ~clean
2222+ ~branch =
2323+ let _checked, errors = Git.Subtree.verify git_repo ~prefix () in
2424+ if errors <> [] then begin
2525+ Log.info (fun m ->
2626+ m "Clearing invalid cache for %s (%d errors)" prefix
2727+ (List.length errors));
2828+ Git.Subtree.Cache.clear git_repo ~prefix
2929+ end;
3030+ match Git.Repository.read_ref git_repo "HEAD" with
3131+ | None -> Error (Ctx.Git_error (Git_cli.Io_error "no HEAD ref found"))
3232+ | Some head -> (
3333+ match Git.Subtree.split git_repo ~prefix ~head () with
3434+ | Ok None -> Error (Ctx.Git_error (Git_cli.Subtree_prefix_missing prefix))
3535+ | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg))
3636+ | Ok (Some split_hash) -> (
3737+ let final_hash =
3838+ if clean then (
3939+ match Git.Subtree.fix git_repo ~prefix ~head:split_hash () with
4040+ | Ok (Some h) ->
4141+ Log.info (fun m -> m "Cleaned history for %s" prefix);
4242+ h
4343+ | Ok None -> split_hash
4444+ | Error (`Msg msg) ->
4545+ Log.warn (fun m -> m "Failed to clean %s: %s" prefix msg);
4646+ split_hash)
4747+ else split_hash
4848+ in
4949+ let refspec = Git.Hash.to_hex final_hash ^ ":refs/heads/" ^ branch in
5050+ match
5151+ Git_cli.push_refspec ~proc ~fs ~repo:monorepo ~url:checkout_url
5252+ ~refspec ~force:clean ()
5353+ with
5454+ | Ok () -> Ok ()
5555+ | Error e -> Error (Ctx.Git_error e)))
5656+1257let one ~proc ~fs ~config ~clean pkg =
1358 let ( let* ) r f =
1459 Result.bind (Result.map_error (fun e -> Ctx.Git_error e) r) f
···4085 in
4186 let checkout_url = Uri.of_string (Fpath.to_string checkout_dir) in
4287 let git_repo = Git.Repository.open_repo ~fs monorepo in
4343- let checkout_repo = Git.Repository.open_repo ~fs checkout_dir in
4488 let mono_tree =
4589 Git.Repository.tree_hash_at_path git_repo ~rev:"HEAD" ~path:prefix
4690 in
4747- let checkout_tree =
4848- match Git.Repository.head checkout_repo with
4949- | None -> None
5050- | Some h -> (
5151- match Git.Repository.read checkout_repo h with
5252- | Ok (Git.Value.Commit c) -> Some (Git.Commit.tree c)
5353- | _ -> None)
5454- in
9191+ let checkout_tree = checkout_tree_hash ~fs checkout_dir in
5592 if mono_tree = checkout_tree && mono_tree <> None then begin
5693 Log.debug (fun m -> m "Skipping %s (trees match)" prefix);
5794 Ok ()
5895 end
5996 else begin
6097 Log.info (fun m -> m "Subtree push %s -> %a" prefix Fpath.pp checkout_dir);
6161- let _checked, errors = Git.Subtree.verify git_repo ~prefix () in
6262- if errors <> [] then begin
6363- Log.info (fun m ->
6464- m "Clearing invalid cache for %s (%d errors)" prefix
6565- (List.length errors));
6666- Git.Subtree.Cache.clear git_repo ~prefix
6767- end;
6868- match Git.Repository.read_ref git_repo "HEAD" with
6969- | None -> Error (Ctx.Git_error (Git_cli.Io_error "no HEAD ref found"))
7070- | Some head -> (
7171- match Git.Subtree.split git_repo ~prefix ~head () with
7272- | Ok None ->
7373- Error (Ctx.Git_error (Git_cli.Subtree_prefix_missing prefix))
7474- | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg))
7575- | Ok (Some split_hash) -> (
7676- let final_hash =
7777- if clean then (
7878- match
7979- Git.Subtree.fix git_repo ~prefix ~head:split_hash ()
8080- with
8181- | Ok (Some h) ->
8282- Log.info (fun m -> m "Cleaned history for %s" prefix);
8383- h
8484- | Ok None -> split_hash
8585- | Error (`Msg msg) ->
8686- Log.warn (fun m -> m "Failed to clean %s: %s" prefix msg);
8787- split_hash)
8888- else split_hash
8989- in
9090- let refspec =
9191- Git.Hash.to_hex final_hash ^ ":refs/heads/" ^ branch
9292- in
9393- match
9494- Git_cli.push_refspec ~proc ~fs ~repo:monorepo ~url:checkout_url
9595- ~refspec ~force:clean ()
9696- with
9797- | Ok () -> Ok ()
9898- | Error e -> Error (Ctx.Git_error e)))
9898+ split_and_push ~proc ~fs ~monorepo ~git_repo ~prefix ~checkout_url ~clean
9999+ ~branch
99100 end
100101 end
101102···127128128129(** {1 Main Push Operation} *)
129130131131+let export_repos ~proc ~fs ~config ~clean ~progress repos =
132132+ let update_progress name =
133133+ Tty.Progress.update progress ~phase:"Export" ~msg:name
134134+ in
135135+ let rec loop pushed_repos = function
136136+ | [] -> Ok (List.rev pushed_repos)
137137+ | pkg :: rest -> (
138138+ let name = Package.subtree_prefix pkg in
139139+ update_progress name;
140140+ Log.debug (fun m -> m "Subtree push %s" name);
141141+ match one ~proc ~fs ~config ~clean pkg with
142142+ | Ok () -> loop (pkg :: pushed_repos) rest
143143+ | Error e ->
144144+ Tty.Progress.clear progress;
145145+ Error e)
146146+ in
147147+ loop [] repos
148148+149149+let to_upstream ~proc ~fs ~config ~force ~progress pushed_repos =
150150+ Log.info (fun m ->
151151+ m "Pushing %d repos to upstream (parallel)" (List.length pushed_repos));
152152+ let checkouts_root = Config.Paths.checkouts config in
153153+ Eio.Fiber.List.map ~max_fibers:8
154154+ (fun pkg ->
155155+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
156156+ let name = Package.repo_name pkg in
157157+ Tty.Progress.update progress ~phase:"Push" ~msg:name;
158158+ let branch = Ctx.branch ~config pkg in
159159+ let knot = Config.knot config in
160160+ let push_url = Ctx.url_to_push_url ~knot (Package.dev_repo pkg) in
161161+ Log.info (fun m -> m "Pushing %s to %s" name push_url);
162162+ let repo = Git.Repository.open_repo ~fs checkout_dir in
163163+ (match Git.Repository.set_push_url repo ~name:"origin" ~url:push_url with
164164+ | Ok () -> ()
165165+ | Error (`Msg msg) ->
166166+ Log.warn (fun m -> m "Failed to set push URL: %s" msg));
167167+ match Git_cli.push_remote ~proc ~fs ~branch ~force checkout_dir with
168168+ | Ok () -> Ok name
169169+ | Error e -> Error (name, Ctx.Git_error e))
170170+ pushed_repos
171171+172172+let log_push_results push_results =
173173+ let successes, failures =
174174+ List.partition_map
175175+ (function Ok name -> Left name | Error (name, _) -> Right name)
176176+ push_results
177177+ in
178178+ List.iter (fun name -> Log.app (fun m -> m " ✓ %s" name)) successes;
179179+ List.iter (fun name -> Log.app (fun m -> m " ✗ %s" name)) failures;
180180+ match List.find_opt Result.is_error push_results with
181181+ | Some (Error (_, e)) -> Error e
182182+ | _ -> Ok ()
183183+184184+let repos_to_push statuses pkgs =
185185+ let status_by_prefix =
186186+ List.fold_left
187187+ (fun acc s ->
188188+ let prefix = Package.subtree_prefix s.Status.package in
189189+ (prefix, s) :: acc)
190190+ [] statuses
191191+ in
192192+ let needs_export pkg =
193193+ let prefix = Package.subtree_prefix pkg in
194194+ match List.assoc_opt prefix status_by_prefix with
195195+ | Some s -> not (Status.is_fully_synced s)
196196+ | None -> true
197197+ in
198198+ let all_repos = Ctx.unique_repos pkgs in
199199+ let repos = List.filter needs_export all_repos in
200200+ let skipped = List.length all_repos - List.length repos in
201201+ if skipped > 0 then
202202+ Log.info (fun m -> m "Skipping %d already-synced repos" skipped);
203203+ repos
204204+205205+let export_and_push ~proc ~fs ~fs_t ~config ~upstream ~clean ~force repos =
206206+ let n_repos = List.length repos in
207207+ let total = if upstream then n_repos * 2 else n_repos in
208208+ let progress = Tty.Progress.create ~total "Push" in
209209+ match export_repos ~proc ~fs ~config ~clean ~progress repos with
210210+ | Error e -> Error e
211211+ | Ok pushed_repos -> (
212212+ let push_results =
213213+ if upstream && pushed_repos <> [] then
214214+ to_upstream ~proc ~fs:fs_t ~config ~force ~progress pushed_repos
215215+ else List.map (fun p -> Ok (Package.repo_name p)) pushed_repos
216216+ in
217217+ Tty.Progress.clear progress;
218218+ match log_push_results push_results with
219219+ | Error e -> Error e
220220+ | Ok () ->
221221+ if upstream then workspace_repos ~proc ~fs:fs_t ~config ~force;
222222+ Ok ())
223223+130224let run ~proc ~fs ~config ?(packages = []) ?(upstream = false) ?(clean = false)
131225 ?(force = false) () =
132226 let fs_t = Ctx.fs_typed fs in
···154248 in
155249 if dirty <> [] then Error (Ctx.Dirty_state dirty)
156250 else begin
157157- let status_by_prefix =
158158- List.fold_left
159159- (fun acc s ->
160160- let prefix = Package.subtree_prefix s.Status.package in
161161- (prefix, s) :: acc)
162162- [] statuses
163163- in
164164- let needs_export pkg =
165165- let prefix = Package.subtree_prefix pkg in
166166- match List.assoc_opt prefix status_by_prefix with
167167- | Some s -> not (Status.is_fully_synced s)
168168- | None -> true
169169- in
170170- let all_repos = Ctx.unique_repos pkgs in
171171- let repos = List.filter needs_export all_repos in
172172- let skipped = List.length all_repos - List.length repos in
173173- if skipped > 0 then
174174- Log.info (fun m -> m "Skipping %d already-synced repos" skipped);
175175- Log.info (fun m -> m "Pushing %d unique repos" (List.length repos));
176176- let n_repos = List.length repos in
177177- if n_repos = 0 then begin
251251+ let to_push = repos_to_push statuses pkgs in
252252+ Log.info (fun m -> m "Pushing %d unique repos" (List.length to_push));
253253+ if to_push = [] then begin
178254 Log.app (fun m -> m "Nothing to push (all repos in sync)");
179255 Ok ()
180256 end
181181- else begin
182182- let total = if upstream then n_repos * 2 else n_repos in
183183- let progress = Tty.Progress.create ~total "Push" in
184184- let update_progress phase name =
185185- Tty.Progress.update progress ~phase ~msg:name
186186- in
187187- let rec loop pushed_repos = function
188188- | [] -> Ok (List.rev pushed_repos)
189189- | pkg :: rest -> (
190190- let name = Package.subtree_prefix pkg in
191191- update_progress "Export" name;
192192- Log.debug (fun m -> m "Subtree push %s" name);
193193- match one ~proc ~fs ~config ~clean pkg with
194194- | Ok () -> loop (pkg :: pushed_repos) rest
195195- | Error e ->
196196- Tty.Progress.clear progress;
197197- Error e)
198198- in
199199- match loop [] repos with
200200- | Error e -> Error e
201201- | Ok pushed_repos -> (
202202- let push_results =
203203- if upstream && pushed_repos <> [] then begin
204204- Log.info (fun m ->
205205- m "Pushing %d repos to upstream (parallel)"
206206- (List.length pushed_repos));
207207- let checkouts_root = Config.Paths.checkouts config in
208208- Eio.Fiber.List.map ~max_fibers:8
209209- (fun pkg ->
210210- let checkout_dir =
211211- Package.checkout_dir ~checkouts_root pkg
212212- in
213213- let name = Package.repo_name pkg in
214214- update_progress "Push" name;
215215- let branch = Ctx.branch ~config pkg in
216216- let knot = Config.knot config in
217217- let push_url =
218218- Ctx.url_to_push_url ~knot (Package.dev_repo pkg)
219219- in
220220- Log.info (fun m -> m "Pushing %s to %s" name push_url);
221221- let repo =
222222- Git.Repository.open_repo ~fs:fs_t checkout_dir
223223- in
224224- (match
225225- Git.Repository.set_push_url repo ~name:"origin"
226226- ~url:push_url
227227- with
228228- | Ok () -> ()
229229- | Error (`Msg msg) ->
230230- Log.warn (fun m ->
231231- m "Failed to set push URL: %s" msg));
232232- match
233233- Git_cli.push_remote ~proc ~fs:fs_t ~branch ~force
234234- checkout_dir
235235- with
236236- | Ok () -> Ok name
237237- | Error e -> Error (name, Ctx.Git_error e))
238238- pushed_repos
239239- end
240240- else List.map (fun p -> Ok (Package.repo_name p)) pushed_repos
241241- in
242242- Tty.Progress.clear progress;
243243- let successes, failures =
244244- List.partition_map
245245- (function
246246- | Ok name -> Left name | Error (name, _) -> Right name)
247247- push_results
248248- in
249249- List.iter
250250- (fun name -> Log.app (fun m -> m " ✓ %s" name))
251251- successes;
252252- List.iter
253253- (fun name -> Log.app (fun m -> m " ✗ %s" name))
254254- failures;
255255- match List.find_opt Result.is_error push_results with
256256- | Some (Error (_, e)) -> Error e
257257- | _ ->
258258- if upstream then
259259- workspace_repos ~proc ~fs:fs_t ~config ~force;
260260- Ok ())
261261- end
257257+ else
258258+ export_and_push ~proc ~fs ~fs_t ~config ~upstream ~clean ~force
259259+ to_push
262260 end
263261 end
+89-68
lib/site.ml
···196196 let cmp = compare b.ri_dep_count a.ri_dep_count in
197197 if cmp <> 0 then cmp else String.compare a.ri_name b.ri_name)
198198199199+let local_member ~registry ~handle_to_name ~local_handle ~local_pkgs =
200200+ let member = Verse_registry.member registry ~handle:local_handle in
201201+ let display_name =
202202+ try Hashtbl.find handle_to_name local_handle
203203+ with Not_found -> local_handle
204204+ in
205205+ {
206206+ handle = local_handle;
207207+ display_name;
208208+ monorepo_url = (match member with Some m -> m.monorepo | None -> "");
209209+ opam_url = (match member with Some m -> m.opamrepo | None -> "");
210210+ package_count = List.length local_pkgs;
211211+ unique_packages = [];
212212+ }
213213+214214+let compute_unique_packages all_packages =
215215+ let unique_by_handle = Hashtbl.create 32 in
216216+ List.iter
217217+ (fun (pkg : pkg_info) ->
218218+ if List.length pkg.owners = 1 then begin
219219+ let handle = List.hd pkg.owners in
220220+ let existing =
221221+ try Hashtbl.find unique_by_handle handle with Not_found -> []
222222+ in
223223+ Hashtbl.replace unique_by_handle handle (pkg.name :: existing)
224224+ end)
225225+ all_packages;
226226+ unique_by_handle
227227+228228+let apply_unique_packages unique_by_handle members =
229229+ List.map
230230+ (fun m ->
231231+ let unique =
232232+ try Hashtbl.find unique_by_handle m.handle with Not_found -> []
233233+ in
234234+ { m with unique_packages = List.sort String.compare unique })
235235+ members
236236+199237(** Collect site data from the workspace *)
200238let collect_data ~fs ~config ?forks ~registry () =
201239 let local_handle = Verse_config.handle config in
···210248211249 let pkg_map = Hashtbl.create 256 in
212250 add_packages_to_map pkg_map local_handle local_pkgs;
213213-214251 let handle_to_name = build_handle_names registry in
215215-216252 let member_infos =
217253 scan_tracked_members ~fs ~verse_path ~local_handle ~pkg_map ~registry
218254 ~handle_to_name
219255 in
220220-221256 let local_member =
222222- let member = Verse_registry.member registry ~handle:local_handle in
223223- let display_name =
224224- try Hashtbl.find handle_to_name local_handle
225225- with Not_found -> local_handle
226226- in
227227- {
228228- handle = local_handle;
229229- display_name;
230230- monorepo_url = (match member with Some m -> m.monorepo | None -> "");
231231- opam_url = (match member with Some m -> m.opamrepo | None -> "");
232232- package_count = List.length local_pkgs;
233233- unique_packages = [];
234234- }
257257+ local_member ~registry ~handle_to_name ~local_handle ~local_pkgs
235258 in
236236-237259 let all_packages = build_all_packages pkg_map in
238260 let all_repos = build_all_repos all_packages forks in
239261 let common_repos =
···242264 let unique_repos =
243265 List.filter (fun r -> List.length r.ri_owners = 1) all_repos
244266 in
245245-246246- let unique_by_handle = Hashtbl.create 32 in
247247- List.iter
248248- (fun (pkg : pkg_info) ->
249249- if List.length pkg.owners = 1 then begin
250250- let handle = List.hd pkg.owners in
251251- let existing =
252252- try Hashtbl.find unique_by_handle handle with Not_found -> []
253253- in
254254- Hashtbl.replace unique_by_handle handle (pkg.name :: existing)
255255- end)
256256- all_packages;
257257-258258- let update_member m =
259259- let unique =
260260- try Hashtbl.find unique_by_handle m.handle with Not_found -> []
261261- in
262262- { m with unique_packages = List.sort String.compare unique }
267267+ let unique_by_handle = compute_unique_packages all_packages in
268268+ let members =
269269+ apply_unique_packages unique_by_handle (local_member :: member_infos)
263270 in
264264-265265- let members = List.map update_member (local_member :: member_infos) in
266266-267271 {
268272 local_handle;
269273 registry_name = registry.Verse_registry.name;
···302306 | Forks.Unrelated -> "unrel"
303307 | Forks.Not_fetched -> "?"
304308305305-(** CSS styles for the site *)
306306-let css =
309309+let css_base =
307310 {|* { margin: 0; padding: 0; box-sizing: border-box; }
308311body { font: 10pt/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #333; max-width: 900px; margin: 0 auto; padding: 12px; }
309312h1 { font-size: 14pt; font-weight: 600; margin-bottom: 4px; }
···327330.summary-title { font-weight: 600; margin-bottom: 8px; }
328331.summary-list { font-size: 9pt; color: #555; line-height: 1.6; }
329332.summary-item { display: inline-block; background: #fff; border: 1px solid #ddd; padding: 1px 6px; border-radius: 3px; margin: 2px 2px; }
330330-.summary-item a { color: #333; }
331331-.repo { margin-bottom: 12px; padding: 8px; background: #fafafa; border-radius: 4px; }
333333+.summary-item a { color: #333; }|}
334334+335335+let css_components =
336336+ {|.repo { margin-bottom: 12px; padding: 8px; background: #fafafa; border-radius: 4px; }
332337.repo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
333338.repo-name { font-weight: 600; }
334339.repo-name a { color: #333; }
···354359.unique-list { font-size: 9pt; color: #666; margin-top: 2px; }
355360.intro { background: #f0f7ff; border: 1px solid #d0e3f5; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 9pt; line-height: 1.5; color: #444; }
356361footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 9pt; color: #888; }|}
362362+363363+let css = css_base ^ "\n" ^ css_components
357364358365(** Generate member card HTML *)
359366let generate_member_card buf m =
···472479 end;
473480 add "</div>\n"
474481475475-(** Generate HTML from site data *)
476476-let generate_html data =
477477- let buf = Buffer.create 16384 in
478478- let add = Buffer.add_string buf in
479479-482482+let build_member_lookups members =
480483 let member_urls = Hashtbl.create 16 in
481484 let member_names = Hashtbl.create 16 in
482485 List.iter
483486 (fun m ->
484487 Hashtbl.replace member_urls m.handle (m.monorepo_url, m.opam_url);
485488 Hashtbl.replace member_names m.handle m.display_name)
486486- data.members;
487487-489489+ members;
488490 let get_name handle =
489491 try Hashtbl.find member_names handle with Not_found -> handle
490492 in
493493+ (member_urls, get_name)
491494495495+let generate_html_head buf data =
496496+ let add = Buffer.add_string buf in
492497 add "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n";
493498 add "<meta charset=\"UTF-8\">\n";
494499 add
···497502 add "<style>\n";
498503 add css;
499504 add "\n</style>\n</head>\n<body>\n";
500500-501505 add (Fmt.str "<h1>%s</h1>\n" (html_escape data.registry_name));
502506 (match data.registry_description with
503507 | Some desc ->
504508 add (Fmt.str "<div class=\"subtitle\">%s</div>\n" (html_escape desc))
505509 | None -> add "<div class=\"subtitle\"></div>\n");
506506-507510 add
508511 {|<div class="intro">
509512This is an experiment in large-scale agentic coding using OCaml and OxCaml, where we're building environments to exchange vibe code at scale.
···515518 add external_link_icon;
516519 add {|</a>.
517520</div>
518518-|};
521521+|}
519522520520- add "<div class=\"section\">\n<h2>Members</h2>\n<div class=\"members\">\n";
521521- List.iter (generate_member_card buf) data.members;
522522- add "</div>\n</div>\n";
523523-523523+let generate_common_summary buf data =
524524+ let add = Buffer.add_string buf in
524525 add "<div class=\"section\">\n<div class=\"summary\">\n";
525526 add
526527 (Fmt.str
···540541 (html_escape r.ri_name) (html_escape r.ri_name)
541542 (List.length r.ri_packages)))
542543 data.common_repos;
543543- add "</div>\n</div>\n";
544544+ add "</div>\n</div>\n"
544545546546+let generate_unique_summary buf data =
547547+ let add = Buffer.add_string buf in
545548 let members_with_unique =
546549 List.filter (fun m -> m.unique_packages <> []) data.members
547550 in
···563566 add "</span>\n</div>\n")
564567 members_with_unique;
565568 add "</div>\n</div>\n"
566566- end;
567567- add "</div>\n";
568568-569569- if data.common_repos <> [] then begin
570570- add "<div class=\"section\">\n<h2>Repository Details</h2>\n";
571571- List.iter
572572- (generate_repo_detail buf ~member_urls ~get_name)
573573- data.common_repos;
574574- add "</div>\n"
575575- end;
569569+ end
576570571571+let generate_html_footer buf data =
572572+ let add = Buffer.add_string buf in
577573 let now = Unix.gettimeofday () in
578574 let tm = Unix.gmtime now in
579575 let date_str =
···587583 date_str (List.length data.members)
588584 (List.length data.common_repos + List.length data.unique_repos)
589585 (List.length data.all_packages));
586586+ add "</body>\n</html>\n"
590587591591- add "</body>\n</html>\n";
588588+(** Generate HTML from site data *)
589589+let generate_html data =
590590+ let buf = Buffer.create 16384 in
591591+ let add = Buffer.add_string buf in
592592+ let member_urls, get_name = build_member_lookups data.members in
593593+594594+ generate_html_head buf data;
595595+596596+ add "<div class=\"section\">\n<h2>Members</h2>\n<div class=\"members\">\n";
597597+ List.iter (generate_member_card buf) data.members;
598598+ add "</div>\n</div>\n";
599599+600600+ generate_common_summary buf data;
601601+ generate_unique_summary buf data;
602602+ add "</div>\n";
603603+604604+ if data.common_repos <> [] then begin
605605+ add "<div class=\"section\">\n<h2>Repository Details</h2>\n";
606606+ List.iter
607607+ (generate_repo_detail buf ~member_urls ~get_name)
608608+ data.common_repos;
609609+ add "</div>\n"
610610+ end;
611611+612612+ generate_html_footer buf data;
592613 Buffer.contents buf
593614594615(** Generate the site and return the HTML content *)
+31-29
lib/status.ml
···383383 Tty.Span.styled Tty.Style.(fg Tty.Color.cyan) "v:"
384384 | Some _ -> Tty.Span.empty
385385386386-let pp_table ?sources ppf statuses =
386386+let pp_status_header ppf statuses =
387387 let total = List.length statuses in
388388- let actionable = filter_actionable statuses in
389388 let synced = List.filter is_fully_synced statuses |> List.length in
390389 let dirty = List.filter has_local_changes statuses |> List.length in
391390 let local_sync_needed =
392391 List.filter needs_local_sync statuses |> List.length
393392 in
394393 let remote_needed = List.filter needs_remote_action statuses |> List.length in
395395- let action_count = List.length actionable in
396396- (* Header line with colors *)
394394+ let action_count = List.length (filter_actionable statuses) in
397395 if dirty > 0 then
398396 Fmt.pf ppf "%a %d total, %a synced, %a dirty\n"
399397 Fmt.(styled `Bold string)
···419417 Fmt.(styled `Bold string)
420418 "Packages:" total
421419 Fmt.(styled `Green string)
422422- "all synced";
423423- (* Show actionable items in a table *)
424424- if actionable <> [] then begin
425425- let columns =
426426- [
427427- Tty.Table.column "Package";
428428- Tty.Table.column ~align:`Center "Local";
429429- Tty.Table.column ~align:`Center "Remote";
430430- Tty.Table.column "Origin";
431431- ]
432432- in
433433- let rows =
434434- List.map
435435- (fun t ->
436436- [
437437- Tty.Span.text (Package.name t.package);
438438- local_status_span t;
439439- remote_status_span t;
440440- origin_span ?sources t;
441441- ])
442442- actionable
443443- in
444444- let table = Tty.Table.of_rows ~border:Tty.Border.rounded columns rows in
445445- Fmt.pf ppf "%a" Tty.Table.pp table
446446- end
420420+ "all synced"
421421+422422+let pp_actionable_table ?sources ppf actionable =
423423+ let columns =
424424+ [
425425+ Tty.Table.column "Package";
426426+ Tty.Table.column ~align:`Center "Local";
427427+ Tty.Table.column ~align:`Center "Remote";
428428+ Tty.Table.column "Origin";
429429+ ]
430430+ in
431431+ let rows =
432432+ List.map
433433+ (fun t ->
434434+ [
435435+ Tty.Span.text (Package.name t.package);
436436+ local_status_span t;
437437+ remote_status_span t;
438438+ origin_span ?sources t;
439439+ ])
440440+ actionable
441441+ in
442442+ let table = Tty.Table.of_rows ~border:Tty.Border.rounded columns rows in
443443+ Fmt.pf ppf "%a" Tty.Table.pp table
444444+445445+let pp_table ?sources ppf statuses =
446446+ pp_status_header ppf statuses;
447447+ let actionable = filter_actionable statuses in
448448+ if actionable <> [] then pp_actionable_table ?sources ppf actionable
+168-202
lib/verse.ml
···137137 |> List.filter (fun name -> is_directory ~fs Fpath.(verse_path / name))
138138 with Eio.Io _ -> []
139139140140+let resolve_root ~fs root =
141141+ if Fpath.is_abs root then root
142142+ else
143143+ let root_str = Fpath.to_string root in
144144+ let eio_path = Eio.Path.(fs / root_str) in
145145+ (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ());
146146+ match Unix.realpath root_str with
147147+ | abs_str -> (
148148+ match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
149149+ | exception _ -> root
150150+151151+let clone_workspace_repos ~proc ~fs ~config ~member =
152152+ ensure_dir ~fs (Verse_config.root config);
153153+ ensure_dir ~fs (Verse_config.src_path config);
154154+ ensure_dir ~fs (Verse_config.verse_path config);
155155+ let mono_path = Verse_config.mono_path config in
156156+ Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
157157+ let mono_url = Uri.of_string member.Verse_registry.monorepo in
158158+ match
159159+ Git_cli.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch
160160+ mono_path
161161+ with
162162+ | Error e ->
163163+ Logs.err (fun m -> m "Monorepo clone failed: %a" Git_cli.pp_error e);
164164+ Error (Git_error e)
165165+ | Ok () -> (
166166+ Logs.info (fun m -> m "Monorepo cloned");
167167+ let opam_path = Verse_config.opam_repo_path config in
168168+ Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path);
169169+ let opam_url = Uri.of_string member.Verse_registry.opamrepo in
170170+ match
171171+ Git_cli.clone ~proc ~fs ~url:opam_url
172172+ ~branch:Verse_config.default_branch opam_path
173173+ with
174174+ | Error e ->
175175+ Logs.err (fun m -> m "Opam repo clone failed: %a" Git_cli.pp_error e);
176176+ Error (Git_error e)
177177+ | Ok () ->
178178+ Logs.info (fun m -> m "Opam repo cloned");
179179+ Ok ())
180180+140181let init ~proc ~fs ~root ~handle () =
141141- (* Check if config already exists in XDG *)
142182 let config_file = Verse_config.file () in
143183 Logs.info (fun m -> m "Config file: %a" Fpath.pp config_file);
144184 if is_file ~fs config_file then begin
···146186 Error (Workspace_exists root)
147187 end
148188 else
149149- (* Resolve root to absolute path *)
150150- let root =
151151- if Fpath.is_abs root then root
152152- else
153153- (* Get absolute path via realpath *)
154154- let root_str = Fpath.to_string root in
155155- let eio_path = Eio.Path.(fs / root_str) in
156156- (* Ensure the directory exists first so realpath works *)
157157- (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ());
158158- match Unix.realpath root_str with
159159- | abs_str -> (
160160- match Fpath.of_string abs_str with Ok p -> p | Error _ -> root)
161161- | exception _ -> root
162162- in
189189+ let root = resolve_root ~fs root in
163190 Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root);
164164- (* Create config - need this temporarily to get paths *)
165191 let config = Verse_config.v ~root ~handle () in
166166- (* Clone registry first to look up user's repos *)
167192 Logs.info (fun m -> m "Cloning registry...");
168193 match Verse_registry.clone_or_pull ~proc ~fs ~config () with
169194 | Error msg ->
···171196 Error (Registry_error msg)
172197 | Ok registry -> (
173198 Logs.info (fun m -> m "Registry loaded");
174174- (* Look up user in registry - this validates the handle *)
175199 match Verse_registry.member registry ~handle with
176200 | None ->
177201 Logs.err (fun m -> m "Handle %s not found in registry" handle);
···180204 Logs.info (fun m ->
181205 m "Found member: mono=%s opam=%s" member.monorepo
182206 member.opamrepo);
183183- (* Create workspace directories *)
184207 Logs.info (fun m -> m "Creating workspace directories...");
185185- ensure_dir ~fs root;
186186- ensure_dir ~fs (Verse_config.src_path config);
187187- ensure_dir ~fs (Verse_config.verse_path config);
188188- (* Clone user's monorepo *)
189189- let mono_path = Verse_config.mono_path config in
190190- Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path);
191191- let mono_url = Uri.of_string member.monorepo in
192192- match
193193- Git_cli.clone ~proc ~fs ~url:mono_url
194194- ~branch:Verse_config.default_branch mono_path
195195- with
196196- | Error e ->
197197- Logs.err (fun m ->
198198- m "Monorepo clone failed: %a" Git_cli.pp_error e);
199199- Error (Git_error e)
208208+ match clone_workspace_repos ~proc ~fs ~config ~member with
209209+ | Error e -> Error e
200210 | Ok () -> (
201201- Logs.info (fun m -> m "Monorepo cloned");
202202- (* Clone user's opam repo *)
203203- let opam_path = Verse_config.opam_repo_path config in
204211 Logs.info (fun m ->
205205- m "Cloning opam repo to %a" Fpath.pp opam_path);
206206- let opam_url = Uri.of_string member.opamrepo in
207207- match
208208- Git_cli.clone ~proc ~fs ~url:opam_url
209209- ~branch:Verse_config.default_branch opam_path
210210- with
211211- | Error e ->
212212- Logs.err (fun m ->
213213- m "Opam repo clone failed: %a" Git_cli.pp_error e);
214214- Error (Git_error e)
215215- | Ok () -> (
216216- Logs.info (fun m -> m "Opam repo cloned");
217217- (* Save config to XDG *)
218218- Logs.info (fun m ->
219219- m "Saving config to %a" Fpath.pp config_file);
220220- match Verse_config.save ~fs config with
221221- | Error msg ->
222222- Logs.err (fun m -> m "Failed to save config: %s" msg);
223223- Error (Config_error msg)
224224- | Ok () ->
225225- Logs.info (fun m ->
226226- m "Workspace initialized successfully");
227227- Ok ()))))
212212+ m "Saving config to %a" Fpath.pp config_file);
213213+ match Verse_config.save ~fs config with
214214+ | Error msg ->
215215+ Logs.err (fun m -> m "Failed to save config: %s" msg);
216216+ Error (Config_error msg)
217217+ | Ok () ->
218218+ Logs.info (fun m -> m "Workspace initialized successfully");
219219+ Ok ())))
228220229221let status ~proc ~fs ~config () =
230222 (* Load registry *)
···301293 | Ok () -> Ok true
302294 end
303295296296+let sync_repo_result ~label h result =
297297+ match result with
298298+ | Ok true ->
299299+ Logs.info (fun m -> m " Cloned %s %s" h label);
300300+ None
301301+ | Ok false ->
302302+ Logs.info (fun m -> m " Reset %s %s" h label);
303303+ None
304304+ | Error e ->
305305+ Logs.warn (fun m -> m " Failed %s %s: %a" h label Git_cli.pp_error e);
306306+ Some (Fmt.str "%s %s: %a" h label Git_cli.pp_error e)
307307+308308+let sync_member ~proc ~fs ~verse_dir (member : Verse_registry.member) =
309309+ let h = member.handle in
310310+ let mono_path = Fpath.(verse_dir / h) in
311311+ let opam_path = Fpath.(verse_dir / (h ^ "-opam")) in
312312+ Logs.info (fun m -> m "Syncing %s monorepo" h);
313313+ let mono_branch =
314314+ Option.value ~default:Verse_config.default_branch member.monorepo_branch
315315+ in
316316+ let mono_result =
317317+ clone_or_reset_repo ~proc ~fs ~url:member.monorepo ~branch:mono_branch
318318+ mono_path
319319+ in
320320+ let mono_err = sync_repo_result ~label:"monorepo" h mono_result in
321321+ Logs.info (fun m -> m "Syncing %s opam repo" h);
322322+ let opam_branch =
323323+ Option.value ~default:Verse_config.default_branch member.opamrepo_branch
324324+ in
325325+ let opam_result =
326326+ clone_or_reset_repo ~proc ~fs ~url:member.opamrepo ~branch:opam_branch
327327+ opam_path
328328+ in
329329+ let opam_err = sync_repo_result ~label:"opam repo" h opam_result in
330330+ match (mono_err, opam_err) with
331331+ | None, None -> None
332332+ | Some e, None | None, Some e -> Some e
333333+ | Some e1, Some e2 -> Some (e1 ^ "; " ^ e2)
334334+304335let pull ~proc ~fs ~config ?handle () =
305305- (* Load registry to get all members *)
306336 match Verse_registry.clone_or_pull ~proc ~fs ~config () with
307337 | Error msg -> Error (Registry_error msg)
308338 | Ok registry ->
···320350 let verse_dir = Verse_config.verse_path config in
321351 ensure_dir ~fs verse_dir;
322352 Logs.info (fun m -> m "Syncing %d members" (List.length members));
323323- (* Sync all members in parallel *)
324353 let errors =
325354 Eio.Fiber.List.filter_map ~max_fibers:4
326326- (fun (member : Verse_registry.member) ->
327327- let h = member.handle in
328328- let mono_path = Fpath.(verse_dir / h) in
329329- let opam_path = Fpath.(verse_dir / (h ^ "-opam")) in
330330- (* Clone or fetch+reset monorepo *)
331331- Logs.info (fun m -> m "Syncing %s monorepo" h);
332332- let mono_branch =
333333- Option.value ~default:Verse_config.default_branch
334334- member.monorepo_branch
335335- in
336336- let mono_result =
337337- clone_or_reset_repo ~proc ~fs ~url:member.monorepo
338338- ~branch:mono_branch mono_path
339339- in
340340- let mono_err =
341341- match mono_result with
342342- | Ok true ->
343343- Logs.info (fun m -> m " Cloned %s monorepo" h);
344344- None
345345- | Ok false ->
346346- Logs.info (fun m -> m " Reset %s monorepo" h);
347347- None
348348- | Error e ->
349349- Logs.warn (fun m ->
350350- m " Failed %s monorepo: %a" h Git_cli.pp_error e);
351351- Some (Fmt.str "%s monorepo: %a" h Git_cli.pp_error e)
352352- in
353353- (* Clone or fetch+reset opam repo *)
354354- Logs.info (fun m -> m "Syncing %s opam repo" h);
355355- let opam_branch =
356356- Option.value ~default:Verse_config.default_branch
357357- member.opamrepo_branch
358358- in
359359- let opam_result =
360360- clone_or_reset_repo ~proc ~fs ~url:member.opamrepo
361361- ~branch:opam_branch opam_path
362362- in
363363- let opam_err =
364364- match opam_result with
365365- | Ok true ->
366366- Logs.info (fun m -> m " Cloned %s opam repo" h);
367367- None
368368- | Ok false ->
369369- Logs.info (fun m -> m " Reset %s opam repo" h);
370370- None
371371- | Error e ->
372372- Logs.warn (fun m ->
373373- m " Failed %s opam repo: %a" h Git_cli.pp_error e);
374374- Some (Fmt.str "%s opam: %a" h Git_cli.pp_error e)
375375- in
376376- match (mono_err, opam_err) with
377377- | None, None -> None
378378- | Some e, None | None, Some e -> Some e
379379- | Some e1, Some e2 -> Some (e1 ^ "; " ^ e2))
355355+ (sync_member ~proc ~fs ~verse_dir)
380356 members
381357 in
382358 if errors = [] then Ok ()
···482458 @param package Package name to fork
483459 @param fork_url Git URL of your fork
484460 @param dry_run If true, show what would be done without making changes *)
461461+let fork_package_to_repo ~fs ~member_opam_repo ~user_opam_repo ~fork_url p =
462462+ let name = Package.name p in
463463+ let version = Package.version p in
464464+ let opam_path =
465465+ Fpath.(
466466+ member_opam_repo / "packages" / name / (name ^ "." ^ version) / "opam")
467467+ in
468468+ match Opam_repo.read_opam_file ~fs opam_path with
469469+ | Error e -> Error (Opam_repo_error e)
470470+ | Ok content -> (
471471+ let new_content =
472472+ Opam_repo.replace_dev_repo_url content ~new_url:fork_url
473473+ in
474474+ match
475475+ Opam_repo.write_package ~fs ~repo_path:user_opam_repo ~name ~version
476476+ ~content:new_content
477477+ with
478478+ | Error e -> Error (Opam_repo_error e)
479479+ | Ok () -> Ok name)
480480+481481+let execute_fork ~fs ~member_opam_repo ~user_opam_repo ~fork_url ~handle
482482+ ~upstream_url ~subtree_name related_pkgs =
483483+ let results =
484484+ List.map
485485+ (fork_package_to_repo ~fs ~member_opam_repo ~user_opam_repo ~fork_url)
486486+ related_pkgs
487487+ in
488488+ match List.find_opt Result.is_error results with
489489+ | Some (Error e) -> Error e
490490+ | _ ->
491491+ let forked_names =
492492+ List.filter_map (function Ok n -> Some n | Error _ -> None) results
493493+ in
494494+ Ok
495495+ {
496496+ packages_forked = forked_names;
497497+ source_handle = handle;
498498+ fork_url;
499499+ upstream_url;
500500+ subtree_name;
501501+ }
502502+503503+let prepare_fork ~fs ~config ~handle ~package ~fork_url ~dry_run
504504+ member_opam_repo =
505505+ let pkgs, _errors = Opam_repo.scan_all ~fs member_opam_repo in
506506+ match List.find_opt (fun p -> Package.name p = package) pkgs with
507507+ | None -> Error (Package_not_found (package, handle))
508508+ | Some pkg ->
509509+ let related_pkgs = List.filter (fun p -> Package.same_repo p pkg) pkgs in
510510+ let pkg_names = List.map Package.name related_pkgs in
511511+ let upstream_url = Uri.to_string (Package.dev_repo pkg) in
512512+ let subtree_name = subtree_name_from_url fork_url in
513513+ let user_opam_repo = Verse_config.opam_repo_path config in
514514+ let conflicts =
515515+ List.filter
516516+ (fun name ->
517517+ Opam_repo.package_exists ~fs ~repo_path:user_opam_repo ~name)
518518+ pkg_names
519519+ in
520520+ if conflicts <> [] then Error (Package_already_exists conflicts)
521521+ else if dry_run then
522522+ Ok
523523+ {
524524+ packages_forked = pkg_names;
525525+ source_handle = handle;
526526+ fork_url;
527527+ upstream_url;
528528+ subtree_name;
529529+ }
530530+ else
531531+ execute_fork ~fs ~member_opam_repo ~user_opam_repo ~fork_url ~handle
532532+ ~upstream_url ~subtree_name related_pkgs
533533+485534let fork ~proc ~fs ~config ~handle ~package ~fork_url ?(dry_run = false) () =
486486- (* Ensure the member exists and their opam-repo is synced *)
487535 match Verse_registry.clone_or_pull ~proc ~fs ~config () with
488536 | Error msg -> Error (Registry_error msg)
489537 | Ok registry -> (
490538 match Verse_registry.member registry ~handle with
491539 | None -> Error (Member_not_found handle)
492492- | Some _member -> (
540540+ | Some _member ->
493541 let verse_path = Verse_config.verse_path config in
494542 let member_opam_repo = Fpath.(verse_path / (handle ^ "-opam")) in
495495- (* Check if their opam repo exists locally *)
496543 if not (is_directory ~fs member_opam_repo) then
497544 Error
498545 (Config_error
···500547 "Member's opam repo not synced. Run: monopam verse pull %s"
501548 handle))
502549 else
503503- (* Scan their opam repo to find the package *)
504504- let pkgs, _errors = Opam_repo.scan_all ~fs member_opam_repo in
505505- (* Find the requested package *)
506506- match List.find_opt (fun p -> Package.name p = package) pkgs with
507507- | None -> Error (Package_not_found (package, handle))
508508- | Some pkg ->
509509- (* Find all packages from the same dev-repo *)
510510- let related_pkgs =
511511- List.filter (fun p -> Package.same_repo p pkg) pkgs
512512- in
513513- let pkg_names = List.map Package.name related_pkgs in
514514- (* Get upstream URL and subtree name *)
515515- let upstream_url = Uri.to_string (Package.dev_repo pkg) in
516516- let subtree_name = subtree_name_from_url fork_url in
517517- (* Check for conflicts in user's opam-repo *)
518518- let user_opam_repo = Verse_config.opam_repo_path config in
519519- let conflicts =
520520- List.filter
521521- (fun name ->
522522- Opam_repo.package_exists ~fs ~repo_path:user_opam_repo
523523- ~name)
524524- pkg_names
525525- in
526526- if conflicts <> [] then Error (Package_already_exists conflicts)
527527- else if dry_run then
528528- (* Dry run - just report what would be done *)
529529- Ok
530530- {
531531- packages_forked = pkg_names;
532532- source_handle = handle;
533533- fork_url;
534534- upstream_url;
535535- subtree_name;
536536- }
537537- else begin
538538- (* Fork each package *)
539539- let results =
540540- List.map
541541- (fun p ->
542542- let name = Package.name p in
543543- let version = Package.version p in
544544- let opam_path =
545545- Fpath.(
546546- member_opam_repo / "packages" / name
547547- / (name ^ "." ^ version)
548548- / "opam")
549549- in
550550- match Opam_repo.read_opam_file ~fs opam_path with
551551- | Error e -> Error (Opam_repo_error e)
552552- | Ok content -> (
553553- (* Replace dev-repo and url with fork URL *)
554554- let new_content =
555555- Opam_repo.replace_dev_repo_url content
556556- ~new_url:fork_url
557557- in
558558- (* Write to user's opam-repo *)
559559- match
560560- Opam_repo.write_package ~fs
561561- ~repo_path:user_opam_repo ~name ~version
562562- ~content:new_content
563563- with
564564- | Error e -> Error (Opam_repo_error e)
565565- | Ok () -> Ok name))
566566- related_pkgs
567567- in
568568- (* Check for errors *)
569569- match List.find_opt Result.is_error results with
570570- | Some (Error e) -> Error e
571571- | _ ->
572572- let forked_names =
573573- List.filter_map
574574- (function Ok n -> Some n | Error _ -> None)
575575- results
576576- in
577577- Ok
578578- {
579579- packages_forked = forked_names;
580580- source_handle = handle;
581581- fork_url;
582582- upstream_url;
583583- subtree_name;
584584- }
585585- end))
550550+ prepare_fork ~fs ~config ~handle ~package ~fork_url ~dry_run
551551+ member_opam_repo)