···1+open Cmdliner
2+3+let setup_logging style_renderer level =
4+ Fmt_tty.setup_std_outputs ?style_renderer ();
5+ Logs.set_level level;
6+ Logs.set_reporter (Logs_fmt.reporter ())
7+8+let logging_term =
9+ Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
10+11+let config_file_arg =
12+ let doc = "Path to config file. If not specified, searches current directory then XDG locations." in
13+ Arg.(value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
14+15+let package_arg =
16+ let doc = "Package name. If not specified, operates on all packages." in
17+ Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
18+19+let load_config env config_file =
20+ let fs = Eio.Stdenv.fs env in
21+ let cwd = Eio.Stdenv.cwd env in
22+ match config_file with
23+ | Some path -> (
24+ (* If absolute, use fs; if relative, use cwd *)
25+ let load_path = Fpath.v path in
26+ if Fpath.is_abs load_path then
27+ Monopam.Config.load ~fs ~root_fs:fs load_path
28+ else
29+ match Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs load_path with
30+ | Ok c -> Ok c
31+ | Error msg -> Error msg)
32+ | None -> (
33+ (* Try current directory first *)
34+ let cwd_config = Fpath.v "monopam.toml" in
35+ match Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs cwd_config with
36+ | Ok c -> Ok c
37+ | Error _ ->
38+ (* Try XDG *)
39+ let xdg = Xdge.create fs "monopam" in
40+ match Monopam.Config.load_xdg ~xdg () with
41+ | Ok c -> Ok c
42+ | Error msg -> Error msg)
43+44+let with_config env config_file f =
45+ match load_config env config_file with
46+ | Ok config -> f config
47+ | Error msg ->
48+ Fmt.epr "Error loading config: %s@." msg;
49+ `Error (false, "configuration error")
50+51+(* Status command *)
52+53+let status_cmd =
54+ let doc = "Show synchronization status of all packages" in
55+ let man = [
56+ `S Manpage.s_description;
57+ `P "Displays the status of each package discovered in the opam overlay. \
58+ For each package, shows whether the checkout is clean or has local \
59+ changes, and whether the subtree is present in the monorepo.";
60+ `P "Status indicators:";
61+ `I ("clean", "Checkout matches remote, no local changes");
62+ `I ("ahead N, behind M", "Checkout has N unpushed commits and is M commits behind remote");
63+ `I ("present", "Subtree exists in monorepo");
64+ `I ("missing", "Subtree not yet added to monorepo");
65+ ] in
66+ let info = Cmd.info "status" ~doc ~man in
67+ let run config_file () =
68+ Eio_main.run @@ fun env ->
69+ with_config env config_file @@ fun config ->
70+ let fs = Eio.Stdenv.fs env in
71+ let proc = Eio.Stdenv.process_mgr env in
72+ match Monopam.status ~proc ~fs ~config () with
73+ | Ok statuses ->
74+ Fmt.pr "%a@." Monopam.Status.pp_summary statuses;
75+ `Ok ()
76+ | Error e ->
77+ Fmt.epr "Error: %a@." Monopam.pp_error e;
78+ `Error (false, "status failed")
79+ in
80+ Cmd.v info Term.(ret (const run $ config_file_arg $ logging_term))
81+82+(* Pull command *)
83+84+let pull_cmd =
85+ let doc = "Pull updates from remotes into monorepo" in
86+ let man = [
87+ `S Manpage.s_description;
88+ `P "Fetches the latest changes from git remotes and updates both the \
89+ individual checkouts and the monorepo subtrees.";
90+ `P "For each unique repository:";
91+ `I ("1.", "Clones the repository if not present, or fetches and fast-forward merges");
92+ `I ("2.", "Adds or pulls the git subtree into the monorepo");
93+ `P "If a specific package is given, only that package's repository is processed.";
94+ `P "The operation will fail if any checkout has uncommitted changes.";
95+ ] in
96+ let info = Cmd.info "pull" ~doc ~man in
97+ let run config_file package () =
98+ Eio_main.run @@ fun env ->
99+ with_config env config_file @@ fun config ->
100+ let fs = Eio.Stdenv.fs env in
101+ let proc = Eio.Stdenv.process_mgr env in
102+ match Monopam.pull ~proc ~fs ~config ?package () with
103+ | Ok () ->
104+ Fmt.pr "Pull completed.@.";
105+ `Ok ()
106+ | Error e ->
107+ Fmt.epr "Error: %a@." Monopam.pp_error e;
108+ `Error (false, "pull failed")
109+ in
110+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
111+112+(* Push command *)
113+114+let push_cmd =
115+ let doc = "Push changes from monorepo to checkouts" in
116+ let man = [
117+ `S Manpage.s_description;
118+ `P "Extracts changes made in the monorepo and merges them into the \
119+ individual git checkouts using git subtree split.";
120+ `P "For each unique repository:";
121+ `I ("1.", "Splits the subtree commits from the monorepo");
122+ `I ("2.", "Fast-forward merges the split commits into the checkout");
123+ `P "After running push, you can review the changes in each checkout \
124+ and manually push them to the git remotes.";
125+ `P "The operation will fail if any checkout has uncommitted changes.";
126+ ] in
127+ let info = Cmd.info "push" ~doc ~man in
128+ let run config_file package () =
129+ Eio_main.run @@ fun env ->
130+ with_config env config_file @@ fun config ->
131+ let fs = Eio.Stdenv.fs env in
132+ let proc = Eio.Stdenv.process_mgr env in
133+ match Monopam.push ~proc ~fs ~config ?package () with
134+ | Ok () ->
135+ Fmt.pr "Push completed.@.";
136+ `Ok ()
137+ | Error e ->
138+ Fmt.epr "Error: %a@." Monopam.pp_error e;
139+ `Error (false, "push failed")
140+ in
141+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
142+143+(* Add command *)
144+145+let add_cmd =
146+ let doc = "Add a package to the monorepo" in
147+ let man = [
148+ `S Manpage.s_description;
149+ `P "Adds a single package from the opam overlay to the monorepo.";
150+ `P "This clones the package's git repository if not already present, \
151+ then adds it as a git subtree in the monorepo.";
152+ ] in
153+ let info = Cmd.info "add" ~doc ~man in
154+ let package_arg =
155+ let doc = "Package name to add" in
156+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
157+ in
158+ let run config_file package () =
159+ Eio_main.run @@ fun env ->
160+ with_config env config_file @@ fun config ->
161+ let fs = Eio.Stdenv.fs env in
162+ let proc = Eio.Stdenv.process_mgr env in
163+ match Monopam.add ~proc ~fs ~config ~package () with
164+ | Ok () ->
165+ Fmt.pr "Added %s to monorepo.@." package;
166+ `Ok ()
167+ | Error e ->
168+ Fmt.epr "Error: %a@." Monopam.pp_error e;
169+ `Error (false, "add failed")
170+ in
171+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
172+173+(* Remove command *)
174+175+let remove_cmd =
176+ let doc = "Remove a package from the monorepo" in
177+ let man = [
178+ `S Manpage.s_description;
179+ `P "Removes a package's subtree directory from the monorepo.";
180+ `P "This does not delete the git checkout - only the subtree in the monorepo.";
181+ ] in
182+ let info = Cmd.info "remove" ~doc ~man in
183+ let package_arg =
184+ let doc = "Package name to remove" in
185+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
186+ in
187+ let run config_file package () =
188+ Eio_main.run @@ fun env ->
189+ with_config env config_file @@ fun config ->
190+ let fs = Eio.Stdenv.fs env in
191+ let proc = Eio.Stdenv.process_mgr env in
192+ match Monopam.remove ~proc ~fs ~config ~package () with
193+ | Ok () ->
194+ Fmt.pr "Removed %s from monorepo.@." package;
195+ `Ok ()
196+ | Error e ->
197+ Fmt.epr "Error: %a@." Monopam.pp_error e;
198+ `Error (false, "remove failed")
199+ in
200+ Cmd.v info Term.(ret (const run $ config_file_arg $ package_arg $ logging_term))
201+202+(* Init command *)
203+204+let prompt_path ~stdin ~stdout ~cwd prompt ~default =
205+ let default_str = match default with Some d -> Fmt.str " [%a]" Fpath.pp d | None -> "" in
206+ Eio.Flow.copy_string (Fmt.str "%s%s: " prompt default_str) stdout;
207+ let input = String.trim (Eio.Buf_read.line stdin) in
208+ let input = if input = "" then Option.map Fpath.to_string default else Some input in
209+ match input with
210+ | None -> Error "Path is required"
211+ | Some s ->
212+ (* Expand tilde *)
213+ let s =
214+ if String.length s > 0 && s.[0] = '~' then
215+ match Sys.getenv_opt "HOME" with
216+ | Some home ->
217+ if String.length s = 1 then home
218+ else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1)
219+ else s
220+ | None -> s
221+ else s
222+ in
223+ match Fpath.of_string s with
224+ | Error (`Msg m) -> Error m
225+ | Ok path ->
226+ (* Convert relative to absolute using cwd *)
227+ let path =
228+ if Fpath.is_abs path then path
229+ else Fpath.(cwd // path |> normalize)
230+ in
231+ Ok path
232+233+let init_cmd =
234+ let doc = "Initialize a new monopam configuration" in
235+ let man = [
236+ `S Manpage.s_description;
237+ `P "Interactively creates a monopam.toml configuration file in the current \
238+ directory. Prompts for the paths to the opam overlay, checkouts directory, \
239+ and monorepo directory.";
240+ `P "All paths must be absolute. You can use ~/ for your home directory, \
241+ and relative paths will be converted to absolute based on the current \
242+ working directory.";
243+ ] in
244+ let info = Cmd.info "init" ~doc ~man in
245+ let output_arg =
246+ let doc = "Output path for config file (default: monopam.toml)" in
247+ Arg.(value & opt string "monopam.toml" & info [ "o"; "output" ] ~docv:"FILE" ~doc)
248+ in
249+ let run output () =
250+ Eio_main.run @@ fun env ->
251+ let _fs = Eio.Stdenv.fs env in
252+ let cwd_path = Eio.Stdenv.cwd env in
253+ let stdin = Eio.Buf_read.of_flow ~max_size:(1024 * 1024) (Eio.Stdenv.stdin env) in
254+ let stdout = Eio.Stdenv.stdout env in
255+ (* Get current working directory as Fpath *)
256+ let cwd =
257+ let (_, cwd_str) = (cwd_path :> _ Eio.Path.t) in
258+ match Fpath.of_string cwd_str with
259+ | Ok p -> p
260+ | Error _ -> Fpath.v "/"
261+ in
262+ Eio.Flow.copy_string "Monopam Configuration Setup\n" stdout;
263+ Eio.Flow.copy_string "===========================\n\n" stdout;
264+ Eio.Flow.copy_string "All paths must be absolute. Use ~/ for home directory.\n" stdout;
265+ Eio.Flow.copy_string "Relative paths will be converted to absolute.\n\n" stdout;
266+ (* Prompt for opam_repo *)
267+ let opam_repo = ref None in
268+ while !opam_repo = None do
269+ match prompt_path ~stdin ~stdout ~cwd "Path to opam overlay repository" ~default:None with
270+ | Ok p -> opam_repo := Some p
271+ | Error msg ->
272+ Eio.Flow.copy_string (Fmt.str "Error: %s. Please try again.\n" msg) stdout
273+ done;
274+ let opam_repo = Option.get !opam_repo in
275+ (* Prompt for checkouts *)
276+ let default_checkouts = Fpath.(parent opam_repo / "src") in
277+ let checkouts = ref None in
278+ while !checkouts = None do
279+ match prompt_path ~stdin ~stdout ~cwd "Path for git checkouts" ~default:(Some default_checkouts) with
280+ | Ok p -> checkouts := Some p
281+ | Error msg ->
282+ Eio.Flow.copy_string (Fmt.str "Error: %s. Please try again.\n" msg) stdout
283+ done;
284+ let checkouts = Option.get !checkouts in
285+ (* Prompt for monorepo *)
286+ let default_monorepo = Fpath.(parent opam_repo / "mono") in
287+ let monorepo = ref None in
288+ while !monorepo = None do
289+ match prompt_path ~stdin ~stdout ~cwd "Path for monorepo" ~default:(Some default_monorepo) with
290+ | Ok p -> monorepo := Some p
291+ | Error msg ->
292+ Eio.Flow.copy_string (Fmt.str "Error: %s. Please try again.\n" msg) stdout
293+ done;
294+ let monorepo = Option.get !monorepo in
295+ (* Prompt for default branch *)
296+ Eio.Flow.copy_string "Default git branch [main]: " stdout;
297+ let branch_input = String.trim (Eio.Buf_read.line stdin) in
298+ let default_branch = if branch_input = "" then "main" else branch_input in
299+ (* Create config *)
300+ let config = Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch () in
301+ (* Save config *)
302+ let output_path = Fpath.v output in
303+ match Monopam.Config.save ~fs:(cwd_path :> _ Eio.Path.t) config output_path with
304+ | Ok () ->
305+ Eio.Flow.copy_string (Fmt.str "\nConfiguration saved to %s\n" output) stdout;
306+ Eio.Flow.copy_string "\nYou can now run 'monopam pull' to initialize the monorepo.\n" stdout;
307+ `Ok ()
308+ | Error msg ->
309+ Fmt.epr "Error saving config: %s@." msg;
310+ `Error (false, "init failed")
311+ in
312+ Cmd.v info Term.(ret (const run $ output_arg $ logging_term))
313+314+(* Main command group *)
315+316+let main_cmd =
317+ let doc = "Manage opam overlay with git subtree monorepo" in
318+ let man =
319+ [
320+ `S Manpage.s_description;
321+ `P
322+ "Monopam synchronizes packages between an opam overlay repository, \
323+ individual git checkouts, and a monorepo using git subtrees.";
324+ `S "DIRECTORY STRUCTURE";
325+ `P "Monopam manages three directory trees:";
326+ `I ("opam-repo/", "The opam overlay repository containing package metadata. \
327+ Each package's opam file specifies a dev-repo URL pointing to its git source.");
328+ `I ("src/", "Individual git checkouts of each unique repository. Multiple \
329+ packages may share a checkout if they come from the same dev-repo. \
330+ Directory names are the repository basename (e.g., ocaml-yaml from \
331+ https://github.com/foo/ocaml-yaml.git).");
332+ `I ("mono/", "The monorepo combining all packages as git subtrees. Each \
333+ subtree directory is named after the repository basename. This is \
334+ where you make changes that span multiple packages.");
335+ `S "WORKFLOW";
336+ `P "The typical workflow is:";
337+ `I ("1. monopam pull", "Fetch latest from all remotes, update checkouts, \
338+ merge into monorepo subtrees");
339+ `I ("2. Edit code", "Make changes in the mono/ directory");
340+ `I ("3. git commit", "Commit your changes in mono/");
341+ `I ("4. monopam push", "Extract changes back to individual checkouts");
342+ `I ("5. Review and push", "Review changes in src/*/, then git push each one");
343+ `S "CONFIGURATION";
344+ `P "Run $(b,monopam init) to interactively create a configuration file. \
345+ Configuration is read from monopam.toml in the current directory \
346+ or XDG config locations.";
347+ `P "All paths in the configuration must be absolute. Example:";
348+ `Pre "opam_repo = \"/home/user/opam-overlay\"\n\
349+ checkouts = \"/home/user/src\"\n\
350+ monorepo = \"/home/user/mono\"\n\
351+ default_branch = \"main\"";
352+ `S Manpage.s_commands;
353+ `P "Use $(b,monopam COMMAND --help) for help on a specific command.";
354+ ]
355+ in
356+ let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in
357+ Cmd.group info
358+ [ init_cmd; status_cmd; pull_cmd; push_cmd; add_cmd; remove_cmd ]
359+360+let () = exit (Cmd.eval main_cmd)
···1+(** Configuration management for monopam.
2+3+ Configuration is stored in TOML format and loaded from XDG standard
4+ locations or a user-specified path. The config file specifies paths
5+ to the opam overlay, individual checkouts, and the monorepo, along
6+ with optional per-package overrides. *)
7+8+(** {1 Types} *)
9+10+(** Per-package configuration overrides. *)
11+module Package_config : sig
12+ (** Package-specific settings. *)
13+ type t
14+15+ (** [branch t] returns the branch override for this package, if set. *)
16+ val branch : t -> string option
17+end
18+19+(** The main configuration. *)
20+type t
21+22+(** {1 Paths Configuration} *)
23+24+(** Path-related accessors. *)
25+module Paths : sig
26+ (** [opam_repo t] returns the path to the opam overlay repository. *)
27+ val opam_repo : t -> Fpath.t
28+29+ (** [checkouts t] returns the parent directory where individual
30+ package checkouts are stored. *)
31+ val checkouts : t -> Fpath.t
32+33+ (** [monorepo t] returns the path to the monorepo directory. *)
34+ val monorepo : t -> Fpath.t
35+end
36+37+(** {1 Options} *)
38+39+(** [default_branch t] returns the default git branch to track.
40+41+ Defaults to "main" if not specified. *)
42+val default_branch : t -> string
43+44+(** [package_config t name] returns package-specific configuration
45+ overrides for the named package, if any exist. *)
46+val package_config : t -> string -> Package_config.t option
47+48+(** {1 Validation} *)
49+50+(** Errors that can occur when validating configuration paths. *)
51+type validation_error =
52+ | Path_not_found of string * Fpath.t
53+ (** A configured path does not exist *)
54+ | Not_a_directory of string * Fpath.t
55+ (** A configured path is not a directory *)
56+ | Not_an_opam_repo of Fpath.t
57+ (** The opam_repo path is not a valid opam repository
58+ (missing packages/ directory) *)
59+ | Invalid_path of string * string
60+ (** A path string could not be parsed *)
61+ | Relative_path of string * Fpath.t
62+ (** A configured path is relative but must be absolute *)
63+64+(** [pp_validation_error] formats validation errors. *)
65+val pp_validation_error : validation_error Fmt.t
66+67+(** {1 Loading and Saving} *)
68+69+(** [load ~fs ~root_fs path] loads configuration from the specified TOML file.
70+71+ Validates that paths exist and are valid. Supports tilde expansion
72+ for paths (e.g., [~/src/...]).
73+74+ @param fs The filesystem path for locating the config file
75+ @param root_fs The root filesystem for validating absolute paths in config
76+77+ Returns [Error msg] if the file cannot be read, parsed, or
78+ if validation fails. *)
79+val load : fs:_ Eio.Path.t -> root_fs:_ Eio.Path.t -> Fpath.t -> (t, string) result
80+81+(** [load_xdg ~xdg ()] loads configuration from XDG standard locations.
82+83+ Searches for "config.toml" in the monopam XDG config directory.
84+ Validates that paths exist and are valid. Supports tilde expansion.
85+86+ Returns [Error msg] if no config file is found, parsing fails, or
87+ if validation fails.
88+89+ @param xdg The Xdge context for "monopam" application *)
90+val load_xdg : xdg:Xdge.t -> unit -> (t, string) result
91+92+(** [save ~fs t path] writes the configuration to the specified path. *)
93+val save : fs:_ Eio.Path.t -> t -> Fpath.t -> (unit, string) result
94+95+(** {1 Construction} *)
96+97+(** [create ~opam_repo ~checkouts ~monorepo ?default_branch ()] creates
98+ a new configuration with the specified paths.
99+100+ @param opam_repo Path to the opam overlay repository
101+ @param checkouts Parent directory for individual git checkouts
102+ @param monorepo Path to the monorepo
103+ @param default_branch Default branch to track (default: "main") *)
104+val create :
105+ opam_repo:Fpath.t ->
106+ checkouts:Fpath.t ->
107+ monorepo:Fpath.t ->
108+ ?default_branch:string ->
109+ unit ->
110+ t
111+112+(** [with_package_override t ~name ~branch] returns a new config with
113+ a branch override for the named package. *)
114+val with_package_override : t -> name:string -> branch:string -> t
115+116+(** {1 Pretty Printing} *)
117+118+(** [pp] is a formatter for configuration. *)
119+val pp : t Fmt.t
···1+module Config = Config
2+module Package = Package
3+module Opam_repo = Opam_repo
4+module Git = Git
5+module Status = Status
6+7+let src = Logs.Src.create "monopam" ~doc:"Monopam operations"
8+module Log = (val Logs.src_log src : Logs.LOG)
9+10+type error =
11+ | Config_error of string
12+ | Repo_error of Opam_repo.error
13+ | Git_error of Git.error
14+ | Dirty_state of Package.t list
15+ | Package_not_found of string
16+17+let pp_error ppf = function
18+ | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
19+ | Repo_error e -> Fmt.pf ppf "Repository error: %a" Opam_repo.pp_error e
20+ | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e
21+ | Dirty_state pkgs ->
22+ Fmt.pf ppf "Dirty packages: %a"
23+ Fmt.(list ~sep:comma (using Package.name string))
24+ pkgs
25+ | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name
26+27+let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t =
28+ let (dir, _) = fs in
29+ (dir, "")
30+31+let discover_packages ~fs ~config () =
32+ let repo_path = Config.Paths.opam_repo config in
33+ Log.debug (fun m -> m "Scanning opam repo at %a" Fpath.pp repo_path);
34+ match Opam_repo.scan ~fs repo_path with
35+ | Ok pkgs ->
36+ Log.info (fun m -> m "Found %d packages in opam repo" (List.length pkgs));
37+ Ok pkgs
38+ | Error e -> Error (Repo_error e)
39+40+let find_package ~fs ~config name =
41+ match discover_packages ~fs ~config () with
42+ | Error e -> Error e
43+ | Ok pkgs -> (
44+ match List.find_opt (fun p -> Package.name p = name) pkgs with
45+ | Some p -> Ok p
46+ | None -> Error (Package_not_found name))
47+48+let rec mkdirs path =
49+ match Eio.Path.kind ~follow:true path with
50+ | `Directory -> ()
51+ | _ ->
52+ Log.debug (fun m -> m "Creating directory %a" Eio.Path.pp path);
53+ Eio.Path.mkdir ~perm:0o755 path
54+ | exception Eio.Io _ ->
55+ (* Parent might not exist, try to create it first *)
56+ let parent = Eio.Path.split path in
57+ (match parent with
58+ | Some (parent_path, _) -> mkdirs parent_path
59+ | None -> ());
60+ Log.debug (fun m -> m "Creating directory %a" Eio.Path.pp path);
61+ Eio.Path.mkdir ~perm:0o755 path
62+63+let ensure_checkouts_dir ~fs ~config =
64+ let checkouts = Config.Paths.checkouts config in
65+ let checkouts_eio = Eio.Path.(fs / Fpath.to_string checkouts) in
66+ Log.debug (fun m -> m "Ensuring checkouts directory exists: %a" Fpath.pp checkouts);
67+ mkdirs checkouts_eio
68+69+let status ~proc ~fs ~config () =
70+ let fs = fs_typed fs in
71+ (* Ensure checkouts directory exists before computing status *)
72+ ensure_checkouts_dir ~fs ~config;
73+ match discover_packages ~fs:(fs :> _ Eio.Path.t) ~config () with
74+ | Error e -> Error e
75+ | Ok pkgs -> Ok (Status.compute_all ~proc ~fs ~config pkgs)
76+77+let get_branch ~config pkg =
78+ match Package.branch pkg with
79+ | Some b -> b
80+ | None -> (
81+ match Config.package_config config (Package.name pkg) with
82+ | Some pc -> Option.value ~default:(Config.default_branch config) (Config.Package_config.branch pc)
83+ | None -> Config.default_branch config)
84+85+let ensure_checkout ~proc ~fs ~config pkg =
86+ let checkouts_root = Config.Paths.checkouts config in
87+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
88+ let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
89+ let branch = get_branch ~config pkg in
90+ let do_clone () =
91+ Log.info (fun m -> m "Cloning %s from %a (branch: %s)"
92+ (Package.repo_name pkg) Uri.pp (Package.dev_repo pkg) branch);
93+ Git.clone ~proc ~fs ~url:(Package.dev_repo pkg) ~branch checkout_dir
94+ in
95+ let is_directory =
96+ match Eio.Path.kind ~follow:true checkout_eio with
97+ | `Directory -> true
98+ | _ -> false
99+ | exception Eio.Io _ -> false
100+ in
101+ if not is_directory then do_clone ()
102+ else if not (Git.is_repo ~proc ~fs checkout_dir) then do_clone ()
103+ else begin
104+ Log.info (fun m -> m "Fetching %s" (Package.repo_name pkg));
105+ match Git.fetch ~proc ~fs checkout_dir with
106+ | Error e -> Error e
107+ | Ok () ->
108+ Log.info (fun m -> m "Updating %s to %s" (Package.repo_name pkg) branch);
109+ Git.merge_ff ~proc ~fs ~branch checkout_dir
110+ end
111+112+let claude_md_content = {|# Monorepo Development Guide
113+114+This is a monorepo managed by `monopam`. Each subdirectory is a git subtree
115+from a separate upstream repository.
116+117+## Making Changes
118+119+1. Edit code in any subdirectory as normal
120+2. Build and test: `opam exec -- dune build` and `opam exec -- dune test`
121+3. Commit your changes to this monorepo with git
122+123+## Exporting Changes to Upstream
124+125+After committing changes here, they must be exported to the individual
126+repositories before they can be pushed upstream:
127+128+```
129+monopam push
130+```
131+132+This extracts your commits into the individual checkouts in `../src/`.
133+You then review and push each one manually:
134+135+```
136+cd ../src/<repo-name>
137+git log --oneline -5 # review the changes
138+git push origin main # push to upstream
139+```
140+141+## Pulling Updates from Upstream
142+143+To fetch the latest changes from all upstream repositories:
144+145+```
146+monopam pull
147+```
148+149+This updates both the checkouts and merges changes into this monorepo.
150+151+## Important Notes
152+153+- **Always commit before push**: `monopam push` only exports committed changes
154+- **Check status first**: Run `monopam status` to see which repos have changes
155+- **One repo per directory**: Each subdirectory maps to exactly one git remote
156+- **Shared repos**: Multiple opam packages may live in the same subdirectory
157+ if they share an upstream repository
158+159+## Troubleshooting
160+161+If `monopam push` fails with "dirty state", you have uncommitted changes.
162+Commit or stash them first.
163+164+If merge conflicts occur during `monopam pull`, resolve them in this monorepo,
165+commit, then the next pull will succeed.
166+|}
167+168+let ensure_monorepo_initialized ~proc ~fs ~config =
169+ let monorepo = Config.Paths.monorepo config in
170+ let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
171+ let init_and_commit () =
172+ Log.info (fun m -> m "Initializing monorepo at %a" Fpath.pp monorepo);
173+ match Git.init ~proc ~fs monorepo with
174+ | Error e -> Error (Git_error e)
175+ | Ok () ->
176+ (* Create dune-project file so the monorepo builds *)
177+ let dune_project = Eio.Path.(monorepo_eio / "dune-project") in
178+ Log.debug (fun m -> m "Creating dune-project file");
179+ Eio.Path.save ~create:(`Or_truncate 0o644) dune_project "(lang dune 3.20)\n";
180+ (* Create CLAUDE.md for agent instructions *)
181+ let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in
182+ Log.debug (fun m -> m "Creating CLAUDE.md");
183+ Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content;
184+ (* Stage the files *)
185+ Log.debug (fun m -> m "Staging initial files");
186+ Eio.Switch.run (fun sw ->
187+ let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
188+ [ "git"; "add"; "dune-project"; "CLAUDE.md" ] in
189+ ignore (Eio.Process.await child));
190+ (* Commit *)
191+ Log.debug (fun m -> m "Creating initial commit in monorepo");
192+ match Git.commit_allow_empty ~proc ~fs ~message:"Initial commit with dune-project and CLAUDE.md" monorepo with
193+ | Ok () -> Ok ()
194+ | Error e -> Error (Git_error e)
195+ in
196+ let ensure_claude_md () =
197+ let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in
198+ let exists =
199+ match Eio.Path.kind ~follow:true claude_md with
200+ | `Regular_file -> true
201+ | _ -> false
202+ | exception Eio.Io _ -> false
203+ in
204+ if not exists then begin
205+ Log.info (fun m -> m "Adding CLAUDE.md to monorepo");
206+ Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content;
207+ Eio.Switch.run (fun sw ->
208+ let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
209+ [ "git"; "add"; "CLAUDE.md" ] in
210+ ignore (Eio.Process.await child));
211+ Eio.Switch.run (fun sw ->
212+ let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio
213+ [ "git"; "commit"; "-m"; "Add CLAUDE.md" ] in
214+ ignore (Eio.Process.await child))
215+ end
216+ in
217+ let is_directory =
218+ match Eio.Path.kind ~follow:true monorepo_eio with
219+ | `Directory -> true
220+ | _ -> false
221+ | exception Eio.Io _ -> false
222+ in
223+ if is_directory && Git.is_repo ~proc ~fs monorepo then begin
224+ Log.debug (fun m -> m "Monorepo already initialized at %a" Fpath.pp monorepo);
225+ ensure_claude_md ();
226+ Ok ()
227+ end else begin
228+ if not is_directory then begin
229+ Log.debug (fun m -> m "Creating monorepo directory %a" Fpath.pp monorepo);
230+ mkdirs monorepo_eio
231+ end;
232+ init_and_commit ()
233+ end
234+235+(* Normalize URL for comparison: extract scheme + host + path, strip trailing slashes *)
236+let normalize_url_for_comparison uri =
237+ let scheme = Option.value ~default:"" (Uri.scheme uri) in
238+ let host = Option.value ~default:"" (Uri.host uri) in
239+ let path = Uri.path uri in
240+ (* Strip trailing slash from path *)
241+ let path =
242+ if String.length path > 1 && path.[String.length path - 1] = '/' then
243+ String.sub path 0 (String.length path - 1)
244+ else path
245+ in
246+ Printf.sprintf "%s://%s%s" scheme host path
247+248+(* Deduplicate packages by dev-repo, keeping first occurrence of each repo *)
249+let unique_repos pkgs =
250+ let seen = Hashtbl.create 16 in
251+ List.filter (fun pkg ->
252+ let url = normalize_url_for_comparison (Package.dev_repo pkg) in
253+ Log.debug (fun m -> m "Checking repo URL: %s (from %s)" url (Package.name pkg));
254+ if Hashtbl.mem seen url then begin
255+ Log.debug (fun m -> m " -> Already seen, skipping");
256+ false
257+ end else begin
258+ Hashtbl.add seen url ();
259+ Log.debug (fun m -> m " -> New repo, keeping");
260+ true
261+ end) pkgs
262+263+(* Result of pulling a single repo *)
264+type pull_result = {
265+ repo_name : string;
266+ cloned : bool; (* true if newly cloned, false if fetched *)
267+ commits_pulled : int; (* number of commits pulled, 0 if none or cloned *)
268+ subtree_added : bool; (* true if subtree was newly added *)
269+}
270+271+let pull_subtree ~proc ~fs ~config pkg =
272+ let fs = fs_typed fs in
273+ let monorepo = Config.Paths.monorepo config in
274+ let prefix = Package.subtree_prefix pkg in
275+ let branch = get_branch ~config pkg in
276+ let url = Package.dev_repo pkg in
277+ if Git.Subtree.exists ~fs ~repo:monorepo ~prefix then begin
278+ Log.info (fun m -> m "Pulling subtree %s" prefix);
279+ match Git.Subtree.pull ~proc ~fs ~repo:monorepo ~prefix ~url ~branch () with
280+ | Ok () -> Ok false (* not newly added *)
281+ | Error e -> Error (Git_error e)
282+ end else begin
283+ Log.info (fun m -> m "Adding subtree %s" prefix);
284+ match Git.Subtree.add ~proc ~fs ~repo:monorepo ~prefix ~url ~branch () with
285+ | Ok () -> Ok true (* newly added *)
286+ | Error e -> Error (Git_error e)
287+ end
288+289+(* Check if checkout exists and is a repo *)
290+let checkout_exists ~proc ~fs ~config pkg =
291+ let checkouts_root = Config.Paths.checkouts config in
292+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
293+ let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
294+ match Eio.Path.kind ~follow:true checkout_eio with
295+ | `Directory -> Git.is_repo ~proc ~fs checkout_dir
296+ | _ -> false
297+ | exception Eio.Io _ -> false
298+299+(* Get commits behind before fetching *)
300+let get_behind ~proc ~fs ~config pkg =
301+ let checkouts_root = Config.Paths.checkouts config in
302+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
303+ let branch = get_branch ~config pkg in
304+ match Git.ahead_behind ~proc ~fs ~branch checkout_dir with
305+ | Ok ab -> ab.behind
306+ | Error _ -> 0
307+308+let pull ~proc ~fs ~config ?package () =
309+ let fs_t = fs_typed fs in
310+ (* Update the opam repo first *)
311+ let opam_repo = Config.Paths.opam_repo config in
312+ if Git.is_repo ~proc ~fs:fs_t opam_repo then begin
313+ Log.info (fun m -> m "Updating opam repo at %a" Fpath.pp opam_repo);
314+ let result =
315+ let ( let* ) = Result.bind in
316+ let* () = Git.fetch ~proc ~fs:fs_t opam_repo in
317+ Git.merge_ff ~proc ~fs:fs_t opam_repo
318+ in
319+ match result with
320+ | Ok () -> ()
321+ | Error e -> Log.warn (fun m -> m "Failed to update opam repo: %a" Git.pp_error e)
322+ end;
323+ (* Ensure directories exist before computing status *)
324+ ensure_checkouts_dir ~fs:fs_t ~config;
325+ match ensure_monorepo_initialized ~proc ~fs:fs_t ~config with
326+ | Error e -> Error e
327+ | Ok () ->
328+ match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
329+ | Error e -> Error e
330+ | Ok all_pkgs ->
331+ let pkgs =
332+ match package with
333+ | None -> all_pkgs
334+ | Some name -> List.filter (fun p -> Package.name p = name) all_pkgs
335+ in
336+ if pkgs = [] && package <> None then
337+ Error (Package_not_found (Option.get package))
338+ else begin
339+ Log.info (fun m -> m "Checking status of %d packages" (List.length pkgs));
340+ let statuses = Status.compute_all ~proc ~fs:fs_t ~config pkgs in
341+ let dirty =
342+ List.filter Status.has_local_changes statuses
343+ |> List.map (fun s -> s.Status.package)
344+ in
345+ if dirty <> [] then Error (Dirty_state dirty)
346+ else begin
347+ (* First, clone/fetch unique repositories *)
348+ let repos = unique_repos pkgs in
349+ Log.info (fun m -> m "Cloning/fetching %d unique repositories" (List.length repos));
350+ let clone_repos () =
351+ let total = List.length repos in
352+ let rec loop i acc = function
353+ | [] -> Ok (List.rev acc)
354+ | pkg :: rest ->
355+ let repo_name = Package.repo_name pkg in
356+ Log.info (fun m -> m "[%d/%d] Fetching repo %s" i total repo_name);
357+ let existed = checkout_exists ~proc ~fs:fs_t ~config pkg in
358+ let behind_before =
359+ if existed then get_behind ~proc ~fs:fs_t ~config pkg else 0
360+ in
361+ match ensure_checkout ~proc ~fs:fs_t ~config pkg with
362+ | Error e -> Error (Git_error e)
363+ | Ok () ->
364+ let result = {
365+ repo_name;
366+ cloned = not existed;
367+ commits_pulled = behind_before;
368+ subtree_added = false; (* will be updated later *)
369+ } in
370+ loop (i + 1) (result :: acc) rest
371+ in
372+ loop 1 [] repos
373+ in
374+ match clone_repos () with
375+ | Error e -> Error e
376+ | Ok checkout_results ->
377+ (* Then, add/pull subtrees for unique repos only *)
378+ Log.info (fun m -> m "Processing %d unique subtrees" (List.length repos));
379+ let total = List.length repos in
380+ let rec loop i results_acc repos_left checkout_results_left =
381+ match repos_left, checkout_results_left with
382+ | [], [] -> Ok (List.rev results_acc)
383+ | pkg :: rest_repos, cr :: rest_cr ->
384+ Log.info (fun m -> m "[%d/%d] Subtree %s" i total (Package.subtree_prefix pkg));
385+ (match pull_subtree ~proc ~fs ~config pkg with
386+ | Ok subtree_added ->
387+ let result = { cr with subtree_added } in
388+ loop (i + 1) (result :: results_acc) rest_repos rest_cr
389+ | Error e -> Error e)
390+ | _ -> Ok (List.rev results_acc) (* mismatched lengths, shouldn't happen *)
391+ in
392+ match loop 1 [] repos checkout_results with
393+ | Error e -> Error e
394+ | Ok results ->
395+ (* Print summary *)
396+ let cloned = List.filter (fun r -> r.cloned) results in
397+ let updated = List.filter (fun r -> not r.cloned && r.commits_pulled > 0) results in
398+ let added = List.filter (fun r -> r.subtree_added) results in
399+ if cloned <> [] then begin
400+ Log.app (fun m -> m "Cloned %d new repositories:" (List.length cloned));
401+ List.iter (fun r -> Log.app (fun m -> m " + %s" r.repo_name)) cloned
402+ end;
403+ if updated <> [] then begin
404+ Log.app (fun m -> m "Updated %d repositories:" (List.length updated));
405+ List.iter (fun r ->
406+ Log.app (fun m -> m " ~ %s (%d new commits)" r.repo_name r.commits_pulled)
407+ ) updated
408+ end;
409+ if added <> [] then begin
410+ Log.app (fun m -> m "Added %d new subtrees:" (List.length added));
411+ List.iter (fun r -> Log.app (fun m -> m " + %s" r.repo_name)) added
412+ end;
413+ let unchanged = List.length results - List.length cloned - List.length updated in
414+ if cloned = [] && updated = [] && added = [] then
415+ Log.app (fun m -> m "All %d repositories up to date." (List.length results))
416+ else if unchanged > 0 then
417+ Log.app (fun m -> m "%d repositories unchanged." unchanged);
418+ Ok ()
419+ end
420+ end
421+422+let run_git_in ~proc ~cwd args =
423+ Eio.Switch.run @@ fun sw ->
424+ let buf_stdout = Buffer.create 256 in
425+ let buf_stderr = Buffer.create 256 in
426+ let child =
427+ Eio.Process.spawn proc ~sw ~cwd
428+ ~stdout:(Eio.Flow.buffer_sink buf_stdout)
429+ ~stderr:(Eio.Flow.buffer_sink buf_stderr)
430+ ("git" :: args)
431+ in
432+ match Eio.Process.await child with
433+ | `Exited 0 -> Ok (Buffer.contents buf_stdout |> String.trim)
434+ | _ ->
435+ let result = Git.{
436+ exit_code = 1;
437+ stdout = Buffer.contents buf_stdout;
438+ stderr = Buffer.contents buf_stderr;
439+ } in
440+ Error (Git.Command_failed (String.concat " " ("git" :: args), result))
441+442+let push_one ~proc ~fs ~config pkg =
443+ let ( let* ) r f = Result.bind (Result.map_error (fun e -> Git_error e) r) f in
444+ let fs = fs_typed fs in
445+ let monorepo = Config.Paths.monorepo config in
446+ let prefix = Package.subtree_prefix pkg in
447+ let checkouts_root = Config.Paths.checkouts config in
448+ let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
449+ let branch = get_branch ~config pkg in
450+ let sync_branch = "monopam-sync" in
451+ if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then begin
452+ Log.debug (fun m -> m "Subtree %s not in monorepo, skipping" prefix);
453+ Ok ()
454+ end else begin
455+ let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in
456+ match Eio.Path.kind ~follow:true checkout_eio with
457+ | exception Eio.Io _ ->
458+ Log.debug (fun m -> m "Checkout %a does not exist, skipping" Fpath.pp checkout_dir);
459+ Ok ()
460+ | `Directory when Git.is_repo ~proc ~fs checkout_dir ->
461+ let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in
462+ let checkout_path = Fpath.to_string checkout_dir in
463+ (* Push subtree to a sync branch (avoids "branch is checked out" error) *)
464+ Log.info (fun m -> m "Pushing subtree %s to checkout" prefix);
465+ let* _ = run_git_in ~proc ~cwd:monorepo_eio
466+ [ "subtree"; "push"; "--prefix"; prefix; checkout_path; sync_branch ] in
467+ (* Merge sync branch into the target branch in checkout *)
468+ Log.debug (fun m -> m "Merging %s into %s" sync_branch branch);
469+ let* _ = run_git_in ~proc ~cwd:checkout_eio [ "merge"; "--ff-only"; sync_branch ] in
470+ (* Delete the sync branch *)
471+ Log.debug (fun m -> m "Cleaning up %s branch" sync_branch);
472+ ignore (run_git_in ~proc ~cwd:checkout_eio [ "branch"; "-d"; sync_branch ]);
473+ Ok ()
474+ | _ ->
475+ Log.debug (fun m -> m "Checkout %a is not a git repo, skipping" Fpath.pp checkout_dir);
476+ Ok ()
477+ end
478+479+let push ~proc ~fs ~config ?package () =
480+ let fs_t = fs_typed fs in
481+ (* Ensure checkouts directory exists before computing status *)
482+ ensure_checkouts_dir ~fs:fs_t ~config;
483+ match discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config () with
484+ | Error e -> Error e
485+ | Ok all_pkgs ->
486+ let pkgs =
487+ match package with
488+ | None -> all_pkgs
489+ | Some name -> List.filter (fun p -> Package.name p = name) all_pkgs
490+ in
491+ if pkgs = [] && package <> None then
492+ Error (Package_not_found (Option.get package))
493+ else begin
494+ Log.info (fun m -> m "Checking status of %d packages" (List.length pkgs));
495+ let statuses = Status.compute_all ~proc ~fs:fs_t ~config pkgs in
496+ let dirty =
497+ List.filter Status.has_local_changes statuses
498+ |> List.map (fun s -> s.Status.package)
499+ in
500+ if dirty <> [] then Error (Dirty_state dirty)
501+ else begin
502+ let repos = unique_repos pkgs in
503+ Log.info (fun m -> m "Pushing %d unique repos" (List.length repos));
504+ let total = List.length repos in
505+ let rec loop i = function
506+ | [] -> Ok ()
507+ | pkg :: rest ->
508+ Log.info (fun m -> m "[%d/%d] Processing %s" i total (Package.subtree_prefix pkg));
509+ match push_one ~proc ~fs ~config pkg with
510+ | Ok () -> loop (i + 1) rest
511+ | Error e -> Error e
512+ in
513+ loop 1 repos
514+ end
515+ end
516+517+let add ~proc ~fs ~config ~package () =
518+ let fs_t = fs_typed fs in
519+ ensure_checkouts_dir ~fs:fs_t ~config;
520+ match ensure_monorepo_initialized ~proc ~fs:fs_t ~config with
521+ | Error e -> Error e
522+ | Ok () ->
523+ match find_package ~fs:(fs_t :> _ Eio.Path.t) ~config package with
524+ | Error e -> Error e
525+ | Ok pkg ->
526+ Log.info (fun m -> m "Adding package %s" (Package.name pkg));
527+ match ensure_checkout ~proc ~fs:fs_t ~config pkg with
528+ | Error e -> Error (Git_error e)
529+ | Ok () ->
530+ pull_subtree ~proc ~fs ~config pkg
531+ |> Result.map (fun _ -> ())
532+533+let remove ~proc:_ ~fs ~config ~package () =
534+ let fs = fs_typed fs in
535+ let monorepo = Config.Paths.monorepo config in
536+ let prefix = package in
537+ if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then Ok ()
538+ else
539+ let subtree_path = Eio.Path.(fs / Fpath.to_string monorepo / prefix) in
540+ try
541+ Eio.Path.rmtree subtree_path;
542+ Ok ()
543+ with Eio.Io _ as e -> Error (Git_error (Git.Io_error (Printexc.to_string e)))
···1+(** Opam repository scanning and package discovery.
2+3+ This module scans an opam overlay repository to discover packages
4+ and extract their git remote URLs. It enforces the monopam constraints:
5+ - Only one version per package is allowed
6+ - Only git remotes are allowed (no archive URLs) *)
7+8+(** {1 Types} *)
9+10+(** Errors that can occur during repository scanning. *)
11+type error =
12+ | Multiple_versions of string * string list
13+ (** Package has multiple versions: (name, versions) *)
14+ | No_dev_repo of string
15+ (** Package has no dev-repo field *)
16+ | Invalid_dev_repo of string * string
17+ (** Package has invalid dev-repo: (name, url) *)
18+ | Not_git_remote of string * string
19+ (** Package dev-repo is not a git URL: (name, url) *)
20+ | Parse_error of string * string
21+ (** Failed to parse opam file: (path, message) *)
22+ | Io_error of string
23+ (** Filesystem error *)
24+25+(** [pp_error] is a formatter for errors. *)
26+val pp_error : error Fmt.t
27+28+(** {1 Scanning} *)
29+30+(** [scan ~fs repo_path] scans the opam repository at [repo_path] and
31+ returns all discovered packages.
32+33+ The repository is expected to have the standard opam layout:
34+ [repo_path/packages/<name>/<name.version>/opam]
35+36+ Validates that:
37+ - Each package has exactly one version
38+ - Each package has a dev-repo field
39+ - The dev-repo is a git URL (starts with "git+" or ends with ".git")
40+41+ @param fs Eio filesystem capability
42+ @param repo_path Path to the opam repository root *)
43+val scan :
44+ fs:_ Eio.Path.t ->
45+ Fpath.t ->
46+ (Package.t list, error) result
47+48+(** [scan_all ~fs repo_path] scans the repository and returns all
49+ packages, collecting errors instead of failing on the first one.
50+51+ Returns a pair of (valid packages, errors encountered). *)
52+val scan_all :
53+ fs:_ Eio.Path.t ->
54+ Fpath.t ->
55+ Package.t list * error list
56+57+(** {1 Individual Package Loading} *)
58+59+(** [load_package ~fs opam_file_path] loads a single package from its
60+ opam file path.
61+62+ @param fs Eio filesystem capability
63+ @param opam_file_path Full path to the opam file *)
64+val load_package :
65+ fs:_ Eio.Path.t ->
66+ Fpath.t ->
67+ (Package.t, error) result
68+69+(** {1 Validation} *)
70+71+(** [validate_repo ~fs repo_path] validates the entire repository
72+ without loading packages. Returns a list of validation errors.
73+74+ This is useful for checking repository health before operations. *)
75+val validate_repo :
76+ fs:_ Eio.Path.t ->
77+ Fpath.t ->
78+ error list
79+80+(** [is_git_url url] returns true if the URL is a valid git remote.
81+82+ Accepts URLs starting with "git+" or "git://" or ending with ".git",
83+ as well as SSH-style URLs like "git\@github.com:...". *)
84+val is_git_url : string -> bool
85+86+(** [normalize_git_url url] normalizes a git URL by removing the "git+"
87+ prefix if present.
88+89+ For example, "git+https://example.com/repo.git" becomes
90+ "https://example.com/repo.git". *)
91+val normalize_git_url : string -> Uri.t
+32
lib/package.ml
···00000000000000000000000000000000
···1+type t = {
2+ name : string;
3+ version : string;
4+ dev_repo : Uri.t;
5+ branch : string option;
6+}
7+8+let create ~name ~version ~dev_repo ?branch () = { name; version; dev_repo; branch }
9+let name t = t.name
10+let version t = t.version
11+let dev_repo t = t.dev_repo
12+let branch t = t.branch
13+14+let repo_name t =
15+ (* Extract basename from dev-repo URL, stripping .git suffix *)
16+ let path = Uri.path t.dev_repo in
17+ let basename = Filename.basename path in
18+ if Filename.check_suffix basename ".git" then
19+ Filename.chop_suffix basename ".git"
20+ else
21+ basename
22+23+let checkout_dir ~checkouts_root t = Fpath.(checkouts_root / repo_name t)
24+let subtree_prefix t = repo_name t
25+let compare a b = String.compare a.name b.name
26+let equal a b = String.equal a.name b.name
27+28+let same_repo a b = Uri.equal a.dev_repo b.dev_repo
29+30+let pp ppf t =
31+ Fmt.pf ppf "@[<hov 2>{ name = %S;@ version = %S;@ dev_repo = %a;@ branch = %a }@]"
32+ t.name t.version Uri.pp t.dev_repo Fmt.(option ~none:(any "None") string) t.branch
···1+(** Package metadata and operations.
2+3+ A package represents a single opam package discovered from the opam
4+ overlay repository. It contains the package name, version, git remote
5+ URL, and optional configuration overrides. *)
6+7+(** {1 Types} *)
8+9+(** A package discovered from the opam overlay. *)
10+type t
11+12+(** {1 Constructors} *)
13+14+(** [create ~name ~version ~dev_repo ?branch ()] creates a new package.
15+16+ @param name The opam package name
17+ @param version The package version (e.g., "dev")
18+ @param dev_repo The git remote URL from the opam file's dev-repo field
19+ @param branch Optional branch override; defaults to repository default *)
20+val create :
21+ name:string ->
22+ version:string ->
23+ dev_repo:Uri.t ->
24+ ?branch:string ->
25+ unit ->
26+ t
27+28+(** {1 Accessors} *)
29+30+(** [name t] returns the package name. *)
31+val name : t -> string
32+33+(** [version t] returns the package version string. *)
34+val version : t -> string
35+36+(** [dev_repo t] returns the git remote URI. *)
37+val dev_repo : t -> Uri.t
38+39+(** [branch t] returns the branch to track, if explicitly set. *)
40+val branch : t -> string option
41+42+(** [repo_name t] returns the repository name extracted from the dev-repo URL.
43+44+ This is the basename of the URL path with any ".git" suffix removed.
45+ For example, "https://github.com/foo/bar.git" returns "bar". *)
46+val repo_name : t -> string
47+48+(** {1 Derived Paths} *)
49+50+(** [checkout_dir ~checkouts_root t] returns the expected path for this
51+ package's git checkout, based on the repository name.
52+53+ For a package with dev-repo "https://example.com/foo/bar.git" and
54+ checkouts_root "/home/user/src", this returns "/home/user/src/bar".
55+56+ Multiple packages from the same repository will share the same checkout. *)
57+val checkout_dir : checkouts_root:Fpath.t -> t -> Fpath.t
58+59+(** [subtree_prefix t] returns the subdirectory name used in the monorepo.
60+61+ This is the repository name (same as [repo_name t]), so multiple packages
62+ from the same repository share the same subtree directory. *)
63+val subtree_prefix : t -> string
64+65+(** {1 Comparison} *)
66+67+(** [compare a b] compares packages by name. *)
68+val compare : t -> t -> int
69+70+(** [equal a b] returns true if packages have the same name. *)
71+val equal : t -> t -> bool
72+73+(** [same_repo a b] returns true if packages share the same dev-repo URL. *)
74+val same_repo : t -> t -> bool
75+76+(** {1 Pretty Printing} *)
77+78+(** [pp] is a formatter for packages. *)
79+val pp : t Fmt.t
···1+(** Status computation and display.
2+3+ This module computes the synchronization status of packages across
4+ the three locations: git remote, individual checkout, and monorepo
5+ subtree. *)
6+7+(** {1 Types} *)
8+9+(** Status of an individual checkout relative to its remote. *)
10+type checkout_status =
11+ | Missing
12+ (** Checkout directory does not exist *)
13+ | Not_a_repo
14+ (** Directory exists but is not a git repository *)
15+ | Dirty
16+ (** Has uncommitted changes *)
17+ | Clean of Git.ahead_behind
18+ (** Clean with ahead/behind info relative to remote *)
19+20+(** Status of a subtree in the monorepo. *)
21+type subtree_status =
22+ | Not_added
23+ (** Subtree has not been added to monorepo *)
24+ | Present
25+ (** Subtree exists in monorepo *)
26+27+(** Combined status for a package. *)
28+type t = {
29+ package : Package.t;
30+ checkout : checkout_status;
31+ subtree : subtree_status;
32+}
33+34+(** {1 Status Computation} *)
35+36+(** [compute ~proc ~fs ~config pkg] computes the status of a single package.
37+38+ @param proc Eio process manager
39+ @param fs Eio filesystem
40+ @param config Monopam configuration
41+ @param pkg Package to check *)
42+val compute :
43+ proc:_ Eio.Process.mgr ->
44+ fs:Eio.Fs.dir_ty Eio.Path.t ->
45+ config:Config.t ->
46+ Package.t ->
47+ t
48+49+(** [compute_all ~proc ~fs ~config packages] computes status for all
50+ packages in parallel.
51+52+ @param proc Eio process manager
53+ @param fs Eio filesystem
54+ @param config Monopam configuration
55+ @param packages List of packages to check *)
56+val compute_all :
57+ proc:_ Eio.Process.mgr ->
58+ fs:Eio.Fs.dir_ty Eio.Path.t ->
59+ config:Config.t ->
60+ Package.t list ->
61+ t list
62+63+(** {1 Predicates} *)
64+65+(** [is_checkout_clean t] returns true if the checkout is clean
66+ (exists, is a repo, and has no uncommitted changes). *)
67+val is_checkout_clean : t -> bool
68+69+(** [has_local_changes t] returns true if either the checkout or
70+ subtree has uncommitted local changes. *)
71+val has_local_changes : t -> bool
72+73+(** [needs_pull t] returns true if the checkout is behind the remote. *)
74+val needs_pull : t -> bool
75+76+(** [needs_push t] returns true if the checkout is ahead of the remote. *)
77+val needs_push : t -> bool
78+79+(** [is_fully_synced t] returns true if the package is fully in sync
80+ across all locations. *)
81+val is_fully_synced : t -> bool
82+83+(** {1 Filtering} *)
84+85+(** [filter_actionable statuses] returns only packages that need action
86+ (dirty, ahead, behind, or missing subtree). *)
87+val filter_actionable : t list -> t list
88+89+(** {1 Pretty Printing} *)
90+91+(** [pp_checkout_status] formats checkout status. *)
92+val pp_checkout_status : checkout_status Fmt.t
93+94+(** [pp_subtree_status] formats subtree status. *)
95+val pp_subtree_status : subtree_status Fmt.t
96+97+(** [pp] formats a single package status. *)
98+val pp : t Fmt.t
99+100+(** [pp_summary] formats a summary of all package statuses. *)
101+val pp_summary : t list Fmt.t