Monorepo management for opam overlays
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add extract command for extracting subdirectories as standalone repos

Enables "develop in monorepo first, extract later" workflow by running
git subtree split to extract commits, creating a new repository in
checkouts, and configuring the remote URL. Optionally pushes to remote
and creates opam package metadata.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+201 -1
+54 -1
bin/main.ml
··· 179 179 Term.( 180 180 ret (const run $ config_file_arg $ package_arg $ upstream_arg $ logging_term)) 181 181 182 + (* Extract command *) 183 + 184 + let extract_cmd = 185 + let doc = "Extract a subdirectory as a standalone repository" in 186 + let man = [ 187 + `S Manpage.s_description; 188 + `P "Extracts a subdirectory from the monorepo as a standalone git \ 189 + repository with full history. Enables 'develop first, extract later'."; 190 + `P "The extraction process:"; 191 + `I ("1.", "Runs git subtree split to extract commits"); 192 + `I ("2.", "Creates a new git repository in checkouts"); 193 + `I ("3.", "Configures the remote URL"); 194 + `S "EXAMPLES"; 195 + `Pre " monopam extract my-lib --repo git@github.com:user/my-lib.git"; 196 + `Pre " monopam extract my-lib --repo git@github.com:user/my-lib.git --push"; 197 + ] in 198 + let info = Cmd.info "extract" ~doc ~man in 199 + let subdir_arg = 200 + let doc = "Subdirectory in monorepo to extract" in 201 + Arg.(required & pos 0 (some string) None & info [] ~docv:"SUBDIR" ~doc) 202 + in 203 + let repo_arg = 204 + let doc = "Git URL for the new repository" in 205 + Arg.(required & opt (some string) None & info [ "repo"; "r" ] ~docv:"URL" ~doc) 206 + in 207 + let branch_arg = 208 + let doc = "Branch name (default: from config)" in 209 + Arg.(value & opt (some string) None & info [ "branch"; "b" ] ~docv:"BRANCH" ~doc) 210 + in 211 + let push_arg = 212 + let doc = "Push to remote after extraction" in 213 + Arg.(value & flag & info [ "push" ] ~doc) 214 + in 215 + let create_opam_arg = 216 + let doc = "Create opam package metadata in overlay" in 217 + Arg.(value & flag & info [ "create-opam" ] ~doc) 218 + in 219 + let run config_file subdir repo branch push create_opam () = 220 + Eio_main.run @@ fun env -> 221 + with_config env config_file @@ fun config -> 222 + let fs = Eio.Stdenv.fs env in 223 + let proc = Eio.Stdenv.process_mgr env in 224 + match Monopam.extract ~proc ~fs ~config ~subdir ~repo_url:repo 225 + ?branch ~push ~create_opam () with 226 + | Ok () -> `Ok () 227 + | Error e -> 228 + Fmt.epr "Error: %a@." Monopam.pp_error e; 229 + `Error (false, "extract failed") 230 + in 231 + Cmd.v info 232 + Term.(ret (const run $ config_file_arg $ subdir_arg $ repo_arg 233 + $ branch_arg $ push_arg $ create_opam_arg $ logging_term)) 234 + 182 235 (* Add command *) 183 236 184 237 let add_cmd = ··· 548 601 in 549 602 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in 550 603 Cmd.group info 551 - [ init_cmd; status_cmd; pull_cmd; push_cmd; add_cmd; remove_cmd; changes_cmd ] 604 + [ init_cmd; status_cmd; pull_cmd; push_cmd; extract_cmd; add_cmd; remove_cmd; changes_cmd ] 552 605 553 606 let () = exit (Cmd.eval main_cmd)
+113
lib/monopam.ml
··· 16 16 | Dirty_state of Package.t list 17 17 | Package_not_found of string 18 18 | Claude_error of string 19 + | Subdir_not_found of string 20 + | Checkout_exists of Fpath.t 19 21 20 22 let pp_error ppf = function 21 23 | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg ··· 27 29 pkgs 28 30 | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name 29 31 | Claude_error msg -> Fmt.pf ppf "Claude error: %s" msg 32 + | Subdir_not_found name -> Fmt.pf ppf "Subdirectory not found: %s" name 33 + | Checkout_exists path -> Fmt.pf ppf "Checkout already exists: %a" Fpath.pp path 30 34 31 35 let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t = 32 36 let dir, _ = fs in ··· 831 835 end 832 836 else Ok () 833 837 end 838 + end 839 + 840 + let create_opam_package ~fs ~config ~name ~repo_url = 841 + let opam_repo = Config.Paths.opam_repo config in 842 + let pkg_dir = Fpath.(opam_repo / "packages" / name / (name ^ ".dev")) in 843 + let opam_file = Fpath.(pkg_dir / "opam") in 844 + let content = Printf.sprintf {|opam-version: "2.0" 845 + name: "%s" 846 + version: "dev" 847 + synopsis: "TODO: Add synopsis" 848 + dev-repo: "git+%s" 849 + depends: [ 850 + "dune" {>= "3.0"} 851 + "ocaml" {>= "4.14"} 852 + ] 853 + build: [ 854 + ["dune" "build" "-p" name "-j" jobs] 855 + ] 856 + |} name repo_url in 857 + let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in 858 + mkdirs pkg_dir_eio; 859 + let opam_eio = Eio.Path.(fs / Fpath.to_string opam_file) in 860 + Eio.Path.save ~create:(`Or_truncate 0o644) opam_eio content; 861 + Log.app (fun m -> m "Created opam package at %a" Fpath.pp opam_file); 862 + Ok () 863 + 864 + let extract ~proc ~fs ~config ~subdir ~repo_url ?branch ?(push = false) 865 + ?(create_opam = false) () = 866 + let ( let* ) r f = Result.bind (Result.map_error (fun e -> Git_error e) r) f in 867 + let fs = fs_typed fs in 868 + let monorepo = Config.Paths.monorepo config in 869 + let checkouts_root = Config.Paths.checkouts config in 870 + let checkout_dir = Fpath.(checkouts_root / subdir) in 871 + let branch = Option.value branch ~default:(Config.default_branch config) in 872 + 873 + (* Validate: subdir exists in monorepo *) 874 + if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix:subdir) then 875 + Error (Subdir_not_found subdir) 876 + else 877 + (* Validate: checkout doesn't already exist *) 878 + let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in 879 + let checkout_exists = 880 + match Eio.Path.kind ~follow:true checkout_eio with 881 + | `Directory -> true | _ -> false | exception _ -> false 882 + in 883 + if checkout_exists then Error (Checkout_exists checkout_dir) 884 + else 885 + (* Validate: monorepo is clean *) 886 + if Git.is_dirty ~proc ~fs monorepo then 887 + Error (Git_error (Git.Dirty_worktree monorepo)) 888 + else begin 889 + ensure_checkouts_dir ~fs ~config; 890 + 891 + (* Step 1: Split the subtree history *) 892 + Log.info (fun m -> m "Splitting subtree history for %s" subdir); 893 + let* split_commit = Git.Subtree.split ~proc ~fs ~repo:monorepo ~prefix:subdir () in 894 + Log.info (fun m -> m "Split commit: %s" split_commit); 895 + 896 + (* Step 2: Create new repo from split *) 897 + Log.info (fun m -> m "Creating checkout at %a" Fpath.pp checkout_dir); 898 + let* () = Git.init ~proc ~fs checkout_dir in 899 + let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in 900 + let monorepo_path = Fpath.to_string monorepo in 901 + 902 + (* Fetch split commit from monorepo *) 903 + let* _ = run_git_in ~proc ~cwd:checkout_eio 904 + [ "fetch"; monorepo_path; split_commit ] in 905 + let* _ = run_git_in ~proc ~cwd:checkout_eio 906 + [ "checkout"; "-b"; branch; "FETCH_HEAD" ] in 907 + 908 + (* Step 3: Add origin remote *) 909 + Log.info (fun m -> m "Adding remote origin: %s" repo_url); 910 + let* _ = run_git_in ~proc ~cwd:checkout_eio 911 + [ "remote"; "add"; "origin"; repo_url ] in 912 + 913 + (* Step 4: Optionally push *) 914 + let push_result = 915 + if push then begin 916 + Log.info (fun m -> m "Pushing to %s" repo_url); 917 + Git.push_remote ~proc ~fs ~branch checkout_dir 918 + |> Result.map_error (fun e -> Git_error e) 919 + end else Ok () 920 + in 921 + match push_result with 922 + | Error e -> Error e 923 + | Ok () -> 924 + 925 + (* Step 5: Optionally create opam metadata *) 926 + let create_opam_result = 927 + if create_opam then 928 + create_opam_package ~fs ~config ~name:subdir ~repo_url 929 + else Ok () 930 + in 931 + match create_opam_result with 932 + | Error e -> Error e 933 + | Ok () -> 934 + 935 + (* Print summary *) 936 + Log.app (fun m -> m "Extracted %s to %a" subdir Fpath.pp checkout_dir); 937 + Log.app (fun m -> m ""); 938 + Log.app (fun m -> m "Next steps:"); 939 + if not push then begin 940 + Log.app (fun m -> m " 1. Create the remote repository"); 941 + Log.app (fun m -> m " 2. Push: cd %a && git push -u origin %s" 942 + Fpath.pp checkout_dir branch) 943 + end; 944 + if not create_opam then 945 + Log.app (fun m -> m " 3. Add opam package metadata to enable push/pull"); 946 + Ok () 834 947 end 835 948 836 949 let add ~proc ~fs ~config ~package () =
+34
lib/monopam.mli
··· 40 40 (** Operation blocked due to dirty packages *) 41 41 | Package_not_found of string (** Named package not found in opam repo *) 42 42 | Claude_error of string (** Claude API or response parsing error *) 43 + | Subdir_not_found of string (** Subdirectory not found in monorepo *) 44 + | Checkout_exists of Fpath.t (** Checkout already exists at path *) 43 45 44 46 val pp_error : error Fmt.t 45 47 (** [pp_error] formats errors. *) ··· 107 109 @param config Monopam configuration 108 110 @param package Optional specific package to push 109 111 @param upstream If true, also push checkouts to their git remotes *) 112 + 113 + (** {2 Extract} *) 114 + 115 + val extract : 116 + proc:_ Eio.Process.mgr -> 117 + fs:Eio.Fs.dir_ty Eio.Path.t -> 118 + config:Config.t -> 119 + subdir:string -> 120 + repo_url:string -> 121 + ?branch:string -> 122 + ?push:bool -> 123 + ?create_opam:bool -> 124 + unit -> 125 + (unit, error) result 126 + (** [extract ~proc ~fs ~config ~subdir ~repo_url ()] extracts a subdirectory 127 + from the monorepo as a standalone git repository with full history. 128 + 129 + Enables the "develop in monorepo first, extract later" workflow. 130 + 131 + The extraction process: 132 + 1. Runs git subtree split to extract commits affecting the subdirectory 133 + 2. Creates a new git repository in the checkouts directory 134 + 3. Configures the remote URL 135 + 136 + @param proc Eio process manager 137 + @param fs Eio filesystem 138 + @param config Monopam configuration 139 + @param subdir Subdirectory in monorepo to extract 140 + @param repo_url Git URL for the new repository 141 + @param branch Branch name (default: from config) 142 + @param push If true, push to remote after extraction 143 + @param create_opam If true, create opam package metadata in overlay *) 110 144 111 145 (** {2 Package Management} *) 112 146