A monorepo management tool for the agentic ages

Add monorepo export command for standalone buildable output

New command `unpac monorepo` creates a standalone directory containing all
projects and their vendored dependencies, suitable for building with dune.
No git history is included - only the current state of each branch.

Features:
- Exports all projects from project/* branches
- Exports vendored opam packages from opam/patches/* branches
- Exports vendored git repos from git-repos/patches/* branches
- Generates root dune-project and dune files
- Creates unified vendor/ directory with (vendored_dirs opam git)
- Strips vendor/ from individual projects (uses shared root vendor/)

Options:
- -p/--project: Export specific projects (can repeat)
- --no-opam: Exclude vendored opam packages
- --no-git: Exclude vendored git repositories

Usage:
unpac monorepo /path/to/output
cd /path/to/output && dune build
cd /path/to/output && dune build @doc

๐Ÿค– Generated with [Claude Code](https://claude.ai/code)

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

+455 -1
+71 -1
bin/main.ml
··· 3009 3009 let info = Cmd.info "status" ~doc ~man in 3010 3010 Cmd.v info Term.(const run $ const () $ short_flag $ no_readme_flag $ verbose_flag) 3011 3011 3012 + (* Monorepo export command *) 3013 + let monorepo_cmd = 3014 + let doc = "Export a standalone buildable monorepo." in 3015 + let man = [ 3016 + `S Manpage.s_description; 3017 + `P "Creates a standalone directory containing all projects and their \ 3018 + vendored dependencies, suitable for building with dune. No git history \ 3019 + is included - only the current state of each branch."; 3020 + `S "OUTPUT STRUCTURE"; 3021 + `Pre " output/ 3022 + โ”œโ”€โ”€ dune-project 3023 + โ”œโ”€โ”€ dune 3024 + โ”œโ”€โ”€ project1/ 3025 + โ”‚ โ”œโ”€โ”€ src/ 3026 + โ”‚ โ””โ”€โ”€ dune 3027 + โ”œโ”€โ”€ project2/ 3028 + โ”‚ โ””โ”€โ”€ ... 3029 + โ””โ”€โ”€ vendor/ 3030 + โ”œโ”€โ”€ opam/ 3031 + โ”‚ โ”œโ”€โ”€ pkg1/ 3032 + โ”‚ โ””โ”€โ”€ pkg2/ 3033 + โ””โ”€โ”€ git/ 3034 + โ””โ”€โ”€ repo1/"; 3035 + `S Manpage.s_examples; 3036 + `P "Export all projects:"; 3037 + `Pre " unpac monorepo /path/to/output"; 3038 + `P "Export specific projects:"; 3039 + `Pre " unpac monorepo -p myapp -p mylib /path/to/output"; 3040 + `P "Export without opam packages:"; 3041 + `Pre " unpac monorepo --no-opam /path/to/output"; 3042 + ] in 3043 + let output_arg = 3044 + let doc = "Output directory for the monorepo." in 3045 + Arg.(required & pos 0 (some string) None & info [] ~docv:"OUTPUT" ~doc) 3046 + in 3047 + let projects_arg = 3048 + let doc = "Specific projects to include (can be repeated). Default: all projects." in 3049 + Arg.(value & opt_all string [] & info ["p"; "project"] ~docv:"NAME" ~doc) 3050 + in 3051 + let no_opam_arg = 3052 + let doc = "Exclude vendored opam packages." in 3053 + Arg.(value & flag & info ["no-opam"] ~doc) 3054 + in 3055 + let no_git_arg = 3056 + let doc = "Exclude vendored git repositories." in 3057 + Arg.(value & flag & info ["no-git"] ~doc) 3058 + in 3059 + let run () output_dir projects no_opam no_git = 3060 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 3061 + let config : Unpac.Monorepo.export_config = { 3062 + output_dir; 3063 + projects = if projects = [] then None else Some projects; 3064 + include_opam = not no_opam; 3065 + include_git = not no_git; 3066 + } in 3067 + let result = Unpac.Monorepo.export ~proc_mgr ~root ~config in 3068 + Format.printf "@.Monorepo exported to %s@." result.output_path; 3069 + Format.printf "@.Contents:@."; 3070 + Format.printf " Projects: %s@." (String.concat ", " result.projects_exported); 3071 + if result.opam_packages <> [] then 3072 + Format.printf " Opam packages: %d@." (List.length result.opam_packages); 3073 + if result.git_repos <> [] then 3074 + Format.printf " Git repos: %d@." (List.length result.git_repos); 3075 + Format.printf "@.Build with:@."; 3076 + Format.printf " cd %s && dune build@." output_dir; 3077 + Format.printf " cd %s && dune build @doc@." output_dir 3078 + in 3079 + let info = Cmd.info "monorepo" ~doc ~man in 3080 + Cmd.v info Term.(const run $ logging_term $ output_arg $ projects_arg $ no_opam_arg $ no_git_arg) 3081 + 3012 3082 (* Main command *) 3013 3083 let main_cmd = 3014 3084 let doc = "Multi-backend vendoring tool using git worktrees." in ··· 3038 3108 ] in 3039 3109 let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in 3040 3110 Cmd.group info [init_cmd; status_cmd; project_cmd; opam_cmd; git_cmd; vendor_cmd; push_cmd; log_cmd; 3041 - export_cmd; export_set_remote_cmd; export_push_cmd; export_list_cmd] 3111 + export_cmd; export_set_remote_cmd; export_push_cmd; export_list_cmd; monorepo_cmd] 3042 3112 3043 3113 let () = exit (Cmd.eval main_cmd)
+304
lib/monorepo.ml
··· 1 + (** Monorepo export: create a standalone buildable directory from unpac workspace. 2 + 3 + Combines all projects and their vendored dependencies into a single directory 4 + structure suitable for building with dune. No git history is included. *) 5 + 6 + let src = Logs.Src.create "unpac.monorepo" ~doc:"Monorepo export" 7 + module Log = (val Logs.src_log src : Logs.LOG) 8 + 9 + type export_config = { 10 + output_dir : string; 11 + projects : string list option; (** None = all projects *) 12 + include_opam : bool; 13 + include_git : bool; 14 + } 15 + 16 + type export_result = { 17 + projects_exported : string list; 18 + opam_packages : string list; 19 + git_repos : string list; 20 + output_path : string; 21 + } 22 + 23 + let default_config ~output_dir = { 24 + output_dir; 25 + projects = None; 26 + include_opam = true; 27 + include_git = true; 28 + } 29 + 30 + (* Copy a directory tree recursively, excluding .git and _build *) 31 + let rec copy_tree ~src ~dst = 32 + if Sys.is_directory src then begin 33 + if not (Sys.file_exists dst) then 34 + Unix.mkdir dst 0o755; 35 + let entries = Sys.readdir src in 36 + Array.iter (fun name -> 37 + if name <> ".git" && name <> "_build" then begin 38 + let src_path = Filename.concat src name in 39 + let dst_path = Filename.concat dst name in 40 + copy_tree ~src:src_path ~dst:dst_path 41 + end 42 + ) entries 43 + end else begin 44 + (* Copy file *) 45 + let content = In_channel.with_open_bin src In_channel.input_all in 46 + Out_channel.with_open_bin dst (fun oc -> 47 + Out_channel.output_string oc content) 48 + end 49 + 50 + (* Remove a directory tree recursively *) 51 + let rec remove_tree path = 52 + if Sys.file_exists path then begin 53 + if Sys.is_directory path then begin 54 + Array.iter (fun name -> 55 + remove_tree (Filename.concat path name) 56 + ) (Sys.readdir path); 57 + Unix.rmdir path 58 + end else 59 + Sys.remove path 60 + end 61 + 62 + (* Export files from a git branch to a directory (without git history) *) 63 + let export_branch_to_dir ~proc_mgr ~git_dir ~branch ~output_dir = 64 + Log.info (fun m -> m "Exporting branch %s to %s" branch output_dir); 65 + let temp_dir = Filename.temp_dir "unpac-export" "" in 66 + begin 67 + try 68 + (* Check if branch exists *) 69 + let branch_exists = 70 + match Git.rev_parse ~proc_mgr ~cwd:git_dir branch with 71 + | Some _ -> true 72 + | None -> false 73 + in 74 + if not branch_exists then begin 75 + Log.warn (fun m -> m "Branch %s does not exist, skipping" branch); 76 + false 77 + end else begin 78 + (* Create temporary worktree *) 79 + Git.run_exn ~proc_mgr ~cwd:git_dir 80 + ["worktree"; "add"; "--detach"; temp_dir; branch] 81 + |> ignore; 82 + (* Copy files to output *) 83 + if not (Sys.file_exists output_dir) then 84 + Unix.mkdir output_dir 0o755; 85 + copy_tree ~src:temp_dir ~dst:output_dir; 86 + (* Remove worktree *) 87 + Git.run_exn ~proc_mgr ~cwd:git_dir 88 + ["worktree"; "remove"; "--force"; temp_dir] 89 + |> ignore; 90 + true 91 + end 92 + with exn -> 93 + (* Clean up on error *) 94 + (try 95 + Git.run_exn ~proc_mgr ~cwd:git_dir 96 + ["worktree"; "remove"; "--force"; temp_dir] 97 + |> ignore 98 + with _ -> ()); 99 + (try remove_tree temp_dir with _ -> ()); 100 + raise exn 101 + end 102 + 103 + (* Export a project, stripping its vendor/ directory *) 104 + let export_project ~proc_mgr ~git_dir ~project ~output_dir = 105 + let branch = "project/" ^ project in 106 + let project_dir = Filename.concat output_dir project in 107 + Log.info (fun m -> m "Exporting project %s" project); 108 + 109 + if export_branch_to_dir ~proc_mgr ~git_dir ~branch ~output_dir:project_dir then begin 110 + (* Remove the vendor/ directory from exported project - deps go in root vendor/ *) 111 + let vendor_dir = Filename.concat project_dir "vendor" in 112 + if Sys.file_exists vendor_dir then begin 113 + Log.info (fun m -> m "Removing vendor/ from project %s (will use root vendor/)" project); 114 + remove_tree vendor_dir 115 + end; 116 + true 117 + end else 118 + false 119 + 120 + (* Export an opam package from patches branch *) 121 + let export_opam_package ~proc_mgr ~git_dir ~package ~vendor_dir = 122 + let branch = "opam/patches/" ^ package in 123 + let package_dir = Filename.concat (Filename.concat vendor_dir "opam") package in 124 + Log.info (fun m -> m "Exporting opam package %s" package); 125 + export_branch_to_dir ~proc_mgr ~git_dir ~branch ~output_dir:package_dir 126 + 127 + (* Export a git repo from patches branch *) 128 + let export_git_repo ~proc_mgr ~git_dir ~repo ~vendor_dir = 129 + let branch = "git-repos/patches/" ^ repo in 130 + let repo_dir = Filename.concat (Filename.concat vendor_dir "git") repo in 131 + Log.info (fun m -> m "Exporting git repo %s" repo); 132 + export_branch_to_dir ~proc_mgr ~git_dir ~branch ~output_dir:repo_dir 133 + 134 + (* Generate root dune-project file *) 135 + let generate_dune_project ~output_dir ~projects = 136 + let content = Printf.sprintf 137 + {|(lang dune 3.0) 138 + (name unpac-monorepo) 139 + 140 + ; Combined monorepo from unpac workspace 141 + ; Projects: %s 142 + 143 + (generate_opam_files false) 144 + |} 145 + (String.concat ", " projects) 146 + in 147 + let path = Filename.concat output_dir "dune-project" in 148 + Out_channel.with_open_bin path (fun oc -> 149 + Out_channel.output_string oc content) 150 + 151 + (* Generate root dune file with vendored_dirs and includes *) 152 + let generate_root_dune ~output_dir ~projects ~has_opam ~has_git = 153 + let vendor_stanzas = 154 + if has_opam || has_git then 155 + "(vendored_dirs vendor)\n" 156 + else "" 157 + in 158 + (* Simple root dune - projects are subdirectories *) 159 + let content = Printf.sprintf 160 + {|; Root dune file for unpac monorepo 161 + ; Auto-generated - do not edit 162 + ; Projects: %s 163 + 164 + %s|} 165 + (String.concat ", " projects) 166 + vendor_stanzas 167 + in 168 + let path = Filename.concat output_dir "dune" in 169 + Out_channel.with_open_bin path (fun oc -> 170 + Out_channel.output_string oc content) 171 + 172 + (* Generate vendor/dune file *) 173 + let generate_vendor_dune ~vendor_dir ~has_opam ~has_git = 174 + let subdirs = 175 + (if has_opam then ["opam"] else []) @ 176 + (if has_git then ["git"] else []) 177 + in 178 + if subdirs <> [] then begin 179 + let content = Printf.sprintf 180 + {|; Vendor dune file for unpac monorepo 181 + (vendored_dirs %s) 182 + |} 183 + (String.concat " " subdirs) 184 + in 185 + let path = Filename.concat vendor_dir "dune" in 186 + Out_channel.with_open_bin path (fun oc -> 187 + Out_channel.output_string oc content) 188 + end 189 + 190 + (* Update project dune files to reference parent vendor/ *) 191 + let update_project_dune ~project_dir = 192 + let dune_path = Filename.concat project_dir "dune" in 193 + if Sys.file_exists dune_path then begin 194 + let content = In_channel.with_open_bin dune_path In_channel.input_all in 195 + (* Remove local vendored_dirs since we use root-level vendor/ *) 196 + if String.length content > 0 then begin 197 + let lines = String.split_on_char '\n' content in 198 + let filtered = List.filter (fun line -> 199 + let trimmed = String.trim line in 200 + not (String.length trimmed >= 14 && 201 + String.sub trimmed 0 14 = "(vendored_dirs") 202 + ) lines in 203 + let updated = String.concat "\n" filtered in 204 + Out_channel.with_open_bin dune_path (fun oc -> 205 + Out_channel.output_string oc updated) 206 + end 207 + end 208 + 209 + (* Main export function *) 210 + let export ~proc_mgr ~root ~config = 211 + let git_dir = Worktree.git_dir root in 212 + 213 + (* Create output directory *) 214 + if not (Sys.file_exists config.output_dir) then 215 + Unix.mkdir config.output_dir 0o755; 216 + 217 + (* Get list of projects to export *) 218 + let all_projects = Worktree.list_projects ~proc_mgr root in 219 + let projects = match config.projects with 220 + | Some ps -> List.filter (fun p -> List.mem p all_projects) ps 221 + | None -> all_projects 222 + in 223 + 224 + if projects = [] then begin 225 + Log.warn (fun m -> m "No projects to export"); 226 + { projects_exported = []; opam_packages = []; git_repos = []; 227 + output_path = config.output_dir } 228 + end else begin 229 + Log.info (fun m -> m "Exporting %d projects: %s" 230 + (List.length projects) (String.concat ", " projects)); 231 + 232 + (* Export each project *) 233 + let exported_projects = List.filter_map (fun project -> 234 + if export_project ~proc_mgr ~git_dir ~project ~output_dir:config.output_dir then 235 + Some project 236 + else 237 + None 238 + ) projects in 239 + 240 + (* Create vendor directory *) 241 + let vendor_dir = Filename.concat config.output_dir "vendor" in 242 + if not (Sys.file_exists vendor_dir) then 243 + Unix.mkdir vendor_dir 0o755; 244 + 245 + (* Export opam packages *) 246 + let opam_packages = 247 + if config.include_opam then begin 248 + let all_opam = Worktree.list_opam_packages ~proc_mgr root in 249 + Log.info (fun m -> m "Exporting %d opam packages" (List.length all_opam)); 250 + (* Create opam subdirectory *) 251 + let opam_dir = Filename.concat vendor_dir "opam" in 252 + if not (Sys.file_exists opam_dir) && all_opam <> [] then 253 + Unix.mkdir opam_dir 0o755; 254 + List.filter_map (fun pkg -> 255 + if export_opam_package ~proc_mgr ~git_dir ~package:pkg ~vendor_dir then 256 + Some pkg 257 + else 258 + None 259 + ) all_opam 260 + end else [] 261 + in 262 + 263 + (* Export git repos *) 264 + let git_repos = 265 + if config.include_git then begin 266 + let all_git = Git_backend.list_repos ~proc_mgr ~root in 267 + Log.info (fun m -> m "Exporting %d git repos" (List.length all_git)); 268 + (* Create git subdirectory *) 269 + let git_subdir = Filename.concat vendor_dir "git" in 270 + if not (Sys.file_exists git_subdir) && all_git <> [] then 271 + Unix.mkdir git_subdir 0o755; 272 + List.filter_map (fun repo -> 273 + if export_git_repo ~proc_mgr ~git_dir ~repo ~vendor_dir then 274 + Some repo 275 + else 276 + None 277 + ) all_git 278 + end else [] 279 + in 280 + 281 + (* Generate dune files *) 282 + let has_opam = opam_packages <> [] in 283 + let has_git = git_repos <> [] in 284 + 285 + generate_dune_project ~output_dir:config.output_dir ~projects:exported_projects; 286 + generate_root_dune ~output_dir:config.output_dir ~projects:exported_projects 287 + ~has_opam ~has_git; 288 + 289 + if has_opam || has_git then 290 + generate_vendor_dune ~vendor_dir ~has_opam ~has_git; 291 + 292 + (* Update project dune files *) 293 + List.iter (fun project -> 294 + let project_dir = Filename.concat config.output_dir project in 295 + update_project_dune ~project_dir 296 + ) exported_projects; 297 + 298 + Log.info (fun m -> m "Monorepo export complete: %s" config.output_dir); 299 + 300 + { projects_exported = exported_projects; 301 + opam_packages; 302 + git_repos; 303 + output_path = config.output_dir } 304 + end
+79
lib/monorepo.mli
··· 1 + (** Monorepo export: create a standalone buildable directory from unpac workspace. 2 + 3 + Combines all projects and their vendored dependencies into a single directory 4 + structure suitable for building with dune. No git history is included. 5 + 6 + {1 Output Structure} 7 + 8 + The exported monorepo has this structure: 9 + {v 10 + output/ 11 + โ”œโ”€โ”€ dune-project # Combined project metadata 12 + โ”œโ”€โ”€ dune # Root dune with vendored_dirs 13 + โ”œโ”€โ”€ project1/ # First project 14 + โ”‚ โ”œโ”€โ”€ src/ 15 + โ”‚ โ”œโ”€โ”€ dune 16 + โ”‚ โ””โ”€โ”€ dune-project 17 + โ”œโ”€โ”€ project2/ # Second project 18 + โ”‚ โ””โ”€โ”€ ... 19 + โ””โ”€โ”€ vendor/ # All vendored dependencies 20 + โ”œโ”€โ”€ dune # (vendored_dirs opam git) 21 + โ”œโ”€โ”€ opam/ 22 + โ”‚ โ”œโ”€โ”€ astring/ 23 + โ”‚ โ”œโ”€โ”€ eio/ 24 + โ”‚ โ””โ”€โ”€ ... 25 + โ””โ”€โ”€ git/ 26 + โ”œโ”€โ”€ mylib/ 27 + โ””โ”€โ”€ ... 28 + v} 29 + 30 + {1 Usage} 31 + 32 + {v 33 + unpac monorepo /path/to/output 34 + unpac monorepo -p myproject /path/to/output # single project 35 + unpac monorepo --no-opam /path/to/output # skip opam packages 36 + v} 37 + 38 + The output can be built directly with [dune build] or [dune build @doc]. 39 + *) 40 + 41 + (** {1 Configuration} *) 42 + 43 + type export_config = { 44 + output_dir : string; (** Target directory for export *) 45 + projects : string list option; (** Projects to include (None = all) *) 46 + include_opam : bool; (** Include vendored opam packages *) 47 + include_git : bool; (** Include vendored git repositories *) 48 + } 49 + 50 + val default_config : output_dir:string -> export_config 51 + (** Create default config exporting all projects and dependencies. *) 52 + 53 + (** {1 Export Result} *) 54 + 55 + type export_result = { 56 + projects_exported : string list; (** Projects that were exported *) 57 + opam_packages : string list; (** Opam packages in vendor/ *) 58 + git_repos : string list; (** Git repos in vendor/ *) 59 + output_path : string; (** Path to output directory *) 60 + } 61 + 62 + (** {1 Export Function} *) 63 + 64 + val export : 65 + proc_mgr:Git.proc_mgr -> 66 + root:Worktree.root -> 67 + config:export_config -> 68 + export_result 69 + (** [export ~proc_mgr ~root ~config] creates a standalone monorepo. 70 + 71 + The function: 72 + 1. Exports each project from its [project/<name>] branch 73 + 2. Strips the [vendor/] directory from each project 74 + 3. Exports all vendored opam packages from [opam/patches/*] branches 75 + 4. Exports all vendored git repos from [git-repos/patches/*] branches 76 + 5. Places dependencies in a shared [vendor/] directory 77 + 6. Generates appropriate dune files for building 78 + 79 + No git history is preserved - only the current state of each branch. *)
+1
lib/unpac.ml
··· 12 12 module Audit = Audit 13 13 module Git_backend = Git_backend 14 14 module Promote = Promote 15 + module Monorepo = Monorepo