···11+open Cmdliner
22+33+let setup_logging style_renderer level =
44+ Fmt_tty.setup_std_outputs ?style_renderer ();
55+ Logs.set_level level;
66+ Logs.set_reporter (Logs_fmt.reporter ())
77+88+let logging_term =
99+ Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
1010+1111+let config_file_arg =
1212+ let doc = "Path to config file. If not specified, searches current directory then XDG locations." in
1313+ Arg.(value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
1414+1515+let package_arg =
1616+ let doc = "Package name. If not specified, operates on all packages." in
1717+ Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
1818+1919+let load_config env config_file =
2020+ let fs = Eio.Stdenv.fs env in
2121+ let cwd = Eio.Stdenv.cwd env in
2222+ match config_file with
2323+ | Some path -> (
2424+ (* If absolute, use fs; if relative, use cwd *)
2525+ let load_path = Fpath.v path in
2626+ if Fpath.is_abs load_path then
2727+ Monopam.Config.load ~fs ~root_fs:fs load_path
2828+ else
2929+ match Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs load_path with
3030+ | Ok c -> Ok c
3131+ | Error msg -> Error msg)
3232+ | None -> (
3333+ (* Try current directory first *)
3434+ let cwd_config = Fpath.v "monopam.toml" in
3535+ match Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs cwd_config with
3636+ | Ok c -> Ok c
3737+ | Error _ ->
3838+ (* Try XDG *)
3939+ let xdg = Xdge.create fs "monopam" in
4040+ match Monopam.Config.load_xdg ~xdg () with
4141+ | Ok c -> Ok c
4242+ | Error msg -> Error msg)
4343+4444+let with_config env config_file f =
4545+ match load_config env config_file with
4646+ | Ok config -> f config
4747+ | Error msg ->
4848+ Fmt.epr "Error loading config: %s@." msg;
4949+ `Error (false, "configuration error")
5050+5151+(* Status command *)
5252+5353+let status_cmd =
5454+ let doc = "Show synchronization status of all packages" in
5555+ let man = [
5656+ `S Manpage.s_description;
5757+ `P "Displays the status of each package discovered in the opam overlay. \
5858+ For each package, shows whether the checkout is clean or has local \
5959+ changes, and whether the subtree is present in the monorepo.";
6060+ `P "Status indicators:";
6161+ `I ("clean", "Checkout matches remote, no local changes");
6262+ `I ("ahead N, behind M", "Checkout has N unpushed commits and is M commits behind remote");
6363+ `I ("present", "Subtree exists in monorepo");
6464+ `I ("missing", "Subtree not yet added to monorepo");
6565+ ] in
6666+ let info = Cmd.info "status" ~doc ~man in
6767+ let run config_file () =
6868+ Eio_main.run @@ fun env ->
6969+ with_config env config_file @@ fun config ->
7070+ let fs = Eio.Stdenv.fs env in
7171+ let proc = Eio.Stdenv.process_mgr env in
7272+ match Monopam.status ~proc ~fs ~config () with
7373+ | Ok statuses ->
7474+ Fmt.pr "%a@." Monopam.Status.pp_summary statuses;
7575+ `Ok ()
7676+ | Error e ->
7777+ Fmt.epr "Error: %a@." Monopam.pp_error e;
7878+ `Error (false, "status failed")
7979+ in
8080+ Cmd.v info Term.(ret (const run $ config_file_arg $ logging_term))
8181+8282+(* Pull command *)
8383+8484+let pull_cmd =
8585+ let doc = "Pull updates from remotes into monorepo" in
8686+ let man = [
8787+ `S Manpage.s_description;
8888+ `P "Fetches the latest changes from git remotes and updates both the \
8989+ individual checkouts and the monorepo subtrees.";
9090+ `P "For each unique repository:";
9191+ `I ("1.", "Clones the repository if not present, or fetches and fast-forward merges");
9292+ `I ("2.", "Adds or pulls the git subtree into the monorepo");
9393+ `P "If a specific package is given, only that package's repository is processed.";
9494+ `P "The operation will fail if any checkout has uncommitted changes.";
9595+ ] in
9696+ let info = Cmd.info "pull" ~doc ~man in
9797+ let run config_file package () =
9898+ Eio_main.run @@ fun env ->
9999+ with_config env config_file @@ fun config ->
100100+ let fs = Eio.Stdenv.fs env in
101101+ let proc = Eio.Stdenv.process_mgr env in
102102+ match Monopam.pull ~proc ~fs ~config ?package () with
103103+ | Ok () ->
104104+ Fmt.pr "Pull completed.@.";
105105+ `Ok ()
106106+ | Error e ->
107107+ Fmt.epr "Error: %a@." Monopam.pp_error e;
108108+ `Error (false, "pull failed")
109109+ in
110110+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
111111+112112+(* Push command *)
113113+114114+let push_cmd =
115115+ let doc = "Push changes from monorepo to checkouts" in
116116+ let man = [
117117+ `S Manpage.s_description;
118118+ `P "Extracts changes made in the monorepo and merges them into the \
119119+ individual git checkouts using git subtree split.";
120120+ `P "For each unique repository:";
121121+ `I ("1.", "Splits the subtree commits from the monorepo");
122122+ `I ("2.", "Fast-forward merges the split commits into the checkout");
123123+ `P "After running push, you can review the changes in each checkout \
124124+ and manually push them to the git remotes.";
125125+ `P "The operation will fail if any checkout has uncommitted changes.";
126126+ ] in
127127+ let info = Cmd.info "push" ~doc ~man in
128128+ let run config_file package () =
129129+ Eio_main.run @@ fun env ->
130130+ with_config env config_file @@ fun config ->
131131+ let fs = Eio.Stdenv.fs env in
132132+ let proc = Eio.Stdenv.process_mgr env in
133133+ match Monopam.push ~proc ~fs ~config ?package () with
134134+ | Ok () ->
135135+ Fmt.pr "Push completed.@.";
136136+ `Ok ()
137137+ | Error e ->
138138+ Fmt.epr "Error: %a@." Monopam.pp_error e;
139139+ `Error (false, "push failed")
140140+ in
141141+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
142142+143143+(* Add command *)
144144+145145+let add_cmd =
146146+ let doc = "Add a package to the monorepo" in
147147+ let man = [
148148+ `S Manpage.s_description;
149149+ `P "Adds a single package from the opam overlay to the monorepo.";
150150+ `P "This clones the package's git repository if not already present, \
151151+ then adds it as a git subtree in the monorepo.";
152152+ ] in
153153+ let info = Cmd.info "add" ~doc ~man in
154154+ let package_arg =
155155+ let doc = "Package name to add" in
156156+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
157157+ in
158158+ let run config_file package () =
159159+ Eio_main.run @@ fun env ->
160160+ with_config env config_file @@ fun config ->
161161+ let fs = Eio.Stdenv.fs env in
162162+ let proc = Eio.Stdenv.process_mgr env in
163163+ match Monopam.add ~proc ~fs ~config ~package () with
164164+ | Ok () ->
165165+ Fmt.pr "Added %s to monorepo.@." package;
166166+ `Ok ()
167167+ | Error e ->
168168+ Fmt.epr "Error: %a@." Monopam.pp_error e;
169169+ `Error (false, "add failed")
170170+ in
171171+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
172172+173173+(* Remove command *)
174174+175175+let remove_cmd =
176176+ let doc = "Remove a package from the monorepo" in
177177+ let man = [
178178+ `S Manpage.s_description;
179179+ `P "Removes a package's subtree directory from the monorepo.";
180180+ `P "This does not delete the git checkout - only the subtree in the monorepo.";
181181+ ] in
182182+ let info = Cmd.info "remove" ~doc ~man in
183183+ let package_arg =
184184+ let doc = "Package name to remove" in
185185+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
186186+ in
187187+ let run config_file package () =
188188+ Eio_main.run @@ fun env ->
189189+ with_config env config_file @@ fun config ->
190190+ let fs = Eio.Stdenv.fs env in
191191+ let proc = Eio.Stdenv.process_mgr env in
192192+ match Monopam.remove ~proc ~fs ~config ~package () with
193193+ | Ok () ->
194194+ Fmt.pr "Removed %s from monorepo.@." package;
195195+ `Ok ()
196196+ | Error e ->
197197+ Fmt.epr "Error: %a@." Monopam.pp_error e;
198198+ `Error (false, "remove failed")
199199+ in
200200+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
201201+202202+(* Init command *)
203203+204204+let prompt_path ~stdin ~stdout ~cwd prompt ~default =
205205+ let default_str = match default with Some d -> Fmt.str " [%a]" Fpath.pp d | None -> "" in
206206+ Eio.Flow.copy_string (Fmt.str "%s%s: " prompt default_str) stdout;
207207+ let input = String.trim (Eio.Buf_read.line stdin) in
208208+ let input = if input = "" then Option.map Fpath.to_string default else Some input in
209209+ match input with
210210+ | None -> Error "Path is required"
211211+ | Some s ->
212212+ (* Expand tilde *)
213213+ let s =
214214+ if String.length s > 0 && s.[0] = '~' then
215215+ match Sys.getenv_opt "HOME" with
216216+ | Some home ->
217217+ if String.length s = 1 then home
218218+ else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1)
219219+ else s
220220+ | None -> s
221221+ else s
222222+ in
223223+ match Fpath.of_string s with
224224+ | Error (`Msg m) -> Error m
225225+ | Ok path ->
226226+ (* Convert relative to absolute using cwd *)
227227+ let path =
228228+ if Fpath.is_abs path then path
229229+ else Fpath.(cwd // path |> normalize)
230230+ in
231231+ Ok path
232232+233233+let init_cmd =
234234+ let doc = "Initialize a new monopam configuration" in
235235+ let man = [
236236+ `S Manpage.s_description;
237237+ `P "Interactively creates a monopam.toml configuration file in the current \
238238+ directory. Prompts for the paths to the opam overlay, checkouts directory, \
239239+ and monorepo directory.";
240240+ `P "All paths must be absolute. You can use ~/ for your home directory, \
241241+ and relative paths will be converted to absolute based on the current \
242242+ working directory.";
243243+ ] in
244244+ let info = Cmd.info "init" ~doc ~man in
245245+ let output_arg =
246246+ let doc = "Output path for config file (default: monopam.toml)" in
247247+ Arg.(value & opt string "monopam.toml" & info [ "o"; "output" ] ~docv:"FILE" ~doc)
248248+ in
249249+ let run output () =
250250+ Eio_main.run @@ fun env ->
251251+ let _fs = Eio.Stdenv.fs env in
252252+ let cwd_path = Eio.Stdenv.cwd env in
253253+ let stdin = Eio.Buf_read.of_flow ~max_size:(1024 * 1024) (Eio.Stdenv.stdin env) in
254254+ let stdout = Eio.Stdenv.stdout env in
255255+ (* Get current working directory as Fpath *)
256256+ let cwd =
257257+ let (_, cwd_str) = (cwd_path :> _ Eio.Path.t) in
258258+ match Fpath.of_string cwd_str with
259259+ | Ok p -> p
260260+ | Error _ -> Fpath.v "/"
261261+ in
262262+ Eio.Flow.copy_string "Monopam Configuration Setup\n" stdout;
263263+ Eio.Flow.copy_string "===========================\n\n" stdout;
264264+ Eio.Flow.copy_string "All paths must be absolute. Use ~/ for home directory.\n" stdout;
265265+ Eio.Flow.copy_string "Relative paths will be converted to absolute.\n\n" stdout;
266266+ (* Prompt for opam_repo *)
267267+ let opam_repo = ref None in
268268+ while !opam_repo = None do
269269+ match prompt_path ~stdin ~stdout ~cwd "Path to opam overlay repository" ~default:None with
270270+ | Ok p -> opam_repo := Some p
271271+ | Error msg ->
272272+ Eio.Flow.copy_string (Fmt.str "Error: %s. Please try again.\n" msg) stdout
273273+ done;
274274+ let opam_repo = Option.get !opam_repo in
275275+ (* Prompt for checkouts *)
276276+ let default_checkouts = Fpath.(parent opam_repo / "src") in
277277+ let checkouts = ref None in
278278+ while !checkouts = None do
279279+ match prompt_path ~stdin ~stdout ~cwd "Path for git checkouts" ~default:(Some default_checkouts) with
280280+ | Ok p -> checkouts := Some p
281281+ | Error msg ->
282282+ Eio.Flow.copy_string (Fmt.str "Error: %s. Please try again.\n" msg) stdout
283283+ done;
284284+ let checkouts = Option.get !checkouts in
285285+ (* Prompt for monorepo *)
286286+ let default_monorepo = Fpath.(parent opam_repo / "mono") in
287287+ let monorepo = ref None in
288288+ while !monorepo = None do
289289+ match prompt_path ~stdin ~stdout ~cwd "Path for monorepo" ~default:(Some default_monorepo) with
290290+ | Ok p -> monorepo := Some p
291291+ | Error msg ->
292292+ Eio.Flow.copy_string (Fmt.str "Error: %s. Please try again.\n" msg) stdout
293293+ done;
294294+ let monorepo = Option.get !monorepo in
295295+ (* Prompt for default branch *)
296296+ Eio.Flow.copy_string "Default git branch [main]: " stdout;
297297+ let branch_input = String.trim (Eio.Buf_read.line stdin) in
298298+ let default_branch = if branch_input = "" then "main" else branch_input in
299299+ (* Create config *)
300300+ let config = Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch () in
301301+ (* Save config *)
302302+ let output_path = Fpath.v output in
303303+ match Monopam.Config.save ~fs:(cwd_path :> _ Eio.Path.t) config output_path with
304304+ | Ok () ->
305305+ Eio.Flow.copy_string (Fmt.str "\nConfiguration saved to %s\n" output) stdout;
306306+ Eio.Flow.copy_string "\nYou can now run 'monopam pull' to initialize the monorepo.\n" stdout;
307307+ `Ok ()
308308+ | Error msg ->
309309+ Fmt.epr "Error saving config: %s@." msg;
310310+ `Error (false, "init failed")
311311+ in
312312+ Cmd.v info Term.(ret (const run $ output_arg $ logging_term))
313313+314314+(* Main command group *)
315315+316316+let main_cmd =
317317+ let doc = "Manage opam overlay with git subtree monorepo" in
318318+ let man =
319319+ [
320320+ `S Manpage.s_description;
321321+ `P
322322+ "Monopam synchronizes packages between an opam overlay repository, \
323323+ individual git checkouts, and a monorepo using git subtrees.";
324324+ `S "DIRECTORY STRUCTURE";
325325+ `P "Monopam manages three directory trees:";
326326+ `I ("opam-repo/", "The opam overlay repository containing package metadata. \
327327+ Each package's opam file specifies a dev-repo URL pointing to its git source.");
328328+ `I ("src/", "Individual git checkouts of each unique repository. Multiple \
329329+ packages may share a checkout if they come from the same dev-repo. \
330330+ Directory names are the repository basename (e.g., ocaml-yaml from \
331331+ https://github.com/foo/ocaml-yaml.git).");
332332+ `I ("mono/", "The monorepo combining all packages as git subtrees. Each \
333333+ subtree directory is named after the repository basename. This is \
334334+ where you make changes that span multiple packages.");
335335+ `S "WORKFLOW";
336336+ `P "The typical workflow is:";
337337+ `I ("1. monopam pull", "Fetch latest from all remotes, update checkouts, \
338338+ merge into monorepo subtrees");
339339+ `I ("2. Edit code", "Make changes in the mono/ directory");
340340+ `I ("3. git commit", "Commit your changes in mono/");
341341+ `I ("4. monopam push", "Extract changes back to individual checkouts");
342342+ `I ("5. Review and push", "Review changes in src/*/, then git push each one");
343343+ `S "CONFIGURATION";
344344+ `P "Run $(b,monopam init) to interactively create a configuration file. \
345345+ Configuration is read from monopam.toml in the current directory \
346346+ or XDG config locations.";
347347+ `P "All paths in the configuration must be absolute. Example:";
348348+ `Pre "opam_repo = \"/home/user/opam-overlay\"\n\
349349+ checkouts = \"/home/user/src\"\n\
350350+ monorepo = \"/home/user/mono\"\n\
351351+ default_branch = \"main\"";
352352+ `S Manpage.s_commands;
353353+ `P "Use $(b,monopam COMMAND --help) for help on a specific command.";
354354+ ]
355355+ in
356356+ let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in
357357+ Cmd.group info
358358+ [ init_cmd; status_cmd; pull_cmd; push_cmd; add_cmd; remove_cmd ]
359359+360360+let () = exit (Cmd.eval main_cmd)
···11+module Package_config = struct
22+ type t = { branch : string option }
33+44+ let branch t = t.branch
55+66+ let codec : t Tomlt.t =
77+ Tomlt.(
88+ Table.(
99+ obj (fun branch -> { branch })
1010+ |> opt_mem "branch" string ~enc:(fun c -> c.branch)
1111+ |> finish))
1212+end
1313+1414+type t = {
1515+ opam_repo : Fpath.t;
1616+ checkouts : Fpath.t;
1717+ monorepo : Fpath.t;
1818+ default_branch : string;
1919+ packages : (string * Package_config.t) list;
2020+}
2121+2222+module Paths = struct
2323+ let opam_repo t = t.opam_repo
2424+ let checkouts t = t.checkouts
2525+ let monorepo t = t.monorepo
2626+end
2727+2828+let default_branch t = t.default_branch
2929+let package_config t name = List.assoc_opt name t.packages
3030+3131+let create ~opam_repo ~checkouts ~monorepo ?(default_branch = "main") () =
3232+ { opam_repo; checkouts; monorepo; default_branch; packages = [] }
3333+3434+let with_package_override t ~name ~branch:b =
3535+ let pkg_config = Package_config.{ branch = Some b } in
3636+ let packages = (name, pkg_config) :: List.remove_assoc name t.packages in
3737+ { t with packages }
3838+3939+let expand_tilde s =
4040+ if String.length s > 0 && s.[0] = '~' then
4141+ match Sys.getenv_opt "HOME" with
4242+ | Some home ->
4343+ if String.length s = 1 then home
4444+ else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1)
4545+ else s
4646+ | None -> s
4747+ else s
4848+4949+let fpath_codec : Fpath.t Tomlt.t =
5050+ Tomlt.map
5151+ ~dec:(fun s ->
5252+ let s = expand_tilde s in
5353+ match Fpath.of_string s with
5454+ | Ok p -> p
5555+ | Error (`Msg m) -> failwith m)
5656+ ~enc:Fpath.to_string Tomlt.string
5757+5858+let codec : t Tomlt.t =
5959+ Tomlt.(
6060+ Table.(
6161+ obj
6262+ (fun opam_repo checkouts monorepo default_branch packages ->
6363+ {
6464+ opam_repo;
6565+ checkouts;
6666+ monorepo;
6767+ default_branch = Option.value ~default:"main" default_branch;
6868+ packages;
6969+ })
7070+ |> mem "opam_repo" fpath_codec ~enc:(fun c -> c.opam_repo)
7171+ |> mem "checkouts" fpath_codec ~enc:(fun c -> c.checkouts)
7272+ |> mem "monorepo" fpath_codec ~enc:(fun c -> c.monorepo)
7373+ |> opt_mem "default_branch" string
7474+ ~enc:(fun c ->
7575+ if c.default_branch = "main" then None else Some c.default_branch)
7676+ |> keep_unknown
7777+ ~enc:(fun c -> c.packages)
7878+ (Mems.assoc Package_config.codec)
7979+ |> finish))
8080+8181+type validation_error =
8282+ | Path_not_found of string * Fpath.t
8383+ | Not_a_directory of string * Fpath.t
8484+ | Not_an_opam_repo of Fpath.t
8585+ | Invalid_path of string * string
8686+ | Relative_path of string * Fpath.t
8787+8888+let pp_validation_error ppf = function
8989+ | Path_not_found (field, path) ->
9090+ Fmt.pf ppf "%s path does not exist: %a" field Fpath.pp path
9191+ | Not_a_directory (field, path) ->
9292+ Fmt.pf ppf "%s path is not a directory: %a" field Fpath.pp path
9393+ | Not_an_opam_repo path ->
9494+ Fmt.pf ppf
9595+ "opam_repo is not a valid opam repository (missing packages/ directory): %a"
9696+ Fpath.pp path
9797+ | Invalid_path (field, msg) ->
9898+ Fmt.pf ppf "%s has invalid path: %s" field msg
9999+ | Relative_path (field, path) ->
100100+ Fmt.pf ppf "%s must be an absolute path, got: %a\n\
101101+ Hint: Use an absolute path starting with / or ~/" field Fpath.pp path
102102+103103+let validate ~fs t =
104104+ (* Get the root filesystem for checking absolute paths *)
105105+ let root_fs =
106106+ let (dir, _) = (fs : _ Eio.Path.t) in
107107+ (dir, "")
108108+ in
109109+ let check_absolute field path =
110110+ if Fpath.is_abs path then Ok ()
111111+ else Error (Relative_path (field, path))
112112+ in
113113+ let check_dir field path =
114114+ let eio_path = Eio.Path.(root_fs / Fpath.to_string path) in
115115+ match Eio.Path.kind ~follow:true eio_path with
116116+ | `Directory -> Ok ()
117117+ | `Regular_file | `Symbolic_link | `Block_device | `Character_special
118118+ | `Fifo | `Socket | `Unknown | `Not_found ->
119119+ Error (Not_a_directory (field, path))
120120+ | exception Eio.Io (Eio.Fs.E (Not_found _), _) ->
121121+ Error (Path_not_found (field, path))
122122+ | exception _ -> Error (Path_not_found (field, path))
123123+ in
124124+ let check_opam_repo path =
125125+ let packages_dir = Fpath.(path / "packages") in
126126+ let eio_path = Eio.Path.(root_fs / Fpath.to_string packages_dir) in
127127+ match Eio.Path.kind ~follow:true eio_path with
128128+ | `Directory -> Ok ()
129129+ | _ -> Error (Not_an_opam_repo path)
130130+ | exception _ -> Error (Not_an_opam_repo path)
131131+ in
132132+ let ( let* ) = Result.bind in
133133+ (* Check all paths are absolute first *)
134134+ let* () = check_absolute "opam_repo" t.opam_repo in
135135+ let* () = check_absolute "checkouts" t.checkouts in
136136+ let* () = check_absolute "monorepo" t.monorepo in
137137+ (* Then check opam_repo exists and is valid *)
138138+ let* () = check_dir "opam_repo" t.opam_repo in
139139+ let* () = check_opam_repo t.opam_repo in
140140+ Ok t
141141+142142+let load ~fs ~root_fs path =
143143+ try
144144+ let config = Tomlt_eio.decode_path_exn codec ~fs (Fpath.to_string path) in
145145+ validate ~fs:root_fs config
146146+ |> Result.map_error (fun e -> Fmt.str "%a" pp_validation_error e)
147147+ with
148148+ | Eio.Io _ as e -> Error (Printexc.to_string e)
149149+ | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
150150+151151+let load_xdg ~xdg () =
152152+ let config_dir = Xdge.config_dir xdg in
153153+ let config_path = Eio.Path.(config_dir / "config.toml") in
154154+ try
155155+ let config =
156156+ Tomlt_eio.decode_path_exn codec ~fs:config_dir (snd config_path)
157157+ in
158158+ let (dir, _) = config_dir in
159159+ validate ~fs:(dir, "") config
160160+ |> Result.map_error (fun e -> Fmt.str "%a" pp_validation_error e)
161161+ with
162162+ | Eio.Io _ as e -> Error (Printexc.to_string e)
163163+ | Failure msg -> Error (Fmt.str "Invalid config: %s" msg)
164164+165165+let save ~fs t path =
166166+ try
167167+ Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path);
168168+ Ok ()
169169+ with Eio.Io _ as e -> Error (Printexc.to_string e)
170170+171171+let pp ppf t =
172172+ Fmt.pf ppf
173173+ "@[<v>@[<hov 2>paths:@ opam_repo=%a@ checkouts=%a@ monorepo=%a@]@,\
174174+ default_branch=%s@,\
175175+ packages=%d@]"
176176+ Fpath.pp t.opam_repo Fpath.pp t.checkouts Fpath.pp t.monorepo
177177+ t.default_branch (List.length t.packages)
+119
lib/config.mli
···11+(** Configuration management for monopam.
22+33+ Configuration is stored in TOML format and loaded from XDG standard
44+ locations or a user-specified path. The config file specifies paths
55+ to the opam overlay, individual checkouts, and the monorepo, along
66+ with optional per-package overrides. *)
77+88+(** {1 Types} *)
99+1010+(** Per-package configuration overrides. *)
1111+module Package_config : sig
1212+ (** Package-specific settings. *)
1313+ type t
1414+1515+ (** [branch t] returns the branch override for this package, if set. *)
1616+ val branch : t -> string option
1717+end
1818+1919+(** The main configuration. *)
2020+type t
2121+2222+(** {1 Paths Configuration} *)
2323+2424+(** Path-related accessors. *)
2525+module Paths : sig
2626+ (** [opam_repo t] returns the path to the opam overlay repository. *)
2727+ val opam_repo : t -> Fpath.t
2828+2929+ (** [checkouts t] returns the parent directory where individual
3030+ package checkouts are stored. *)
3131+ val checkouts : t -> Fpath.t
3232+3333+ (** [monorepo t] returns the path to the monorepo directory. *)
3434+ val monorepo : t -> Fpath.t
3535+end
3636+3737+(** {1 Options} *)
3838+3939+(** [default_branch t] returns the default git branch to track.
4040+4141+ Defaults to "main" if not specified. *)
4242+val default_branch : t -> string
4343+4444+(** [package_config t name] returns package-specific configuration
4545+ overrides for the named package, if any exist. *)
4646+val package_config : t -> string -> Package_config.t option
4747+4848+(** {1 Validation} *)
4949+5050+(** Errors that can occur when validating configuration paths. *)
5151+type validation_error =
5252+ | Path_not_found of string * Fpath.t
5353+ (** A configured path does not exist *)
5454+ | Not_a_directory of string * Fpath.t
5555+ (** A configured path is not a directory *)
5656+ | Not_an_opam_repo of Fpath.t
5757+ (** The opam_repo path is not a valid opam repository
5858+ (missing packages/ directory) *)
5959+ | Invalid_path of string * string
6060+ (** A path string could not be parsed *)
6161+ | Relative_path of string * Fpath.t
6262+ (** A configured path is relative but must be absolute *)
6363+6464+(** [pp_validation_error] formats validation errors. *)
6565+val pp_validation_error : validation_error Fmt.t
6666+6767+(** {1 Loading and Saving} *)
6868+6969+(** [load ~fs ~root_fs path] loads configuration from the specified TOML file.
7070+7171+ Validates that paths exist and are valid. Supports tilde expansion
7272+ for paths (e.g., [~/src/...]).
7373+7474+ @param fs The filesystem path for locating the config file
7575+ @param root_fs The root filesystem for validating absolute paths in config
7676+7777+ Returns [Error msg] if the file cannot be read, parsed, or
7878+ if validation fails. *)
7979+val load : fs:_ Eio.Path.t -> root_fs:_ Eio.Path.t -> Fpath.t -> (t, string) result
8080+8181+(** [load_xdg ~xdg ()] loads configuration from XDG standard locations.
8282+8383+ Searches for "config.toml" in the monopam XDG config directory.
8484+ Validates that paths exist and are valid. Supports tilde expansion.
8585+8686+ Returns [Error msg] if no config file is found, parsing fails, or
8787+ if validation fails.
8888+8989+ @param xdg The Xdge context for "monopam" application *)
9090+val load_xdg : xdg:Xdge.t -> unit -> (t, string) result
9191+9292+(** [save ~fs t path] writes the configuration to the specified path. *)
9393+val save : fs:_ Eio.Path.t -> t -> Fpath.t -> (unit, string) result
9494+9595+(** {1 Construction} *)
9696+9797+(** [create ~opam_repo ~checkouts ~monorepo ?default_branch ()] creates
9898+ a new configuration with the specified paths.
9999+100100+ @param opam_repo Path to the opam overlay repository
101101+ @param checkouts Parent directory for individual git checkouts
102102+ @param monorepo Path to the monorepo
103103+ @param default_branch Default branch to track (default: "main") *)
104104+val create :
105105+ opam_repo:Fpath.t ->
106106+ checkouts:Fpath.t ->
107107+ monorepo:Fpath.t ->
108108+ ?default_branch:string ->
109109+ unit ->
110110+ t
111111+112112+(** [with_package_override t ~name ~branch] returns a new config with
113113+ a branch override for the named package. *)
114114+val with_package_override : t -> name:string -> branch:string -> t
115115+116116+(** {1 Pretty Printing} *)
117117+118118+(** [pp] is a formatter for configuration. *)
119119+val pp : t Fmt.t
···11+type cmd_result = { exit_code : int; stdout : string; stderr : string }
22+33+type error =
44+ | Command_failed of string * cmd_result
55+ | Not_a_repo of Fpath.t
66+ | Dirty_worktree of Fpath.t
77+ | Remote_not_found of string
88+ | Branch_not_found of string
99+ | Subtree_prefix_exists of string
1010+ | Subtree_prefix_missing of string
1111+ | Io_error of string
1212+1313+let pp_error ppf = function
1414+ | Command_failed (cmd, r) ->
1515+ Fmt.pf ppf "Command failed: %s (exit %d)@.stdout: %s@.stderr: %s" cmd
1616+ r.exit_code r.stdout r.stderr
1717+ | Not_a_repo path -> Fmt.pf ppf "Not a git repository: %a" Fpath.pp path
1818+ | Dirty_worktree path ->
1919+ Fmt.pf ppf "Repository has uncommitted changes: %a" Fpath.pp path
2020+ | Remote_not_found name -> Fmt.pf ppf "Remote not found: %s" name
2121+ | Branch_not_found name -> Fmt.pf ppf "Branch not found: %s" name
2222+ | Subtree_prefix_exists prefix ->
2323+ Fmt.pf ppf "Subtree prefix already exists: %s" prefix
2424+ | Subtree_prefix_missing prefix ->
2525+ Fmt.pf ppf "Subtree prefix does not exist: %s" prefix
2626+ | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg
2727+2828+let run_git ~proc ~cwd args =
2929+ let cmd = "git" :: args in
3030+ let buf_stdout = Buffer.create 256 in
3131+ let buf_stderr = Buffer.create 256 in
3232+ Eio.Switch.run @@ fun sw ->
3333+ let child =
3434+ Eio.Process.spawn proc ~sw ~cwd
3535+ ~stdout:(Eio.Flow.buffer_sink buf_stdout)
3636+ ~stderr:(Eio.Flow.buffer_sink buf_stderr)
3737+ cmd
3838+ in
3939+ let exit_status = Eio.Process.await child in
4040+ let exit_code =
4141+ match exit_status with `Exited n -> n | `Signaled n -> 128 + n
4242+ in
4343+ {
4444+ exit_code;
4545+ stdout = Buffer.contents buf_stdout |> String.trim;
4646+ stderr = Buffer.contents buf_stderr |> String.trim;
4747+ }
4848+4949+let run_git_ok ~proc ~cwd args =
5050+ let result = run_git ~proc ~cwd args in
5151+ if result.exit_code = 0 then Ok result.stdout
5252+ else Error (Command_failed (String.concat " " ("git" :: args), result))
5353+5454+let path_to_eio ~(fs : Eio.Fs.dir_ty Eio.Path.t) path =
5555+ let (dir, _) = fs in
5656+ (dir, Fpath.to_string path)
5757+5858+let is_repo ~proc ~fs path =
5959+ let cwd = path_to_eio ~fs path in
6060+ let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in
6161+ result.exit_code = 0
6262+6363+let is_dirty ~proc ~fs path =
6464+ let cwd = path_to_eio ~fs path in
6565+ let result = run_git ~proc ~cwd [ "status"; "--porcelain" ] in
6666+ result.exit_code = 0 && result.stdout <> ""
6767+6868+let current_branch ~proc ~fs path =
6969+ let cwd = path_to_eio ~fs path in
7070+ let result = run_git ~proc ~cwd [ "symbolic-ref"; "--short"; "HEAD" ] in
7171+ if result.exit_code = 0 then Some result.stdout else None
7272+7373+let head_commit ~proc ~fs path =
7474+ let cwd = path_to_eio ~fs path in
7575+ run_git_ok ~proc ~cwd [ "rev-parse"; "HEAD" ]
7676+7777+let clone ~proc ~fs ~url ~branch target =
7878+ let parent = Fpath.parent target in
7979+ let cwd = Eio.Path.(fs / Fpath.to_string parent) in
8080+ let target_name = Fpath.basename target in
8181+ let url_str = Uri.to_string url in
8282+ run_git_ok ~proc ~cwd
8383+ [ "clone"; "--branch"; branch; url_str; target_name ]
8484+ |> Result.map ignore
8585+8686+let fetch ~proc ~fs ?(remote = "origin") path =
8787+ let cwd = path_to_eio ~fs path in
8888+ run_git_ok ~proc ~cwd [ "fetch"; remote ] |> Result.map ignore
8989+9090+let merge_ff ~proc ~fs ?(remote = "origin") ?branch path =
9191+ let cwd = path_to_eio ~fs path in
9292+ let branch =
9393+ match branch with
9494+ | Some b -> b
9595+ | None -> Option.value ~default:"main" (current_branch ~proc ~fs path)
9696+ in
9797+ let upstream = remote ^ "/" ^ branch in
9898+ run_git_ok ~proc ~cwd [ "merge"; "--ff-only"; upstream ] |> Result.map ignore
9999+100100+let pull ~proc ~fs ?(remote = "origin") ?branch path =
101101+ let cwd = path_to_eio ~fs path in
102102+ let args =
103103+ match branch with
104104+ | Some b -> [ "pull"; remote; b ]
105105+ | None -> [ "pull"; remote ]
106106+ in
107107+ run_git_ok ~proc ~cwd args |> Result.map ignore
108108+109109+let checkout ~proc ~fs ~branch path =
110110+ let cwd = path_to_eio ~fs path in
111111+ run_git_ok ~proc ~cwd [ "checkout"; branch ] |> Result.map ignore
112112+113113+type ahead_behind = { ahead : int; behind : int }
114114+115115+let ahead_behind ~proc ~fs ?(remote = "origin") ?branch path =
116116+ let cwd = path_to_eio ~fs path in
117117+ let branch =
118118+ match branch with Some b -> b | None -> Option.value ~default:"HEAD" (current_branch ~proc ~fs path)
119119+ in
120120+ let upstream = remote ^ "/" ^ branch in
121121+ match run_git_ok ~proc ~cwd [ "rev-list"; "--left-right"; "--count"; branch ^ "..." ^ upstream ] with
122122+ | Error e -> Error e
123123+ | Ok output -> (
124124+ match String.split_on_char '\t' output with
125125+ | [ ahead; behind ] ->
126126+ Ok { ahead = int_of_string ahead; behind = int_of_string behind }
127127+ | _ -> Ok { ahead = 0; behind = 0 })
128128+129129+module Subtree = struct
130130+ let exists ~fs ~repo ~prefix =
131131+ let path = Eio.Path.(fs / Fpath.to_string repo / prefix) in
132132+ match Eio.Path.kind ~follow:true path with
133133+ | `Directory -> true
134134+ | _ -> false
135135+ | exception _ -> false
136136+137137+ let add ~proc ~fs ~repo ~prefix ~url ~branch () =
138138+ if exists ~fs ~repo ~prefix then Error (Subtree_prefix_exists prefix)
139139+ else
140140+ let cwd = path_to_eio ~fs repo in
141141+ let url_str = Uri.to_string url in
142142+ run_git_ok ~proc ~cwd
143143+ [ "subtree"; "add"; "--prefix"; prefix; url_str; branch; "--squash" ]
144144+ |> Result.map ignore
145145+146146+ let pull ~proc ~fs ~repo ~prefix ~url ~branch () =
147147+ if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
148148+ else
149149+ let cwd = path_to_eio ~fs repo in
150150+ let url_str = Uri.to_string url in
151151+ run_git_ok ~proc ~cwd
152152+ [ "subtree"; "pull"; "--prefix"; prefix; url_str; branch; "--squash" ]
153153+ |> Result.map ignore
154154+155155+ let push ~proc ~fs ~repo ~prefix ~url ~branch () =
156156+ if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
157157+ else
158158+ let cwd = path_to_eio ~fs repo in
159159+ let url_str = Uri.to_string url in
160160+ run_git_ok ~proc ~cwd
161161+ [ "subtree"; "push"; "--prefix"; prefix; url_str; branch ]
162162+ |> Result.map ignore
163163+164164+ let split ~proc ~fs ~repo ~prefix () =
165165+ if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
166166+ else
167167+ let cwd = path_to_eio ~fs repo in
168168+ run_git_ok ~proc ~cwd [ "subtree"; "split"; "--prefix"; prefix ]
169169+end
170170+171171+let init ~proc ~fs path =
172172+ let cwd = path_to_eio ~fs (Fpath.parent path) in
173173+ let name = Fpath.basename path in
174174+ run_git_ok ~proc ~cwd [ "init"; name ] |> Result.map ignore
175175+176176+let commit_allow_empty ~proc ~fs ~message path =
177177+ let cwd = path_to_eio ~fs path in
178178+ run_git_ok ~proc ~cwd [ "commit"; "--allow-empty"; "-m"; message ]
179179+ |> Result.map ignore
+246
lib/git.mli
···11+(** Git operations for monopam.
22+33+ This module provides git operations needed for managing individual
44+ checkouts and git subtree operations in the monorepo. All operations
55+ use Eio for process spawning. *)
66+77+(** {1 Types} *)
88+99+(** Result of a git command execution. *)
1010+type cmd_result = {
1111+ exit_code : int;
1212+ stdout : string;
1313+ stderr : string;
1414+}
1515+1616+(** Errors from git operations. *)
1717+type error =
1818+ | Command_failed of string * cmd_result
1919+ (** Git command failed: (command, result) *)
2020+ | Not_a_repo of Fpath.t
2121+ (** Path is not a git repository *)
2222+ | Dirty_worktree of Fpath.t
2323+ (** Repository has uncommitted changes *)
2424+ | Remote_not_found of string
2525+ (** Named remote does not exist *)
2626+ | Branch_not_found of string
2727+ (** Named branch does not exist *)
2828+ | Subtree_prefix_exists of string
2929+ (** Subtree prefix already exists in repo *)
3030+ | Subtree_prefix_missing of string
3131+ (** Subtree prefix does not exist *)
3232+ | Io_error of string
3333+ (** Filesystem or process error *)
3434+3535+(** [pp_error] is a formatter for errors. *)
3636+val pp_error : error Fmt.t
3737+3838+(** {1 Repository Queries} *)
3939+4040+(** [is_repo ~proc ~fs path] returns true if path is a git repository. *)
4141+val is_repo :
4242+ proc:_ Eio.Process.mgr ->
4343+ fs:Eio.Fs.dir_ty Eio.Path.t ->
4444+ Fpath.t ->
4545+ bool
4646+4747+(** [is_dirty ~proc ~fs path] returns true if the repository has
4848+ uncommitted changes (staged or unstaged). *)
4949+val is_dirty :
5050+ proc:_ Eio.Process.mgr ->
5151+ fs:Eio.Fs.dir_ty Eio.Path.t ->
5252+ Fpath.t ->
5353+ bool
5454+5555+(** [current_branch ~proc ~fs path] returns the current branch name,
5656+ or [None] if in detached HEAD state. *)
5757+val current_branch :
5858+ proc:_ Eio.Process.mgr ->
5959+ fs:Eio.Fs.dir_ty Eio.Path.t ->
6060+ Fpath.t ->
6161+ string option
6262+6363+(** [head_commit ~proc ~fs path] returns the current HEAD commit hash. *)
6464+val head_commit :
6565+ proc:_ Eio.Process.mgr ->
6666+ fs:Eio.Fs.dir_ty Eio.Path.t ->
6767+ Fpath.t ->
6868+ (string, error) result
6969+7070+(** {1 Basic Operations} *)
7171+7272+(** [clone ~proc ~fs ~url ~branch target] clones a repository.
7373+7474+ @param proc Eio process manager
7575+ @param fs Eio filesystem
7676+ @param url Git remote URL
7777+ @param branch Branch to checkout
7878+ @param target Destination directory *)
7979+val clone :
8080+ proc:_ Eio.Process.mgr ->
8181+ fs:Eio.Fs.dir_ty Eio.Path.t ->
8282+ url:Uri.t ->
8383+ branch:string ->
8484+ Fpath.t ->
8585+ (unit, error) result
8686+8787+(** [fetch ~proc ~fs ?remote path] fetches from the remote.
8888+8989+ @param remote Remote name (default: "origin") *)
9090+val fetch :
9191+ proc:_ Eio.Process.mgr ->
9292+ fs:Eio.Fs.dir_ty Eio.Path.t ->
9393+ ?remote:string ->
9494+ Fpath.t ->
9595+ (unit, error) result
9696+9797+(** [merge_ff ~proc ~fs ?remote ?branch path] performs a fast-forward only
9898+ merge from the remote tracking branch.
9999+100100+ @param remote Remote name (default: "origin")
101101+ @param branch Branch to merge from (default: current branch) *)
102102+val merge_ff :
103103+ proc:_ Eio.Process.mgr ->
104104+ fs:Eio.Fs.dir_ty Eio.Path.t ->
105105+ ?remote:string ->
106106+ ?branch:string ->
107107+ Fpath.t ->
108108+ (unit, error) result
109109+110110+(** [pull ~proc ~fs ?remote ?branch path] pulls from the remote.
111111+112112+ @param remote Remote name (default: "origin")
113113+ @param branch Branch to pull (default: current branch) *)
114114+val pull :
115115+ proc:_ Eio.Process.mgr ->
116116+ fs:Eio.Fs.dir_ty Eio.Path.t ->
117117+ ?remote:string ->
118118+ ?branch:string ->
119119+ Fpath.t ->
120120+ (unit, error) result
121121+122122+(** [checkout ~proc ~fs ~branch path] checks out the specified branch. *)
123123+val checkout :
124124+ proc:_ Eio.Process.mgr ->
125125+ fs:Eio.Fs.dir_ty Eio.Path.t ->
126126+ branch:string ->
127127+ Fpath.t ->
128128+ (unit, error) result
129129+130130+(** {1 Comparison} *)
131131+132132+(** Describes how a local branch relates to its upstream. *)
133133+type ahead_behind = {
134134+ ahead : int; (** Commits ahead of upstream *)
135135+ behind : int; (** Commits behind upstream *)
136136+}
137137+138138+(** [ahead_behind ~proc ~fs ?remote ?branch path] computes how many
139139+ commits the local branch is ahead/behind the remote.
140140+141141+ @param remote Remote name (default: "origin")
142142+ @param branch Branch to compare (default: current branch) *)
143143+val ahead_behind :
144144+ proc:_ Eio.Process.mgr ->
145145+ fs:Eio.Fs.dir_ty Eio.Path.t ->
146146+ ?remote:string ->
147147+ ?branch:string ->
148148+ Fpath.t ->
149149+ (ahead_behind, error) result
150150+151151+(** {1 Subtree Operations} *)
152152+153153+(** Operations for git subtree management in the monorepo. *)
154154+module Subtree : sig
155155+ (** [add ~proc ~fs ~repo ~prefix ~url ~branch ()] adds a new subtree
156156+ to the repository.
157157+158158+ @param repo Path to the monorepo
159159+ @param prefix Subdirectory for the subtree
160160+ @param url Git remote URL for the subtree source
161161+ @param branch Branch to add *)
162162+ val add :
163163+ proc:_ Eio.Process.mgr ->
164164+ fs:Eio.Fs.dir_ty Eio.Path.t ->
165165+ repo:Fpath.t ->
166166+ prefix:string ->
167167+ url:Uri.t ->
168168+ branch:string ->
169169+ unit ->
170170+ (unit, error) result
171171+172172+ (** [pull ~proc ~fs ~repo ~prefix ~url ~branch ()] pulls updates from
173173+ the remote into the subtree.
174174+175175+ @param repo Path to the monorepo
176176+ @param prefix Subdirectory of the subtree
177177+ @param url Git remote URL
178178+ @param branch Branch to pull *)
179179+ val pull :
180180+ proc:_ Eio.Process.mgr ->
181181+ fs:Eio.Fs.dir_ty Eio.Path.t ->
182182+ repo:Fpath.t ->
183183+ prefix:string ->
184184+ url:Uri.t ->
185185+ branch:string ->
186186+ unit ->
187187+ (unit, error) result
188188+189189+ (** [push ~proc ~fs ~repo ~prefix ~url ~branch ()] pushes subtree
190190+ changes to the remote.
191191+192192+ This extracts commits that affected the subtree and pushes them
193193+ to the specified remote/branch.
194194+195195+ @param repo Path to the monorepo
196196+ @param prefix Subdirectory of the subtree
197197+ @param url Git remote URL
198198+ @param branch Branch to push to *)
199199+ val push :
200200+ proc:_ Eio.Process.mgr ->
201201+ fs:Eio.Fs.dir_ty Eio.Path.t ->
202202+ repo:Fpath.t ->
203203+ prefix:string ->
204204+ url:Uri.t ->
205205+ branch:string ->
206206+ unit ->
207207+ (unit, error) result
208208+209209+ (** [split ~proc ~fs ~repo ~prefix ()] extracts commits for a subtree
210210+ into a standalone branch.
211211+212212+ Returns the commit hash of the split branch head. *)
213213+ val split :
214214+ proc:_ Eio.Process.mgr ->
215215+ fs:Eio.Fs.dir_ty Eio.Path.t ->
216216+ repo:Fpath.t ->
217217+ prefix:string ->
218218+ unit ->
219219+ (string, error) result
220220+221221+ (** [exists ~fs ~repo ~prefix] returns true if the subtree prefix
222222+ directory exists in the repository. *)
223223+ val exists :
224224+ fs:Eio.Fs.dir_ty Eio.Path.t ->
225225+ repo:Fpath.t ->
226226+ prefix:string ->
227227+ bool
228228+end
229229+230230+(** {1 Initialization} *)
231231+232232+(** [init ~proc ~fs path] initializes a new git repository. *)
233233+val init :
234234+ proc:_ Eio.Process.mgr ->
235235+ fs:Eio.Fs.dir_ty Eio.Path.t ->
236236+ Fpath.t ->
237237+ (unit, error) result
238238+239239+(** [commit_allow_empty ~proc ~fs ~message path] creates a commit,
240240+ even if there are no changes. Useful for initializing a repository. *)
241241+val commit_allow_empty :
242242+ proc:_ Eio.Process.mgr ->
243243+ fs:Eio.Fs.dir_ty Eio.Path.t ->
244244+ message:string ->
245245+ Fpath.t ->
246246+ (unit, error) result
+543
lib/monopam.ml
···11+module Config = Config
22+module Package = Package
33+module Opam_repo = Opam_repo
44+module Git = Git
55+module Status = Status
66+77+let src = Logs.Src.create "monopam" ~doc:"Monopam operations"
88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type error =
1111+ | Config_error of string
1212+ | Repo_error of Opam_repo.error
1313+ | Git_error of Git.error
1414+ | Dirty_state of Package.t list
1515+ | Package_not_found of string
1616+1717+let pp_error ppf = function
1818+ | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
1919+ | Repo_error e -> Fmt.pf ppf "Repository error: %a" Opam_repo.pp_error e
2020+ | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e
2121+ | Dirty_state pkgs ->
2222+ Fmt.pf ppf "Dirty packages: %a"
2323+ Fmt.(list ~sep:comma (using Package.name string))
2424+ pkgs
2525+ | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name
2626+2727+let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t =
2828+ let (dir, _) = fs in
2929+ (dir, "")
3030+3131+let discover_packages ~fs ~config () =
3232+ let repo_path = Config.Paths.opam_repo config in
3333+ Log.debug (fun m -> m "Scanning opam repo at %a" Fpath.pp repo_path);
3434+ match Opam_repo.scan ~fs repo_path with
3535+ | Ok pkgs ->
3636+ Log.info (fun m -> m "Found %d packages in opam repo" (List.length pkgs));
3737+ Ok pkgs
3838+ | Error e -> Error (Repo_error e)
3939+4040+let find_package ~fs ~config name =
4141+ match discover_packages ~fs ~config () with
4242+ | Error e -> Error e
4343+ | Ok pkgs -> (
4444+ match List.find_opt (fun p -> Package.name p = name) pkgs with
4545+ | Some p -> Ok p
4646+ | None -> Error (Package_not_found name))
4747+4848+let rec mkdirs path =
4949+ match Eio.Path.kind ~follow:true path with
5050+ | `Directory -> ()
5151+ | _ ->
5252+ Log.debug (fun m -> m "Creating directory %a" Eio.Path.pp path);
5353+ Eio.Path.mkdir ~perm:0o755 path
5454+ | exception Eio.Io _ ->
5555+ (* Parent might not exist, try to create it first *)
5656+ let parent = Eio.Path.split path in
5757+ (match parent with
5858+ | Some (parent_path, _) -> mkdirs parent_path
5959+ | None -> ());
6060+ Log.debug (fun m -> m "Creating directory %a" Eio.Path.pp path);
6161+ Eio.Path.mkdir ~perm:0o755 path
6262+6363+let ensure_checkouts_dir ~fs ~config =
6464+ let checkouts = Config.Paths.checkouts config in
6565+ let checkouts_eio = Eio.Path.(fs / Fpath.to_string checkouts) in
6666+ Log.debug (fun m -> m "Ensuring checkouts directory exists: %a" Fpath.pp checkouts);
6767+ mkdirs checkouts_eio
6868+6969+let status ~proc ~fs ~config () =
7070+ let fs = fs_typed fs in
7171+ (* Ensure checkouts directory exists before computing status *)
7272+ ensure_checkouts_dir ~fs ~config;
7373+ match discover_packages ~fs:(fs :> _ Eio.Path.t) ~config () with
7474+ | Error e -> Error e
7575+ | Ok pkgs -> Ok (Status.compute_all ~proc ~fs ~config pkgs)
7676+7777+let get_branch ~config pkg =
7878+ match Package.branch pkg with
7979+ | Some b -> b
8080+ | None -> (
8181+ match Config.package_config config (Package.name pkg) with
8282+ | Some pc -> Option.value ~default:(Config.default_branch config) (Config.Package_config.branch pc)
8383+ | None -> Config.default_branch config)
8484+8585+let ensure_checkout ~proc ~fs ~config pkg =
8686+ let checkouts_root = Config.Paths.checkouts config in
8787+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
8888+ let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
8989+ let branch = get_branch ~config pkg in
9090+ let do_clone () =
9191+ Log.info (fun m -> m "Cloning %s from %a (branch: %s)"
9292+ (Package.repo_name pkg) Uri.pp (Package.dev_repo pkg) branch);
9393+ Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir
9494+ in
9595+ let is_directory =
9696+ match Eio.Path.kind ~follow:true checkout_eio with
9797+ | `Directory -> true
9898+ | _ -> false
9999+ | exception Eio.Io _ -> false
100100+ in
101101+ if not is_directory then do_clone ()
102102+ else if not (Git.is_repo ~proc ~fs checkout_dir) then do_clone ()
103103+ else begin
104104+ Log.info (fun m -> m "Fetching %s" (Package.repo_name pkg));
105105+ match Git.fetch ~proc ~fs checkout_dir with
106106+ | Error e -> Error e
107107+ | Ok () ->
108108+ Log.info (fun m -> m "Updating %s to %s" (Package.repo_name pkg) branch);
109109+ Git.merge_ff ~proc ~fs ~branch checkout_dir
110110+ end
111111+112112+let claude_md_content = {|# Monorepo Development Guide
113113+114114+This is a monorepo managed by `monopam`. Each subdirectory is a git subtree
115115+from a separate upstream repository.
116116+117117+## Making Changes
118118+119119+1. Edit code in any subdirectory as normal
120120+2. Build and test: `opam exec -- dune build` and `opam exec -- dune test`
121121+3. Commit your changes to this monorepo with git
122122+123123+## Exporting Changes to Upstream
124124+125125+After committing changes here, they must be exported to the individual
126126+repositories before they can be pushed upstream:
127127+128128+```
129129+monopam push
130130+```
131131+132132+This extracts your commits into the individual checkouts in `../src/`.
133133+You then review and push each one manually:
134134+135135+```
136136+cd ../src/<repo-name>
137137+git log --oneline -5 # review the changes
138138+git push origin main # push to upstream
139139+```
140140+141141+## Pulling Updates from Upstream
142142+143143+To fetch the latest changes from all upstream repositories:
144144+145145+```
146146+monopam pull
147147+```
148148+149149+This updates both the checkouts and merges changes into this monorepo.
150150+151151+## Important Notes
152152+153153+- **Always commit before push**: `monopam push` only exports committed changes
154154+- **Check status first**: Run `monopam status` to see which repos have changes
155155+- **One repo per directory**: Each subdirectory maps to exactly one git remote
156156+- **Shared repos**: Multiple opam packages may live in the same subdirectory
157157+ if they share an upstream repository
158158+159159+## Troubleshooting
160160+161161+If `monopam push` fails with "dirty state", you have uncommitted changes.
162162+Commit or stash them first.
163163+164164+If merge conflicts occur during `monopam pull`, resolve them in this monorepo,
165165+commit, then the next pull will succeed.
166166+|}
167167+168168+let ensure_monorepo_initialized ~proc ~fs ~config =
169169+ let monorepo = Config.Paths.monorepo config in
170170+ let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
171171+ let init_and_commit () =
172172+ Log.info (fun m -> m "Initializing monorepo at %a" Fpath.pp monorepo);
173173+ match Git.init ~proc ~fs monorepo with
174174+ | Error e -> Error (Git_error e)
175175+ | Ok () ->
176176+ (* Create dune-project file so the monorepo builds *)
177177+ let dune_project = Eio.Path.(monorepo_eio / "dune-project") in
178178+ Log.debug (fun m -> m "Creating dune-project file");
179179+ Eio.Path.save ~create:(`Or_truncate 0o644) dune_project "(lang dune 3.20)\n";
180180+ (* Create CLAUDE.md for agent instructions *)
181181+ let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in
182182+ Log.debug (fun m -> m "Creating CLAUDE.md");
183183+ Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content;
184184+ (* Stage the files *)
185185+ Log.debug (fun m -> m "Staging initial files");
186186+ Eio.Switch.run (fun sw ->
187187+ let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
188188+ [ "git"; "add"; "dune-project"; "CLAUDE.md" ] in
189189+ ignore (Eio.Process.await child));
190190+ (* Commit *)
191191+ Log.debug (fun m -> m "Creating initial commit in monorepo");
192192+ match Git.commit_allow_empty ~proc ~fs ~message:"Initial commit with dune-project and CLAUDE.md" monorepo with
193193+ | Ok () -> Ok ()
194194+ | Error e -> Error (Git_error e)
195195+ in
196196+ let ensure_claude_md () =
197197+ let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in
198198+ let exists =
199199+ match Eio.Path.kind ~follow:true claude_md with
200200+ | `Regular_file -> true
201201+ | _ -> false
202202+ | exception Eio.Io _ -> false
203203+ in
204204+ if not exists then begin
205205+ Log.info (fun m -> m "Adding CLAUDE.md to monorepo");
206206+ Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content;
207207+ Eio.Switch.run (fun sw ->
208208+ let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
209209+ [ "git"; "add"; "CLAUDE.md" ] in
210210+ ignore (Eio.Process.await child));
211211+ Eio.Switch.run (fun sw ->
212212+ let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
213213+ [ "git"; "commit"; "-m"; "Add CLAUDE.md" ] in
214214+ ignore (Eio.Process.await child))
215215+ end
216216+ in
217217+ let is_directory =
218218+ match Eio.Path.kind ~follow:true monorepo_eio with
219219+ | `Directory -> true
220220+ | _ -> false
221221+ | exception Eio.Io _ -> false
222222+ in
223223+ if is_directory && Git.is_repo ~proc ~fs monorepo then begin
224224+ Log.debug (fun m -> m "Monorepo already initialized at %a" Fpath.pp monorepo);
225225+ ensure_claude_md ();
226226+ Ok ()
227227+ end else begin
228228+ if not is_directory then begin
229229+ Log.debug (fun m -> m "Creating monorepo directory %a" Fpath.pp monorepo);
230230+ mkdirs monorepo_eio
231231+ end;
232232+ init_and_commit ()
233233+ end
234234+235235+(* Normalize URL for comparison: extract scheme + host + path, strip trailing slashes *)
236236+let normalize_url_for_comparison uri =
237237+ let scheme = Option.value ~default:"" (Uri.scheme uri) in
238238+ let host = Option.value ~default:"" (Uri.host uri) in
239239+ let path = Uri.path uri in
240240+ (* Strip trailing slash from path *)
241241+ let path =
242242+ if String.length path > 1 && path.[String.length path - 1] = '/' then
243243+ String.sub path 0 (String.length path - 1)
244244+ else path
245245+ in
246246+ Printf.sprintf "%s://%s%s" scheme host path
247247+248248+(* Deduplicate packages by dev-repo, keeping first occurrence of each repo *)
249249+let unique_repos pkgs =
250250+ let seen = Hashtbl.create 16 in
251251+ List.filter (fun pkg ->
252252+ let url = normalize_url_for_comparison (Package.dev_repo pkg) in
253253+ Log.debug (fun m -> m "Checking repo URL: %s (from %s)" url (Package.name pkg));
254254+ if Hashtbl.mem seen url then begin
255255+ Log.debug (fun m -> m " -> Already seen, skipping");
256256+ false
257257+ end else begin
258258+ Hashtbl.add seen url ();
259259+ Log.debug (fun m -> m " -> New repo, keeping");
260260+ true
261261+ end) pkgs
262262+263263+(* Result of pulling a single repo *)
264264+type pull_result = {
265265+ repo_name : string;
266266+ cloned : bool; (* true if newly cloned, false if fetched *)
267267+ commits_pulled : int; (* number of commits pulled, 0 if none or cloned *)
268268+ subtree_added : bool; (* true if subtree was newly added *)
269269+}
270270+271271+let pull_subtree ~proc ~fs ~config pkg =
272272+ let fs = fs_typed fs in
273273+ let monorepo = Config.Paths.monorepo config in
274274+ let prefix = Package.subtree_prefix pkg in
275275+ let branch = get_branch ~config pkg in
276276+ let url = Package.dev_repo pkg in
277277+ if Git.Subtree.exists ~fs ~repo:monorepo ~prefix then begin
278278+ Log.info (fun m -> m "Pulling subtree %s" prefix);
279279+ match Git.Subtree.pull ~proc ~fs ~repo:monorepo ~prefix ~url ~branch () with
280280+ | Ok () -> Ok false (* not newly added *)
281281+ | Error e -> Error (Git_error e)
282282+ end else begin
283283+ Log.info (fun m -> m "Adding subtree %s" prefix);
284284+ match Git.Subtree.add ~proc ~fs ~repo:monorepo ~prefix ~url ~branch () with
285285+ | Ok () -> Ok true (* newly added *)
286286+ | Error e -> Error (Git_error e)
287287+ end
288288+289289+(* Check if checkout exists and is a repo *)
290290+let checkout_exists ~proc ~fs ~config pkg =
291291+ let checkouts_root = Config.Paths.checkouts config in
292292+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
293293+ let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
294294+ match Eio.Path.kind ~follow:true checkout_eio with
295295+ | `Directory -> Git.is_repo ~proc ~fs checkout_dir
296296+ | _ -> false
297297+ | exception Eio.Io _ -> false
298298+299299+(* Get commits behind before fetching *)
300300+let get_behind ~proc ~fs ~config pkg =
301301+ let checkouts_root = Config.Paths.checkouts config in
302302+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
303303+ let branch = get_branch ~config pkg in
304304+ match Git.ahead_behind ~proc ~fs ~branch checkout_dir with
305305+ | Ok ab -> ab.behind
306306+ | Error _ -> 0
307307+308308+let pull ~proc ~fs ~config ?package () =
309309+ let fs_t = fs_typed fs in
310310+ (* Update the opam repo first *)
311311+ let opam_repo = Config.Paths.opam_repo config in
312312+ if Git.is_repo ~proc ~fs:fs_t opam_repo then begin
313313+ Log.info (fun m -> m "Updating opam repo at %a" Fpath.pp opam_repo);
314314+ let result =
315315+ let ( let* ) = Result.bind in
316316+ let* () = Git.fetch ~proc ~fs:fs_t opam_repo in
317317+ Git.merge_ff ~proc ~fs:fs_t opam_repo
318318+ in
319319+ match result with
320320+ | Ok () -> ()
321321+ | Error e -> Log.warn (fun m -> m "Failed to update opam repo: %a" Git.pp_error e)
322322+ end;
323323+ (* Ensure directories exist before computing status *)
324324+ ensure_checkouts_dir ~fs:fs_t ~config;
325325+ match ensure_monorepo_initialized ~proc ~fs:fs_t ~config with
326326+ | Error e -> Error e
327327+ | Ok () ->
328328+ match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
329329+ | Error e -> Error e
330330+ | Ok all_pkgs ->
331331+ let pkgs =
332332+ match package with
333333+ | None -> all_pkgs
334334+ | Some name -> List.filter (fun p -> Package.name p = name) all_pkgs
335335+ in
336336+ if pkgs = [] && package <> None then
337337+ Error (Package_not_found (Option.get package))
338338+ else begin
339339+ Log.info (fun m -> m "Checking status of %d packages" (List.length pkgs));
340340+ let statuses = Status.compute_all ~proc ~fs:fs_t ~config pkgs in
341341+ let dirty =
342342+ List.filter Status.has_local_changes statuses
343343+ |> List.map (fun s -> s.Status.package)
344344+ in
345345+ if dirty <> [] then Error (Dirty_state dirty)
346346+ else begin
347347+ (* First, clone/fetch unique repositories *)
348348+ let repos = unique_repos pkgs in
349349+ Log.info (fun m -> m "Cloning/fetching %d unique repositories" (List.length repos));
350350+ let clone_repos () =
351351+ let total = List.length repos in
352352+ let rec loop i acc = function
353353+ | [] -> Ok (List.rev acc)
354354+ | pkg :: rest ->
355355+ let repo_name = Package.repo_name pkg in
356356+ Log.info (fun m -> m "[%d/%d] Fetching repo %s" i total repo_name);
357357+ let existed = checkout_exists ~proc ~fs:fs_t ~config pkg in
358358+ let behind_before =
359359+ if existed then get_behind ~proc ~fs:fs_t ~config pkg else 0
360360+ in
361361+ match ensure_checkout ~proc ~fs:fs_t ~config pkg with
362362+ | Error e -> Error (Git_error e)
363363+ | Ok () ->
364364+ let result = {
365365+ repo_name;
366366+ cloned = not existed;
367367+ commits_pulled = behind_before;
368368+ subtree_added = false; (* will be updated later *)
369369+ } in
370370+ loop (i + 1) (result :: acc) rest
371371+ in
372372+ loop 1 [] repos
373373+ in
374374+ match clone_repos () with
375375+ | Error e -> Error e
376376+ | Ok checkout_results ->
377377+ (* Then, add/pull subtrees for unique repos only *)
378378+ Log.info (fun m -> m "Processing %d unique subtrees" (List.length repos));
379379+ let total = List.length repos in
380380+ let rec loop i results_acc repos_left checkout_results_left =
381381+ match repos_left, checkout_results_left with
382382+ | [], [] -> Ok (List.rev results_acc)
383383+ | pkg :: rest_repos, cr :: rest_cr ->
384384+ Log.info (fun m -> m "[%d/%d] Subtree %s" i total (Package.subtree_prefix pkg));
385385+ (match pull_subtree ~proc ~fs ~config pkg with
386386+ | Ok subtree_added ->
387387+ let result = { cr with subtree_added } in
388388+ loop (i + 1) (result :: results_acc) rest_repos rest_cr
389389+ | Error e -> Error e)
390390+ | _ -> Ok (List.rev results_acc) (* mismatched lengths, shouldn't happen *)
391391+ in
392392+ match loop 1 [] repos checkout_results with
393393+ | Error e -> Error e
394394+ | Ok results ->
395395+ (* Print summary *)
396396+ let cloned = List.filter (fun r -> r.cloned) results in
397397+ let updated = List.filter (fun r -> not r.cloned && r.commits_pulled > 0) results in
398398+ let added = List.filter (fun r -> r.subtree_added) results in
399399+ if cloned <> [] then begin
400400+ Log.app (fun m -> m "Cloned %d new repositories:" (List.length cloned));
401401+ List.iter (fun r -> Log.app (fun m -> m " + %s" r.repo_name)) cloned
402402+ end;
403403+ if updated <> [] then begin
404404+ Log.app (fun m -> m "Updated %d repositories:" (List.length updated));
405405+ List.iter (fun r ->
406406+ Log.app (fun m -> m " ~ %s (%d new commits)" r.repo_name r.commits_pulled)
407407+ ) updated
408408+ end;
409409+ if added <> [] then begin
410410+ Log.app (fun m -> m "Added %d new subtrees:" (List.length added));
411411+ List.iter (fun r -> Log.app (fun m -> m " + %s" r.repo_name)) added
412412+ end;
413413+ let unchanged = List.length results - List.length cloned - List.length updated in
414414+ if cloned = [] && updated = [] && added = [] then
415415+ Log.app (fun m -> m "All %d repositories up to date." (List.length results))
416416+ else if unchanged > 0 then
417417+ Log.app (fun m -> m "%d repositories unchanged." unchanged);
418418+ Ok ()
419419+ end
420420+ end
421421+422422+let run_git_in ~proc ~cwd args =
423423+ Eio.Switch.run @@ fun sw ->
424424+ let buf_stdout = Buffer.create 256 in
425425+ let buf_stderr = Buffer.create 256 in
426426+ let child =
427427+ Eio.Process.spawn proc ~sw ~cwd
428428+ ~stdout:(Eio.Flow.buffer_sink buf_stdout)
429429+ ~stderr:(Eio.Flow.buffer_sink buf_stderr)
430430+ ("git" :: args)
431431+ in
432432+ match Eio.Process.await child with
433433+ | `Exited 0 -> Ok (Buffer.contents buf_stdout |> String.trim)
434434+ | _ ->
435435+ let result = Git.{
436436+ exit_code = 1;
437437+ stdout = Buffer.contents buf_stdout;
438438+ stderr = Buffer.contents buf_stderr;
439439+ } in
440440+ Error (Git.Command_failed (String.concat " " ("git" :: args), result))
441441+442442+let push_one ~proc ~fs ~config pkg =
443443+ let ( let* ) r f = Result.bind (Result.map_error (fun e -> Git_error e) r) f in
444444+ let fs = fs_typed fs in
445445+ let monorepo = Config.Paths.monorepo config in
446446+ let prefix = Package.subtree_prefix pkg in
447447+ let checkouts_root = Config.Paths.checkouts config in
448448+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
449449+ let branch = get_branch ~config pkg in
450450+ let sync_branch = "monopam-sync" in
451451+ if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then begin
452452+ Log.debug (fun m -> m "Subtree %s not in monorepo, skipping" prefix);
453453+ Ok ()
454454+ end else begin
455455+ let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
456456+ match Eio.Path.kind ~follow:true checkout_eio with
457457+ | exception Eio.Io _ ->
458458+ Log.debug (fun m -> m "Checkout %a does not exist, skipping" Fpath.pp checkout_dir);
459459+ Ok ()
460460+ | `Directory when Git.is_repo ~proc ~fs checkout_dir ->
461461+ let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
462462+ let checkout_path = Fpath.to_string checkout_dir in
463463+ (* Push subtree to a sync branch (avoids "branch is checked out" error) *)
464464+ Log.info (fun m -> m "Pushing subtree %s to checkout" prefix);
465465+ let* _ = run_git_in ~proc ~cwd:monorepo_eio
466466+ [ "subtree"; "push"; "--prefix"; prefix; checkout_path; sync_branch ] in
467467+ (* Merge sync branch into the target branch in checkout *)
468468+ Log.debug (fun m -> m "Merging %s into %s" sync_branch branch);
469469+ let* _ = run_git_in ~proc ~cwd:checkout_eio [ "merge"; "--ff-only"; sync_branch ] in
470470+ (* Delete the sync branch *)
471471+ Log.debug (fun m -> m "Cleaning up %s branch" sync_branch);
472472+ ignore (run_git_in ~proc ~cwd:checkout_eio [ "branch"; "-d"; sync_branch ]);
473473+ Ok ()
474474+ | _ ->
475475+ Log.debug (fun m -> m "Checkout %a is not a git repo, skipping" Fpath.pp checkout_dir);
476476+ Ok ()
477477+ end
478478+479479+let push ~proc ~fs ~config ?package () =
480480+ let fs_t = fs_typed fs in
481481+ (* Ensure checkouts directory exists before computing status *)
482482+ ensure_checkouts_dir ~fs:fs_t ~config;
483483+ match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
484484+ | Error e -> Error e
485485+ | Ok all_pkgs ->
486486+ let pkgs =
487487+ match package with
488488+ | None -> all_pkgs
489489+ | Some name -> List.filter (fun p -> Package.name p = name) all_pkgs
490490+ in
491491+ if pkgs = [] && package <> None then
492492+ Error (Package_not_found (Option.get package))
493493+ else begin
494494+ Log.info (fun m -> m "Checking status of %d packages" (List.length pkgs));
495495+ let statuses = Status.compute_all ~proc ~fs:fs_t ~config pkgs in
496496+ let dirty =
497497+ List.filter Status.has_local_changes statuses
498498+ |> List.map (fun s -> s.Status.package)
499499+ in
500500+ if dirty <> [] then Error (Dirty_state dirty)
501501+ else begin
502502+ let repos = unique_repos pkgs in
503503+ Log.info (fun m -> m "Pushing %d unique repos" (List.length repos));
504504+ let total = List.length repos in
505505+ let rec loop i = function
506506+ | [] -> Ok ()
507507+ | pkg :: rest ->
508508+ Log.info (fun m -> m "[%d/%d] Processing %s" i total (Package.subtree_prefix pkg));
509509+ match push_one ~proc ~fs ~config pkg with
510510+ | Ok () -> loop (i + 1) rest
511511+ | Error e -> Error e
512512+ in
513513+ loop 1 repos
514514+ end
515515+ end
516516+517517+let add ~proc ~fs ~config ~package () =
518518+ let fs_t = fs_typed fs in
519519+ ensure_checkouts_dir ~fs:fs_t ~config;
520520+ match ensure_monorepo_initialized ~proc ~fs:fs_t ~config with
521521+ | Error e -> Error e
522522+ | Ok () ->
523523+ match find_package ~fs:(fs_t :> _ Eio.Path.t) ~config package with
524524+ | Error e -> Error e
525525+ | Ok pkg ->
526526+ Log.info (fun m -> m "Adding package %s" (Package.name pkg));
527527+ match ensure_checkout ~proc ~fs:fs_t ~config pkg with
528528+ | Error e -> Error (Git_error e)
529529+ | Ok () ->
530530+ pull_subtree ~proc ~fs ~config pkg
531531+ |> Result.map (fun _ -> ())
532532+533533+let remove ~proc:_ ~fs ~config ~package () =
534534+ let fs = fs_typed fs in
535535+ let monorepo = Config.Paths.monorepo config in
536536+ let prefix = package in
537537+ if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then Ok ()
538538+ else
539539+ let subtree_path = Eio.Path.(fs / Fpath.to_string monorepo / prefix) in
540540+ try
541541+ Eio.Path.rmtree subtree_path;
542542+ Ok ()
543543+ with Eio.Io _ as e -> Error (Git_error (Git.Io_error (Printexc.to_string e)))
+171
lib/monopam.mli
···11+(** Monopam - Opam overlay and monorepo manager.
22+33+ Monopam manages synchronization between an opam overlay repository,
44+ individual git checkouts of packages, and a monorepo using git
55+ subtrees.
66+77+ {1 Overview}
88+99+ The typical workflow is:
1010+1111+ 1. {b init} - Initialize configuration and monorepo
1212+ 2. {b status} - Check synchronization state of all packages
1313+ 3. {b pull} - Fetch from remotes, update checkouts, merge to monorepo
1414+ 4. {b push} - Extract monorepo changes back to checkouts
1515+1616+ {1 Modules}
1717+1818+ - {!Config} - Configuration management
1919+ - {!Package} - Package metadata
2020+ - {!Opam_repo} - Opam repository scanning
2121+ - {!Git} - Git operations
2222+ - {!Status} - Status computation *)
2323+2424+(** Re-export modules for convenience. *)
2525+2626+module Config = Config
2727+module Package = Package
2828+module Opam_repo = Opam_repo
2929+module Git = Git
3030+module Status = Status
3131+3232+(** {1 High-Level Operations} *)
3333+3434+(** Errors from high-level operations. *)
3535+type error =
3636+ | Config_error of string
3737+ (** Configuration error *)
3838+ | Repo_error of Opam_repo.error
3939+ (** Opam repository error *)
4040+ | Git_error of Git.error
4141+ (** Git operation error *)
4242+ | Dirty_state of Package.t list
4343+ (** Operation blocked due to dirty packages *)
4444+ | Package_not_found of string
4545+ (** Named package not found in opam repo *)
4646+4747+(** [pp_error] formats errors. *)
4848+val pp_error : error Fmt.t
4949+5050+(** {2 Status} *)
5151+5252+(** [status ~proc ~fs ~config ()] computes status for all packages
5353+ discovered in the opam repo.
5454+5555+ @param proc Eio process manager
5656+ @param fs Eio filesystem
5757+ @param config Monopam configuration *)
5858+val status :
5959+ proc:_ Eio.Process.mgr ->
6060+ fs:Eio.Fs.dir_ty Eio.Path.t ->
6161+ config:Config.t ->
6262+ unit ->
6363+ (Status.t list, error) result
6464+6565+(** {2 Pull} *)
6666+6767+(** [pull ~proc ~fs ~config ?package ()] pulls updates from remotes.
6868+6969+ For each package (or the specified package):
7070+ 1. Clones or fetches the individual checkout
7171+ 2. Adds or pulls the subtree in the monorepo
7272+7373+ Aborts if any checkout or the monorepo has uncommitted changes.
7474+7575+ @param proc Eio process manager
7676+ @param fs Eio filesystem
7777+ @param config Monopam configuration
7878+ @param package Optional specific package to pull *)
7979+val pull :
8080+ proc:_ Eio.Process.mgr ->
8181+ fs:Eio.Fs.dir_ty Eio.Path.t ->
8282+ config:Config.t ->
8383+ ?package:string ->
8484+ unit ->
8585+ (unit, error) result
8686+8787+(** {2 Push} *)
8888+8989+(** [push ~proc ~fs ~config ?package ()] pushes changes from monorepo
9090+ to checkouts.
9191+9292+ For each package (or the specified package) with changes in the
9393+ monorepo:
9494+ 1. Splits the subtree commits
9595+ 2. Pushes to the individual checkout
9696+9797+ The user must manually push from checkouts to remotes.
9898+9999+ Aborts if any checkout has uncommitted changes.
100100+101101+ @param proc Eio process manager
102102+ @param fs Eio filesystem
103103+ @param config Monopam configuration
104104+ @param package Optional specific package to push *)
105105+val push :
106106+ proc:_ Eio.Process.mgr ->
107107+ fs:Eio.Fs.dir_ty Eio.Path.t ->
108108+ config:Config.t ->
109109+ ?package:string ->
110110+ unit ->
111111+ (unit, error) result
112112+113113+(** {2 Package Management} *)
114114+115115+(** [add ~proc ~fs ~config ~package ()] adds a package to the monorepo.
116116+117117+ Clones the checkout if needed and adds the subtree.
118118+119119+ @param proc Eio process manager
120120+ @param fs Eio filesystem
121121+ @param config Monopam configuration
122122+ @param package Package name to add *)
123123+val add :
124124+ proc:_ Eio.Process.mgr ->
125125+ fs:Eio.Fs.dir_ty Eio.Path.t ->
126126+ config:Config.t ->
127127+ package:string ->
128128+ unit ->
129129+ (unit, error) result
130130+131131+(** [remove ~proc ~fs ~config ~package ()] removes a package from the
132132+ monorepo.
133133+134134+ Removes the subtree directory but does not delete the checkout.
135135+136136+ @param proc Eio process manager
137137+ @param fs Eio filesystem
138138+ @param config Monopam configuration
139139+ @param package Package name to remove *)
140140+val remove :
141141+ proc:_ Eio.Process.mgr ->
142142+ fs:Eio.Fs.dir_ty Eio.Path.t ->
143143+ config:Config.t ->
144144+ package:string ->
145145+ unit ->
146146+ (unit, error) result
147147+148148+(** {1 Package Discovery} *)
149149+150150+(** [discover_packages ~fs ~config ()] scans the opam repo and returns
151151+ all packages.
152152+153153+ @param fs Eio filesystem
154154+ @param config Monopam configuration *)
155155+val discover_packages :
156156+ fs:Eio.Fs.dir_ty Eio.Path.t ->
157157+ config:Config.t ->
158158+ unit ->
159159+ (Package.t list, error) result
160160+161161+(** [find_package ~fs ~config name] finds a package by name in the
162162+ opam repo.
163163+164164+ @param fs Eio filesystem
165165+ @param config Monopam configuration
166166+ @param name Package name to find *)
167167+val find_package :
168168+ fs:Eio.Fs.dir_ty Eio.Path.t ->
169169+ config:Config.t ->
170170+ string ->
171171+ (Package.t, error) result
+129
lib/opam_repo.ml
···11+type error =
22+ | Multiple_versions of string * string list
33+ | No_dev_repo of string
44+ | Invalid_dev_repo of string * string
55+ | Not_git_remote of string * string
66+ | Parse_error of string * string
77+ | Io_error of string
88+99+let pp_error ppf = function
1010+ | Multiple_versions (name, versions) ->
1111+ Fmt.pf ppf "Package %s has multiple versions: %a" name
1212+ Fmt.(list ~sep:comma string)
1313+ versions
1414+ | No_dev_repo name -> Fmt.pf ppf "Package %s has no dev-repo field" name
1515+ | Invalid_dev_repo (name, url) ->
1616+ Fmt.pf ppf "Package %s has invalid dev-repo: %s" name url
1717+ | Not_git_remote (name, url) ->
1818+ Fmt.pf ppf "Package %s dev-repo is not a git URL: %s" name url
1919+ | Parse_error (path, msg) -> Fmt.pf ppf "Failed to parse %s: %s" path msg
2020+ | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg
2121+2222+let has_prefix prefix s =
2323+ String.length s >= String.length prefix &&
2424+ String.sub s 0 (String.length prefix) = prefix
2525+2626+let has_suffix suffix s =
2727+ String.length s >= String.length suffix &&
2828+ String.sub s (String.length s - String.length suffix) (String.length suffix) = suffix
2929+3030+let is_git_url url =
3131+ String.length url > 0 &&
3232+ (has_prefix "git+" url || has_prefix "git://" url ||
3333+ has_prefix "git@" url || has_suffix ".git" url)
3434+3535+let normalize_git_url url =
3636+ let url =
3737+ if has_prefix "git+" url then
3838+ String.sub url 4 (String.length url - 4)
3939+ else url
4040+ in
4141+ Uri.of_string url
4242+4343+module OP = OpamParserTypes.FullPos
4444+4545+let extract_string_value (v : OP.value) : string option =
4646+ match v.pelem with OP.String s -> Some s | _ -> None
4747+4848+let find_dev_repo (items : OP.opamfile_item list) : string option =
4949+ List.find_map
5050+ (fun (item : OP.opamfile_item) ->
5151+ match item.pelem with
5252+ | OP.Variable (name, value) when name.pelem = "dev-repo" ->
5353+ extract_string_value value
5454+ | _ -> None)
5555+ items
5656+5757+let parse_package_path (path : Fpath.t) : (string * string) option =
5858+ let segs = Fpath.segs path in
5959+ let rec find_after_packages = function
6060+ | [] -> None
6161+ | "packages" :: name :: name_version :: _ -> (
6262+ match String.split_on_char '.' name_version with
6363+ | [ n; v ] when n = name -> Some (name, v)
6464+ | n :: rest when n = name -> Some (name, String.concat "." rest)
6565+ | _ -> None)
6666+ | _ :: rest -> find_after_packages rest
6767+ in
6868+ find_after_packages segs
6969+7070+let load_package ~fs opam_file_path =
7171+ let path_str = Fpath.to_string opam_file_path in
7272+ match parse_package_path opam_file_path with
7373+ | None -> Error (Parse_error (path_str, "Cannot determine package name/version"))
7474+ | Some (name, version) -> (
7575+ try
7676+ let eio_path = Eio.Path.(fs / path_str) in
7777+ let content = Eio.Path.load eio_path in
7878+ let opamfile = OpamParser.FullPos.string content path_str in
7979+ match find_dev_repo opamfile.file_contents with
8080+ | None -> Error (No_dev_repo name)
8181+ | Some url ->
8282+ if not (is_git_url url) then Error (Not_git_remote (name, url))
8383+ else
8484+ let dev_repo = normalize_git_url url in
8585+ Ok (Package.create ~name ~version ~dev_repo ())
8686+ with
8787+ | Eio.Io _ as e -> Error (Io_error (Printexc.to_string e))
8888+ | exn -> Error (Parse_error (path_str, Printexc.to_string exn)))
8989+9090+let list_dirs ~fs path =
9191+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
9292+ try
9393+ Eio.Path.read_dir eio_path
9494+ |> List.filter (fun name ->
9595+ let child = Eio.Path.(eio_path / name) in
9696+ match Eio.Path.kind ~follow:false child with
9797+ | `Directory -> true
9898+ | _ -> false)
9999+ with Eio.Io _ -> []
100100+101101+let scan_all ~fs repo_path =
102102+ let packages_dir = Fpath.(repo_path / "packages") in
103103+ let package_names = list_dirs ~fs packages_dir in
104104+ let packages, errors =
105105+ List.fold_left
106106+ (fun (pkgs, errs) name ->
107107+ let pkg_dir = Fpath.(packages_dir / name) in
108108+ let versions = list_dirs ~fs pkg_dir in
109109+ match versions with
110110+ | [] -> (pkgs, errs)
111111+ | [ version_dir ] -> (
112112+ let opam_path = Fpath.(pkg_dir / version_dir / "opam") in
113113+ match load_package ~fs opam_path with
114114+ | Ok pkg -> (pkg :: pkgs, errs)
115115+ | Error e -> (pkgs, e :: errs))
116116+ | _ :: _ :: _ as vs ->
117117+ (pkgs, Multiple_versions (name, vs) :: errs))
118118+ ([], []) package_names
119119+ in
120120+ (List.rev packages, List.rev errors)
121121+122122+let scan ~fs repo_path =
123123+ match scan_all ~fs repo_path with
124124+ | pkgs, [] -> Ok pkgs
125125+ | _, err :: _ -> Error err
126126+127127+let validate_repo ~fs repo_path =
128128+ let _, errors = scan_all ~fs repo_path in
129129+ errors
+91
lib/opam_repo.mli
···11+(** Opam repository scanning and package discovery.
22+33+ This module scans an opam overlay repository to discover packages
44+ and extract their git remote URLs. It enforces the monopam constraints:
55+ - Only one version per package is allowed
66+ - Only git remotes are allowed (no archive URLs) *)
77+88+(** {1 Types} *)
99+1010+(** Errors that can occur during repository scanning. *)
1111+type error =
1212+ | Multiple_versions of string * string list
1313+ (** Package has multiple versions: (name, versions) *)
1414+ | No_dev_repo of string
1515+ (** Package has no dev-repo field *)
1616+ | Invalid_dev_repo of string * string
1717+ (** Package has invalid dev-repo: (name, url) *)
1818+ | Not_git_remote of string * string
1919+ (** Package dev-repo is not a git URL: (name, url) *)
2020+ | Parse_error of string * string
2121+ (** Failed to parse opam file: (path, message) *)
2222+ | Io_error of string
2323+ (** Filesystem error *)
2424+2525+(** [pp_error] is a formatter for errors. *)
2626+val pp_error : error Fmt.t
2727+2828+(** {1 Scanning} *)
2929+3030+(** [scan ~fs repo_path] scans the opam repository at [repo_path] and
3131+ returns all discovered packages.
3232+3333+ The repository is expected to have the standard opam layout:
3434+ [repo_path/packages/<name>/<name.version>/opam]
3535+3636+ Validates that:
3737+ - Each package has exactly one version
3838+ - Each package has a dev-repo field
3939+ - The dev-repo is a git URL (starts with "git+" or ends with ".git")
4040+4141+ @param fs Eio filesystem capability
4242+ @param repo_path Path to the opam repository root *)
4343+val scan :
4444+ fs:_ Eio.Path.t ->
4545+ Fpath.t ->
4646+ (Package.t list, error) result
4747+4848+(** [scan_all ~fs repo_path] scans the repository and returns all
4949+ packages, collecting errors instead of failing on the first one.
5050+5151+ Returns a pair of (valid packages, errors encountered). *)
5252+val scan_all :
5353+ fs:_ Eio.Path.t ->
5454+ Fpath.t ->
5555+ Package.t list * error list
5656+5757+(** {1 Individual Package Loading} *)
5858+5959+(** [load_package ~fs opam_file_path] loads a single package from its
6060+ opam file path.
6161+6262+ @param fs Eio filesystem capability
6363+ @param opam_file_path Full path to the opam file *)
6464+val load_package :
6565+ fs:_ Eio.Path.t ->
6666+ Fpath.t ->
6767+ (Package.t, error) result
6868+6969+(** {1 Validation} *)
7070+7171+(** [validate_repo ~fs repo_path] validates the entire repository
7272+ without loading packages. Returns a list of validation errors.
7373+7474+ This is useful for checking repository health before operations. *)
7575+val validate_repo :
7676+ fs:_ Eio.Path.t ->
7777+ Fpath.t ->
7878+ error list
7979+8080+(** [is_git_url url] returns true if the URL is a valid git remote.
8181+8282+ Accepts URLs starting with "git+" or "git://" or ending with ".git",
8383+ as well as SSH-style URLs like "git\@github.com:...". *)
8484+val is_git_url : string -> bool
8585+8686+(** [normalize_git_url url] normalizes a git URL by removing the "git+"
8787+ prefix if present.
8888+8989+ For example, "git+https://example.com/repo.git" becomes
9090+ "https://example.com/repo.git". *)
9191+val normalize_git_url : string -> Uri.t
+32
lib/package.ml
···11+type t = {
22+ name : string;
33+ version : string;
44+ dev_repo : Uri.t;
55+ branch : string option;
66+}
77+88+let create ~name ~version ~dev_repo ?branch () = { name; version; dev_repo; branch }
99+let name t = t.name
1010+let version t = t.version
1111+let dev_repo t = t.dev_repo
1212+let branch t = t.branch
1313+1414+let repo_name t =
1515+ (* Extract basename from dev-repo URL, stripping .git suffix *)
1616+ let path = Uri.path t.dev_repo in
1717+ let basename = Filename.basename path in
1818+ if Filename.check_suffix basename ".git" then
1919+ Filename.chop_suffix basename ".git"
2020+ else
2121+ basename
2222+2323+let checkout_dir ~checkouts_root t = Fpath.(checkouts_root / repo_name t)
2424+let subtree_prefix t = repo_name t
2525+let compare a b = String.compare a.name b.name
2626+let equal a b = String.equal a.name b.name
2727+2828+let same_repo a b = Uri.equal a.dev_repo b.dev_repo
2929+3030+let pp ppf t =
3131+ Fmt.pf ppf "@[<hov 2>{ name = %S;@ version = %S;@ dev_repo = %a;@ branch = %a }@]"
3232+ t.name t.version Uri.pp t.dev_repo Fmt.(option ~none:(any "None") string) t.branch
+79
lib/package.mli
···11+(** Package metadata and operations.
22+33+ A package represents a single opam package discovered from the opam
44+ overlay repository. It contains the package name, version, git remote
55+ URL, and optional configuration overrides. *)
66+77+(** {1 Types} *)
88+99+(** A package discovered from the opam overlay. *)
1010+type t
1111+1212+(** {1 Constructors} *)
1313+1414+(** [create ~name ~version ~dev_repo ?branch ()] creates a new package.
1515+1616+ @param name The opam package name
1717+ @param version The package version (e.g., "dev")
1818+ @param dev_repo The git remote URL from the opam file's dev-repo field
1919+ @param branch Optional branch override; defaults to repository default *)
2020+val create :
2121+ name:string ->
2222+ version:string ->
2323+ dev_repo:Uri.t ->
2424+ ?branch:string ->
2525+ unit ->
2626+ t
2727+2828+(** {1 Accessors} *)
2929+3030+(** [name t] returns the package name. *)
3131+val name : t -> string
3232+3333+(** [version t] returns the package version string. *)
3434+val version : t -> string
3535+3636+(** [dev_repo t] returns the git remote URI. *)
3737+val dev_repo : t -> Uri.t
3838+3939+(** [branch t] returns the branch to track, if explicitly set. *)
4040+val branch : t -> string option
4141+4242+(** [repo_name t] returns the repository name extracted from the dev-repo URL.
4343+4444+ This is the basename of the URL path with any ".git" suffix removed.
4545+ For example, "https://github.com/foo/bar.git" returns "bar". *)
4646+val repo_name : t -> string
4747+4848+(** {1 Derived Paths} *)
4949+5050+(** [checkout_dir ~checkouts_root t] returns the expected path for this
5151+ package's git checkout, based on the repository name.
5252+5353+ For a package with dev-repo "https://example.com/foo/bar.git" and
5454+ checkouts_root "/home/user/src", this returns "/home/user/src/bar".
5555+5656+ Multiple packages from the same repository will share the same checkout. *)
5757+val checkout_dir : checkouts_root:Fpath.t -> t -> Fpath.t
5858+5959+(** [subtree_prefix t] returns the subdirectory name used in the monorepo.
6060+6161+ This is the repository name (same as [repo_name t]), so multiple packages
6262+ from the same repository share the same subtree directory. *)
6363+val subtree_prefix : t -> string
6464+6565+(** {1 Comparison} *)
6666+6767+(** [compare a b] compares packages by name. *)
6868+val compare : t -> t -> int
6969+7070+(** [equal a b] returns true if packages have the same name. *)
7171+val equal : t -> t -> bool
7272+7373+(** [same_repo a b] returns true if packages share the same dev-repo URL. *)
7474+val same_repo : t -> t -> bool
7575+7676+(** {1 Pretty Printing} *)
7777+7878+(** [pp] is a formatter for packages. *)
7979+val pp : t Fmt.t
+98
lib/status.ml
···11+type checkout_status =
22+ | Missing
33+ | Not_a_repo
44+ | Dirty
55+ | Clean of Git.ahead_behind
66+77+type subtree_status = Not_added | Present
88+99+type t = {
1010+ package : Package.t;
1111+ checkout : checkout_status;
1212+ subtree : subtree_status;
1313+}
1414+1515+let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t =
1616+ let (dir, _) = fs in
1717+ (dir, "")
1818+1919+let compute ~proc ~fs ~config pkg =
2020+ let checkouts_root = Config.Paths.checkouts config in
2121+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
2222+ let monorepo = Config.Paths.monorepo config in
2323+ let prefix = Package.subtree_prefix pkg in
2424+ let fs_t = fs_typed fs in
2525+ let fs_dir =
2626+ let (dir, _) = fs in
2727+ (dir, Fpath.to_string checkout_dir)
2828+ in
2929+ let checkout =
3030+ match Eio.Path.kind ~follow:true fs_dir with
3131+ | exception Eio.Io _ -> Missing
3232+ | `Directory ->
3333+ if not (Git.is_repo ~proc ~fs:fs_t checkout_dir) then Not_a_repo
3434+ else if Git.is_dirty ~proc ~fs:fs_t checkout_dir then Dirty
3535+ else (
3636+ match Git.ahead_behind ~proc ~fs:fs_t checkout_dir with
3737+ | Ok ab -> Clean ab
3838+ | Error _ -> Clean { ahead = 0; behind = 0 })
3939+ | _ -> Missing
4040+ in
4141+ let subtree =
4242+ if Git.Subtree.exists ~fs:fs_t ~repo:monorepo ~prefix then Present
4343+ else Not_added
4444+ in
4545+ { package = pkg; checkout; subtree }
4646+4747+let compute_all ~proc ~fs ~config packages =
4848+ List.map (compute ~proc ~fs ~config) packages
4949+5050+let is_checkout_clean t =
5151+ match t.checkout with Clean _ -> true | _ -> false
5252+5353+let has_local_changes t =
5454+ match t.checkout with Dirty -> true | _ -> false
5555+5656+let needs_pull t =
5757+ match t.checkout with Clean ab -> ab.behind > 0 | _ -> false
5858+5959+let needs_push t =
6060+ match t.checkout with Clean ab -> ab.ahead > 0 | _ -> false
6161+6262+let is_fully_synced t =
6363+ match (t.checkout, t.subtree) with
6464+ | Clean ab, Present -> ab.ahead = 0 && ab.behind = 0
6565+ | _ -> false
6666+6767+let filter_actionable statuses =
6868+ List.filter
6969+ (fun t ->
7070+ match t.checkout with
7171+ | Missing | Not_a_repo | Dirty -> true
7272+ | Clean ab -> ab.ahead > 0 || ab.behind > 0 || t.subtree = Not_added)
7373+ statuses
7474+7575+let pp_checkout_status ppf = function
7676+ | Missing -> Fmt.string ppf "missing"
7777+ | Not_a_repo -> Fmt.string ppf "not a repo"
7878+ | Dirty -> Fmt.string ppf "dirty"
7979+ | Clean ab ->
8080+ if ab.ahead = 0 && ab.behind = 0 then Fmt.string ppf "clean"
8181+ else Fmt.pf ppf "ahead %d, behind %d" ab.ahead ab.behind
8282+8383+let pp_subtree_status ppf = function
8484+ | Not_added -> Fmt.string ppf "not added"
8585+ | Present -> Fmt.string ppf "present"
8686+8787+let pp ppf t =
8888+ Fmt.pf ppf "@[<h>%-20s checkout: %a subtree: %a@]" (Package.name t.package)
8989+ pp_checkout_status t.checkout pp_subtree_status t.subtree
9090+9191+let pp_summary ppf statuses =
9292+ let total = List.length statuses in
9393+ let clean = List.filter is_checkout_clean statuses |> List.length in
9494+ let synced = List.filter is_fully_synced statuses |> List.length in
9595+ Fmt.pf ppf "@[<v>Packages: %d total, %d clean checkouts, %d fully synced@,@,%a@]"
9696+ total clean synced
9797+ Fmt.(list ~sep:cut pp)
9898+ statuses
+101
lib/status.mli
···11+(** Status computation and display.
22+33+ This module computes the synchronization status of packages across
44+ the three locations: git remote, individual checkout, and monorepo
55+ subtree. *)
66+77+(** {1 Types} *)
88+99+(** Status of an individual checkout relative to its remote. *)
1010+type checkout_status =
1111+ | Missing
1212+ (** Checkout directory does not exist *)
1313+ | Not_a_repo
1414+ (** Directory exists but is not a git repository *)
1515+ | Dirty
1616+ (** Has uncommitted changes *)
1717+ | Clean of Git.ahead_behind
1818+ (** Clean with ahead/behind info relative to remote *)
1919+2020+(** Status of a subtree in the monorepo. *)
2121+type subtree_status =
2222+ | Not_added
2323+ (** Subtree has not been added to monorepo *)
2424+ | Present
2525+ (** Subtree exists in monorepo *)
2626+2727+(** Combined status for a package. *)
2828+type t = {
2929+ package : Package.t;
3030+ checkout : checkout_status;
3131+ subtree : subtree_status;
3232+}
3333+3434+(** {1 Status Computation} *)
3535+3636+(** [compute ~proc ~fs ~config pkg] computes the status of a single package.
3737+3838+ @param proc Eio process manager
3939+ @param fs Eio filesystem
4040+ @param config Monopam configuration
4141+ @param pkg Package to check *)
4242+val compute :
4343+ proc:_ Eio.Process.mgr ->
4444+ fs:Eio.Fs.dir_ty Eio.Path.t ->
4545+ config:Config.t ->
4646+ Package.t ->
4747+ t
4848+4949+(** [compute_all ~proc ~fs ~config packages] computes status for all
5050+ packages in parallel.
5151+5252+ @param proc Eio process manager
5353+ @param fs Eio filesystem
5454+ @param config Monopam configuration
5555+ @param packages List of packages to check *)
5656+val compute_all :
5757+ proc:_ Eio.Process.mgr ->
5858+ fs:Eio.Fs.dir_ty Eio.Path.t ->
5959+ config:Config.t ->
6060+ Package.t list ->
6161+ t list
6262+6363+(** {1 Predicates} *)
6464+6565+(** [is_checkout_clean t] returns true if the checkout is clean
6666+ (exists, is a repo, and has no uncommitted changes). *)
6767+val is_checkout_clean : t -> bool
6868+6969+(** [has_local_changes t] returns true if either the checkout or
7070+ subtree has uncommitted local changes. *)
7171+val has_local_changes : t -> bool
7272+7373+(** [needs_pull t] returns true if the checkout is behind the remote. *)
7474+val needs_pull : t -> bool
7575+7676+(** [needs_push t] returns true if the checkout is ahead of the remote. *)
7777+val needs_push : t -> bool
7878+7979+(** [is_fully_synced t] returns true if the package is fully in sync
8080+ across all locations. *)
8181+val is_fully_synced : t -> bool
8282+8383+(** {1 Filtering} *)
8484+8585+(** [filter_actionable statuses] returns only packages that need action
8686+ (dirty, ahead, behind, or missing subtree). *)
8787+val filter_actionable : t list -> t list
8888+8989+(** {1 Pretty Printing} *)
9090+9191+(** [pp_checkout_status] formats checkout status. *)
9292+val pp_checkout_status : checkout_status Fmt.t
9393+9494+(** [pp_subtree_status] formats subtree status. *)
9595+val pp_subtree_status : subtree_status Fmt.t
9696+9797+(** [pp] formats a single package status. *)
9898+val pp : t Fmt.t
9999+100100+(** [pp_summary] formats a summary of all package statuses. *)
101101+val pp_summary : t list Fmt.t