Monorepo management for opam overlays

Add README.md generation and .gitignore to monorepo on pull

- Generate README.md with package index table after each pull
- README groups packages by repository with name, version, upstream URL
- Only updates README if content changed (avoids unnecessary commits)
- Add .gitignore with _build and *.install on init/pull if missing

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

+100 -2
+100 -2
lib/monopam.ml
··· 109 109 Git.merge_ff ~proc ~fs ~branch checkout_dir 110 110 end 111 111 112 + (* Group packages by their repository *) 113 + let group_by_repo pkgs = 114 + let tbl = Hashtbl.create 16 in 115 + List.iter (fun pkg -> 116 + let repo = Package.repo_name pkg in 117 + let existing = try Hashtbl.find tbl repo with Not_found -> [] in 118 + Hashtbl.replace tbl repo (pkg :: existing) 119 + ) pkgs; 120 + (* Sort repos alphabetically and packages within each repo *) 121 + Hashtbl.fold (fun repo pkgs acc -> (repo, List.sort Package.compare pkgs) :: acc) tbl [] 122 + |> List.sort (fun (a, _) (b, _) -> String.compare a b) 123 + 124 + (* Generate README.md content from discovered packages *) 125 + let generate_readme pkgs = 126 + let grouped = group_by_repo pkgs in 127 + let buf = Buffer.create 4096 in 128 + Buffer.add_string buf "# Monorepo Package Index\n\n"; 129 + Buffer.add_string buf "This monorepo contains the following packages, synchronized from their upstream repositories.\n\n"; 130 + Buffer.add_string buf "| Repository | Package | Version | Upstream |\n"; 131 + Buffer.add_string buf "|------------|---------|---------|----------|\n"; 132 + List.iter (fun (repo, pkgs) -> 133 + List.iteri (fun i pkg -> 134 + let repo_cell = if i = 0 then Printf.sprintf "**%s**" repo else "" in 135 + let dev_repo = Uri.to_string (Package.dev_repo pkg) in 136 + (* Clean up git+ prefix for display *) 137 + let display_url = 138 + if String.length dev_repo > 4 && String.sub dev_repo 0 4 = "git+" then 139 + String.sub dev_repo 4 (String.length dev_repo - 4) 140 + else dev_repo 141 + in 142 + Buffer.add_string buf (Printf.sprintf "| %s | %s | %s | %s |\n" 143 + repo_cell (Package.name pkg) (Package.version pkg) display_url) 144 + ) pkgs 145 + ) grouped; 146 + Buffer.add_string buf "\n---\n\n"; 147 + Buffer.add_string buf (Printf.sprintf "_Generated by monopam. %d packages from %d repositories._\n" 148 + (List.length pkgs) (List.length grouped)); 149 + Buffer.contents buf 150 + 112 151 let claude_md_content = {|# Monorepo Development Guide 113 152 114 153 This is a monorepo managed by `monopam`. Each subdirectory is a git subtree ··· 165 204 commit, then the next pull will succeed. 166 205 |} 167 206 207 + let gitignore_content = {|_build 208 + *.install 209 + |} 210 + 168 211 let ensure_monorepo_initialized ~proc ~fs ~config = 169 212 let monorepo = Config.Paths.monorepo config in 170 213 let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in ··· 181 224 let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in 182 225 Log.debug (fun m -> m "Creating CLAUDE.md"); 183 226 Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content; 227 + (* Create .gitignore *) 228 + let gitignore = Eio.Path.(monorepo_eio / ".gitignore") in 229 + Log.debug (fun m -> m "Creating .gitignore"); 230 + Eio.Path.save ~create:(`Or_truncate 0o644) gitignore gitignore_content; 184 231 (* Stage the files *) 185 232 Log.debug (fun m -> m "Staging initial files"); 186 233 Eio.Switch.run (fun sw -> 187 234 let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 188 - [ "git"; "add"; "dune-project"; "CLAUDE.md" ] in 235 + [ "git"; "add"; "dune-project"; "CLAUDE.md"; ".gitignore" ] in 189 236 ignore (Eio.Process.await child)); 190 237 (* Commit *) 191 238 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 239 + match Git.commit_allow_empty ~proc ~fs ~message:"Initial commit with dune-project, CLAUDE.md, and .gitignore" monorepo with 193 240 | Ok () -> Ok () 194 241 | Error e -> Error (Git_error e) 195 242 in ··· 214 261 ignore (Eio.Process.await child)) 215 262 end 216 263 in 264 + let ensure_gitignore () = 265 + let gitignore = Eio.Path.(monorepo_eio / ".gitignore") in 266 + let exists = 267 + match Eio.Path.kind ~follow:true gitignore with 268 + | `Regular_file -> true 269 + | _ -> false 270 + | exception Eio.Io _ -> false 271 + in 272 + if not exists then begin 273 + Log.info (fun m -> m "Adding .gitignore to monorepo"); 274 + Eio.Path.save ~create:(`Or_truncate 0o644) gitignore gitignore_content; 275 + Eio.Switch.run (fun sw -> 276 + let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 277 + [ "git"; "add"; ".gitignore" ] in 278 + ignore (Eio.Process.await child)); 279 + Eio.Switch.run (fun sw -> 280 + let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 281 + [ "git"; "commit"; "-m"; "Add .gitignore" ] in 282 + ignore (Eio.Process.await child)) 283 + end 284 + in 217 285 let is_directory = 218 286 match Eio.Path.kind ~follow:true monorepo_eio with 219 287 | `Directory -> true ··· 223 291 if is_directory && Git.is_repo ~proc ~fs monorepo then begin 224 292 Log.debug (fun m -> m "Monorepo already initialized at %a" Fpath.pp monorepo); 225 293 ensure_claude_md (); 294 + ensure_gitignore (); 226 295 Ok () 227 296 end else begin 228 297 if not is_directory then begin ··· 230 299 mkdirs monorepo_eio 231 300 end; 232 301 init_and_commit () 302 + end 303 + 304 + (* Write README.md to monorepo with package summary *) 305 + let write_readme ~proc ~fs ~config pkgs = 306 + let monorepo = Config.Paths.monorepo config in 307 + let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 308 + let readme_path = Eio.Path.(monorepo_eio / "README.md") in 309 + let content = generate_readme pkgs in 310 + (* Check if README already exists with same content *) 311 + let needs_update = 312 + match Eio.Path.load readme_path with 313 + | existing -> existing <> content 314 + | exception Eio.Io _ -> true 315 + in 316 + if needs_update then begin 317 + Log.info (fun m -> m "Updating README.md in monorepo"); 318 + Eio.Path.save ~create:(`Or_truncate 0o644) readme_path content; 319 + (* Stage and commit the README *) 320 + Eio.Switch.run (fun sw -> 321 + let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 322 + [ "git"; "add"; "README.md" ] in 323 + ignore (Eio.Process.await child)); 324 + Eio.Switch.run (fun sw -> 325 + let child = Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 326 + [ "git"; "commit"; "-m"; "Update README.md package index" ] in 327 + ignore (Eio.Process.await child)); 328 + Log.app (fun m -> m "Updated README.md with %d packages" (List.length pkgs)) 233 329 end 234 330 235 331 (* Normalize URL for comparison: extract scheme + host + path, strip trailing slashes *) ··· 415 511 Log.app (fun m -> m "All %d repositories up to date." (List.length results)) 416 512 else if unchanged > 0 then 417 513 Log.app (fun m -> m "%d repositories unchanged." unchanged); 514 + (* Update README.md with package summary *) 515 + write_readme ~proc ~fs:fs_t ~config all_pkgs; 418 516 Ok () 419 517 end 420 518 end