My own corner of monopam
2
fork

Configure Feed

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

monopam: unify root-file generation in new Root module

Replace the two duplicate dune-project generators (Init.write_* and
Import.update_root_deps) with a single Monopam.Root module that
computes and regenerates the four monorepo root files — dune-project,
README.md, llms.txt, CLAUDE.md — with one git commit per run, only
when files actually change.

Identity-preserving dune-project: regenerated from scratch but the
top-level (name ...) and root package (maintainers ...) / (authors
...) are carried forward when set, guessed from the global git user
otherwise. Lang version bumps from 3.20 to 3.21. Root.opam is no
longer written explicitly — dune handles it via (generate_opam_files
true). Added Root.check for dry-run diffs, surfaced through Lint.

+881 -566
+2
monopam/README.md
··· 22 22 23 23 Install with opam: 24 24 25 + <!-- $MDX skip --> 25 26 ```sh 26 27 $ opam install monopam 27 28 ``` ··· 29 30 If opam cannot find the package, it may not yet be released in the public 30 31 `opam-repository`. Add the overlay repository, then install it: 31 32 33 + <!-- $MDX skip --> 32 34 ```sh 33 35 $ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git 34 36 $ opam update
+4 -2
monopam/bin/cmd_init.ml
··· 92 92 [monopam init] left the workspace without guidance until the 93 93 user ran pull. Now init is self-contained. *) 94 94 Monopam.Init.bootstrap_files ~fs ~target; 95 - (* Step 5: Regenerate root deps (dune-project + root.opam) *) 96 - Monopam.Import.update_root_deps ~fs ~target; 95 + (* Step 5: Regenerate root files (dune-project, README, llms, CLAUDE) *) 96 + let (_ : string list) = 97 + Monopam.Root.regenerate ~sw ~fs ~monorepo:target () 98 + in 97 99 let elapsed = Unix.gettimeofday () -. t0 in 98 100 Common.print_success ~elapsed 99 101 ~next_step:"monopam add <git-url> # or: monopam pull"
+8 -2
monopam/bin/cmd_lint.ml
··· 174 174 Common.with_config env @@ fun config -> 175 175 let fs = Eio.Stdenv.fs env in 176 176 let monorepo = Monopam.Config.Paths.monorepo config in 177 - let { Monopam.Lint.issues; source_issues; packages_scanned } = 177 + let { Monopam.Lint.issues; source_issues; root_diffs; packages_scanned } = 178 178 Monopam.Lint.run ~fs:(fs :> Eio.Fs.dir_ty Eio.Path.t) ~monorepo () 179 179 in 180 180 let issues = filter_dep_issues filter issues in 181 181 let source_issues = filter_source_issues filter source_issues in 182 182 let label = scanned_label filter packages_scanned in 183 - if issues = [] && source_issues = [] then ( 183 + if issues = [] && source_issues = [] && root_diffs = [] then ( 184 184 Fmt.pr "%a All checks passed (%s).@." 185 185 Fmt.(styled (`Fg `Green) string) 186 186 "✓" label; 187 187 `Ok ()) 188 188 else ( 189 189 print_issues issues source_issues; 190 + List.iter 191 + (fun d -> 192 + Fmt.pr "%a %s: out of date (run %s)@." 193 + Fmt.(styled (`Fg `Yellow) string) 194 + "!" d.Monopam.Root.file "monopam pull") 195 + root_diffs; 190 196 print_summary ~issues ~source_issues ~label; 191 197 `Ok ()) 192 198
+4 -89
monopam/lib/import.ml
··· 329 329 Ok successes 330 330 end 331 331 332 - let subtree_dirs eio_target = 333 - let subdirs = try Eio.Path.read_dir eio_target with Eio.Io _ -> [] in 334 - List.filter 335 - (fun d -> 336 - d <> "_build" && d <> ".git" && d <> "src" 337 - && 338 - try Eio.Path.kind ~follow:true Eio.Path.(eio_target / d) = `Directory 339 - with Eio.Io _ -> false) 340 - subdirs 341 - 342 - let opam_package_names eio_target subdirs = 343 - List.concat_map 344 - (fun dir -> 345 - let eio_path = Eio.Path.(eio_target / dir) in 346 - try 347 - Eio.Path.read_dir eio_path 348 - |> List.filter_map (fun name -> 349 - if Filename.check_suffix name ".opam" then 350 - Some (Filename.chop_suffix name ".opam") 351 - else None) 352 - with Eio.Io _ -> []) 353 - subdirs 354 - |> List.sort_uniq String.compare 355 - 356 - let generate_dune_project deps = 357 - let buf = Buffer.create 1024 in 358 - Buffer.add_string buf "(lang dune 3.20)\n"; 359 - Buffer.add_string buf "(name root)\n\n"; 360 - Buffer.add_string buf "(generate_opam_files true)\n\n"; 361 - Buffer.add_string buf "(package\n (name root)\n"; 362 - Buffer.add_string buf 363 - " (synopsis \"Monorepo root package with external dependencies\")\n"; 364 - Buffer.add_string buf " (allow_empty)\n (depends\n"; 365 - List.iter (fun dep -> Buffer.add_string buf (Fmt.str " %s\n" dep)) deps; 366 - Buffer.add_string buf " ))\n"; 367 - Buffer.contents buf 368 - 369 - let generate_root_opam deps = 370 - let buf = Buffer.create 1024 in 371 - Buffer.add_string buf 372 - "# This file is generated by dune, edit dune-project instead\n"; 373 - Buffer.add_string buf "opam-version: \"2.0\"\n"; 374 - Buffer.add_string buf 375 - "synopsis: \"Monorepo root package with external dependencies\"\n"; 376 - Buffer.add_string buf "depends: [\n"; 377 - List.iter (fun dep -> Buffer.add_string buf (Fmt.str " \"%s\"\n" dep)) deps; 378 - Buffer.add_string buf "]\n"; 379 - Buffer.contents buf 380 - 381 - (** Update dune-project and root.opam with external dependencies from all 382 - subtrees *) 383 - let update_root_deps ~fs ~target = 384 - let eio_target = Eio.Path.(fs / Fpath.to_string target) in 385 - let subdirs = subtree_dirs eio_target in 386 - let all_deps = 387 - List.concat_map 388 - (fun dir -> Opam_repo.scan_opam_files_for_deps ~fs Fpath.(target / dir)) 389 - subdirs 390 - |> List.sort_uniq String.compare 391 - in 392 - let pkg_names = opam_package_names eio_target subdirs in 393 - let external_deps = 394 - List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps 395 - in 396 - let dune_content = generate_dune_project external_deps in 397 - let opam_content = generate_root_opam external_deps in 398 - let dune_path = Eio.Path.(eio_target / "dune-project") in 399 - let opam_path = Eio.Path.(eio_target / "root.opam") in 400 - let dune_needs_update = 401 - match Eio.Path.load dune_path with 402 - | existing -> existing <> dune_content 403 - | exception Eio.Io _ -> true 404 - in 405 - let opam_needs_update = 406 - match Eio.Path.load opam_path with 407 - | existing -> existing <> opam_content 408 - | exception Eio.Io _ -> true 409 - in 410 - if dune_needs_update || opam_needs_update then begin 411 - Log.info (fun m -> 412 - m "Updating dune-project and root.opam at %a" Fpath.pp target); 413 - Eio.Path.save ~create:(`Or_truncate 0o644) dune_path dune_content; 414 - Eio.Path.save ~create:(`Or_truncate 0o644) opam_path opam_content; 415 - Log.app (fun m -> 416 - m "Updated dune-project with %d external dependencies" 417 - (List.length external_deps)) 418 - end 419 - 420 332 let stage_and_commit_sources ~sw ~fs ~target ~name = 421 333 let git_repo = Git.Repository.open_repo ~sw ~fs target in 422 334 (* After [Git.Subtree.add] the HEAD commit contains the newly-added ··· 472 384 | Ok result -> 473 385 if not dry_run then begin 474 386 update_sources_toml ~sw ~fs ~target ~url ~branch ~path ~result; 475 - update_root_deps ~fs ~target 387 + let (_ : string list) = 388 + Root.regenerate ~sw ~fs ~monorepo:target () 389 + in 390 + () 476 391 end; 477 392 Ok [ result ])
-4
monopam/lib/import.mli
··· 93 93 {!Git.Subtree.split} before being merged at [name]. Returns error if the 94 94 subtree directory already exists. *) 95 95 96 - val update_root_deps : fs:Eio.Fs.dir_ty Eio.Path.t -> target:Fpath.t -> unit 97 - (** [update_root_deps ~fs ~target] scans all subtree directories for .opam 98 - files, computes external dependencies, and regenerates dune-project. *) 99 - 100 96 val timestamp : unit -> string 101 97 (** [timestamp ()] returns the current time in ISO 8601 format. *)
+6 -389
monopam/lib/init.ml
··· 1 - (** Monorepo initialization and file generation. 2 - 3 - Handles creating and updating the monorepo git repository, README.md, 4 - CLAUDE.md, .gitignore, and dune-project files. *) 1 + (** Monorepo initialization. *) 5 2 6 3 let src = Logs.Src.create "monopam.init" ~doc:"Monopam initialization" 7 4 8 5 module Log = (val Logs.src_log src : Logs.LOG) 9 6 10 - (** {1 Content Templates} *) 11 - 12 - let claude_md_header = 13 - {|# Monorepo Development Guide 14 - 15 - This is a monorepo managed by `monopam`. Each subdirectory is a subtree 16 - from a separate upstream repository. You edit everything in one tree, 17 - build with dune, then `pull` and `push` to move changes in and out. 18 - 19 - > **Note:** Check `CLAUDE.local.md` (if it exists) for additional local 20 - > configuration or preferences specific to this workspace. 21 - 22 - ## Quick Reference 23 - 24 - | Task | Command | 25 - |------------------------------------|------------------------------| 26 - | Check status | `monopam status` | 27 - | Fetch upstream changes | `monopam pull` | 28 - | Push changes to your remotes | `monopam push` | 29 - | Export to checkouts only (no push) | `monopam push --local` | 30 - | Operate on one package | `monopam <cmd> <name>` | 31 - | Build | `opam exec -- dune build` | 32 - | Test | `opam exec -- dune test` | 33 - |} 34 - 35 - let claude_md_workflow = 36 - {|## Daily Workflow 37 - 38 - ```bash 39 - # 1. See what needs attention 40 - monopam status 41 - 42 - # 2. Pull latest upstream changes into the monorepo 43 - monopam pull 44 - 45 - # 3. Make your changes anywhere in the tree, build and test 46 - opam exec -- dune build && opam exec -- dune test 47 - 48 - # 4. Commit your changes to the monorepo 49 - git add -A && git commit -m "Description of changes" 50 - 51 - # 5. Send them back to the upstream repos 52 - monopam push 53 - ``` 54 - 55 - `pull` and `push` are the only sync verbs. There is no `sync` command: 56 - pulling first, building, testing, then pushing keeps the two directions 57 - decoupled so you always know what state you're in. 58 - 59 - ## Understanding Status Output 60 - 61 - Run `monopam status` to see the sync state: 62 - 63 - - `local:=` — Monorepo and checkout in sync 64 - - `local:+N` — Monorepo has N commits not in checkout (run `monopam push --local`) 65 - - `local:-N` — Checkout has N commits not in monorepo (run `monopam pull`) 66 - - `local:sync` — Trees differ; run `monopam pull` then `monopam push` to reconcile 67 - - `remote:=` — Checkout and upstream in sync 68 - - `remote:+N` — You have N commits to push (run `monopam push`) 69 - - `remote:-N` — Upstream has N commits to pull (run `monopam pull`) 70 - 71 - ## Making Changes 72 - 73 - 1. **Edit code** in any subdirectory as normal 74 - 2. **Build and test**: `opam exec -- dune build && opam exec -- dune test` 75 - 3. **Commit** your changes: `git add -A && git commit` 76 - 4. **Push**: `monopam push` to send them upstream 77 - 78 - ## Important Notes 79 - 80 - - **Always commit before push**: `monopam push` only exports committed changes 81 - - **Check status first**: Run `monopam status` to see what needs attention 82 - - **One repo per directory**: Each subdirectory maps to exactly one git remote 83 - |} 84 - 85 - let claude_md_troubleshooting = 86 - {|## Troubleshooting 87 - 88 - ### `local:sync` in status 89 - The monorepo subtree and checkout have diverged trees and monopam can't 90 - pick a direction automatically. Pull first, then push: 91 - 92 - ```bash 93 - monopam pull 94 - monopam push 95 - ``` 96 - 97 - ### Merge conflicts after `monopam pull` 98 - Resolve conflicts in `mono/`, then commit and continue: 99 - 100 - ```bash 101 - git add -A && git commit -m "Resolve merge conflicts" 102 - monopam push # only if you want to publish the resolution 103 - ``` 104 - 105 - ### Push fails with non-fast-forward 106 - Another monorepo (or a direct commit) got there first. Pull, rebuild, then 107 - retry: 108 - 109 - ```bash 110 - monopam pull 111 - opam exec -- dune build && opam exec -- dune test 112 - monopam push 113 - ``` 114 - 115 - If the upstream history is intentionally diverged (e.g. after `git 116 - filter-repo`), `monopam push --force` overrides. 117 - 118 - ### A checkout is missing 119 - Usually means `src/<repo>` hasn't been cloned yet. `monopam pull` will 120 - clone missing checkouts as part of its normal flow. 121 - 122 - ## Getting Help 123 - 124 - ```bash 125 - monopam --help # List of all commands 126 - monopam pull --help # Pull command help 127 - monopam push --help # Push command help 128 - monopam status --help # Status command help 129 - ``` 130 - |} 131 - 132 - let claude_md_content = 133 - String.concat "\n" 134 - [ claude_md_header; claude_md_workflow; claude_md_troubleshooting ] 135 - 136 7 let gitignore_content = {|_build 137 8 *.install 138 9 root.opam 139 10 |} 140 11 141 - (** {1 README Generation} *) 142 - 143 - let generate_readme pkgs = 144 - let grouped = Ctx.group_by_repo pkgs in 145 - let buf = Buffer.create 4096 in 146 - Buffer.add_string buf "# Monorepo Package Index\n\n"; 147 - Buffer.add_string buf 148 - "This monorepo contains the following packages, synchronized from their \ 149 - upstream repositories.\n\n"; 150 - Buffer.add_string buf "| Repository | Package | Synopsis |\n"; 151 - Buffer.add_string buf "|------------|---------|----------|\n"; 152 - List.iter 153 - (fun (repo, pkgs) -> 154 - List.iteri 155 - (fun i pkg -> 156 - let dev_repo = Uri.to_string (Package.dev_repo pkg) in 157 - let display_url = 158 - if String.starts_with ~prefix:"git+" dev_repo then 159 - String.sub dev_repo 4 (String.length dev_repo - 4) 160 - else dev_repo 161 - in 162 - let repo_cell = 163 - if i = 0 then Fmt.str "[**%s**](%s)" repo display_url else "" 164 - in 165 - let synopsis = Option.value ~default:"" (Package.synopsis pkg) in 166 - Buffer.add_string buf 167 - (Fmt.str "| %s | %s | %s |\n" repo_cell (Package.name pkg) synopsis)) 168 - pkgs) 169 - grouped; 170 - Buffer.add_string buf "\n---\n\n"; 171 - Buffer.add_string buf 172 - (Fmt.str "_Generated by monopam. %d packages from %d repositories._\n" 173 - (List.length pkgs) (List.length grouped)); 174 - Buffer.contents buf 175 - 176 - (** {1 llms.txt Generation} 177 - 178 - Generates a /llms.txt file at the monorepo root following the spec at 179 - https://llmstxt.org/. This gives LLMs an efficient entry point for 180 - navigating the monorepo: a structured index that links to each package's 181 - README.md via a relative path, so an LLM can fetch only the packages it 182 - needs without pulling the whole catalog into context. *) 183 - 184 - let generate_llms_txt pkgs = 185 - let grouped = Ctx.group_by_repo pkgs in 186 - let buf = Buffer.create 4096 in 187 - Buffer.add_string buf "# Blacksun Monorepo\n\n"; 188 - Buffer.add_string buf 189 - "> OCaml packages for space systems, cryptography, protocols, and monorepo \ 190 - tooling.\n\n"; 191 - Buffer.add_string buf 192 - "This monorepo aggregates OCaml libraries as git subtrees. Each package \ 193 - has its own README with API documentation and usage examples at the \ 194 - linked path.\n\n"; 195 - Buffer.add_string buf "## Packages\n\n"; 196 - List.iter 197 - (fun (repo, pkgs) -> 198 - List.iter 199 - (fun pkg -> 200 - let name = Package.name pkg in 201 - let synopsis = Option.value ~default:"" (Package.synopsis pkg) in 202 - Buffer.add_string buf 203 - (Fmt.str "- [%s](%s/README.md): %s\n" name repo synopsis)) 204 - pkgs) 205 - grouped; 206 - Buffer.add_string buf "\n## Optional\n\n"; 207 - Buffer.add_string buf 208 - "- [CLAUDE.md](CLAUDE.md): Development workflow and monorepo conventions\n"; 209 - Buffer.add_string buf 210 - "- [README.md](README.md): Human-facing package index with upstream repo \ 211 - links\n"; 212 - Buffer.contents buf 213 - 214 - (** {1 dune-project Generation} *) 215 - 216 - let collect_external_deps ~fs ~config pkgs = 217 - let monorepo = Config.Paths.monorepo config in 218 - let seen = Hashtbl.create 16 in 219 - let repos = 220 - List.filter 221 - (fun pkg -> 222 - let repo = Package.repo_name pkg in 223 - if Hashtbl.mem seen repo then false 224 - else begin 225 - Hashtbl.add seen repo (); 226 - true 227 - end) 228 - pkgs 229 - in 230 - let all_deps = 231 - List.concat_map 232 - (fun pkg -> 233 - let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in 234 - Opam_repo.scan_opam_files_for_deps ~fs subtree_dir) 235 - repos 236 - |> List.sort_uniq String.compare 237 - in 238 - let pkg_names = 239 - List.concat_map 240 - (fun pkg -> 241 - let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in 242 - let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in 243 - try 244 - Eio.Path.read_dir eio_path 245 - |> List.filter_map (fun name -> 246 - if Filename.check_suffix name ".opam" then 247 - Some (Filename.chop_suffix name ".opam") 248 - else None) 249 - with Eio.Io _ -> []) 250 - repos 251 - |> List.sort_uniq String.compare 252 - in 253 - List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps 254 - 255 - let generate_dune_project ~fs ~config pkgs = 256 - let external_deps = collect_external_deps ~fs ~config pkgs in 257 - let buf = Buffer.create 1024 in 258 - Buffer.add_string buf "(lang dune 3.20)\n"; 259 - Buffer.add_string buf "(name root)\n"; 260 - Buffer.add_string buf "\n"; 261 - Buffer.add_string buf "(generate_opam_files true)\n"; 262 - Buffer.add_string buf "\n"; 263 - Buffer.add_string buf "(package\n"; 264 - Buffer.add_string buf " (name root)\n"; 265 - Buffer.add_string buf 266 - " (synopsis \"Monorepo root package with external dependencies\")\n"; 267 - Buffer.add_string buf " (allow_empty)\n"; 268 - Buffer.add_string buf " (depends\n"; 269 - List.iter 270 - (fun dep -> Buffer.add_string buf (Fmt.str " %s\n" dep)) 271 - external_deps; 272 - Buffer.add_string buf " ))\n"; 273 - Buffer.contents buf 274 - 275 - (** {1 File Writers} *) 276 - 277 - let write_dune_project ~proc ~fs ~config pkgs = 278 - let monorepo = Config.Paths.monorepo config in 279 - let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 280 - let dune_project_path = Eio.Path.(monorepo_eio / "dune-project") in 281 - let content = generate_dune_project ~fs ~config pkgs in 282 - let needs_update = 283 - match Eio.Path.load dune_project_path with 284 - | existing -> existing <> content 285 - | exception Eio.Io _ -> true 286 - in 287 - if needs_update then begin 288 - Log.info (fun m -> m "Updating dune-project in monorepo"); 289 - Eio.Path.save ~create:(`Or_truncate 0o644) dune_project_path content; 290 - Eio.Switch.run (fun sw -> 291 - let child = 292 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 293 - [ "git"; "add"; "dune-project" ] 294 - in 295 - ignore (Eio.Process.await child)); 296 - Eio.Switch.run (fun sw -> 297 - let child = 298 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 299 - [ 300 - "git"; 301 - "commit"; 302 - "-m"; 303 - "Update dune-project with external dependencies"; 304 - ] 305 - in 306 - ignore (Eio.Process.await child)); 307 - Log.app (fun m -> 308 - m "Updated dune-project with %d external dependencies" 309 - (List.length (collect_external_deps ~fs ~config pkgs))) 310 - end 311 - 312 - let write_readme ~proc ~fs ~config pkgs = 313 - let monorepo = Config.Paths.monorepo config in 314 - let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 315 - let readme_path = Eio.Path.(monorepo_eio / "README.md") in 316 - let content = generate_readme pkgs in 317 - let needs_update = 318 - match Eio.Path.load readme_path with 319 - | existing -> existing <> content 320 - | exception Eio.Io _ -> true 321 - in 322 - if needs_update then begin 323 - Log.info (fun m -> m "Updating README.md in monorepo"); 324 - Eio.Path.save ~create:(`Or_truncate 0o644) readme_path content; 325 - Eio.Switch.run (fun sw -> 326 - let child = 327 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 328 - [ "git"; "add"; "README.md" ] 329 - in 330 - ignore (Eio.Process.await child)); 331 - Eio.Switch.run (fun sw -> 332 - let child = 333 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 334 - [ "git"; "commit"; "-m"; "Update README.md package index" ] 335 - in 336 - ignore (Eio.Process.await child)); 337 - Log.app (fun m -> m "Updated README.md with %d packages" (List.length pkgs)) 338 - end 339 - 340 - let write_llms_txt ~proc ~fs ~config pkgs = 341 - let monorepo = Config.Paths.monorepo config in 342 - let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 343 - let llms_path = Eio.Path.(monorepo_eio / "llms.txt") in 344 - let content = generate_llms_txt pkgs in 345 - let needs_update = 346 - match Eio.Path.load llms_path with 347 - | existing -> existing <> content 348 - | exception Eio.Io _ -> true 349 - in 350 - if needs_update then begin 351 - Log.info (fun m -> m "Updating llms.txt in monorepo"); 352 - Eio.Path.save ~create:(`Or_truncate 0o644) llms_path content; 353 - Eio.Switch.run (fun sw -> 354 - let child = 355 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 356 - [ "git"; "add"; "llms.txt" ] 357 - in 358 - ignore (Eio.Process.await child)); 359 - Eio.Switch.run (fun sw -> 360 - let child = 361 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 362 - [ "git"; "commit"; "-m"; "Update llms.txt package index" ] 363 - in 364 - ignore (Eio.Process.await child)); 365 - Log.app (fun m -> m "Updated llms.txt with %d packages" (List.length pkgs)) 366 - end 367 - 368 - let write_claude_md ~proc ~fs ~config = 369 - let monorepo = Config.Paths.monorepo config in 370 - let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 371 - let claude_path = Eio.Path.(monorepo_eio / "CLAUDE.md") in 372 - let needs_update = 373 - match Eio.Path.load claude_path with 374 - | existing -> existing <> claude_md_content 375 - | exception Eio.Io _ -> true 376 - in 377 - if needs_update then begin 378 - Log.info (fun m -> m "Updating CLAUDE.md in monorepo"); 379 - Eio.Path.save ~create:(`Or_truncate 0o644) claude_path claude_md_content; 380 - Eio.Switch.run (fun sw -> 381 - let child = 382 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 383 - [ "git"; "add"; "CLAUDE.md" ] 384 - in 385 - ignore (Eio.Process.await child)); 386 - Eio.Switch.run (fun sw -> 387 - let child = 388 - Eio.Process.spawn proc ~sw ~cwd:monorepo_eio 389 - [ "git"; "commit"; "-m"; "Update CLAUDE.md usage tips" ] 390 - in 391 - ignore (Eio.Process.await child)); 392 - Log.app (fun m -> m "Updated CLAUDE.md") 393 - end 394 - 395 12 (** {1 Bootstrap Files} 396 13 397 14 Used by [monopam init] before any config exists. Writes CLAUDE.md and ··· 403 20 let claude_path = Eio.Path.(target_eio / "CLAUDE.md") in 404 21 let needs_update = 405 22 match Eio.Path.load claude_path with 406 - | existing -> existing <> claude_md_content 23 + | existing -> existing <> Root.claude_md 407 24 | exception Eio.Io _ -> true 408 25 in 409 26 if needs_update then 410 - Eio.Path.save ~create:(`Or_truncate 0o644) claude_path claude_md_content; 27 + Eio.Path.save ~create:(`Or_truncate 0o644) claude_path Root.claude_md; 411 28 let gitignore_path = Eio.Path.(target_eio / ".gitignore") in 412 29 match Eio.Path.load gitignore_path with 413 30 | _ -> () ··· 422 39 let (_ : Git.Repository.t) = Git.Repository.init ~sw ~fs monorepo in 423 40 let dune_project = Eio.Path.(monorepo_eio / "dune-project") in 424 41 Log.debug (fun m -> m "Creating dune-project file"); 425 - Eio.Path.save ~create:(`Or_truncate 0o644) dune_project "(lang dune 3.20)\n"; 42 + Eio.Path.save ~create:(`Or_truncate 0o644) dune_project "(lang dune 3.21)\n"; 426 43 let claude_md = Eio.Path.(monorepo_eio / "CLAUDE.md") in 427 44 Log.debug (fun m -> m "Creating CLAUDE.md"); 428 - Eio.Path.save ~create:(`Or_truncate 0o644) claude_md claude_md_content; 45 + Eio.Path.save ~create:(`Or_truncate 0o644) claude_md Root.claude_md; 429 46 let gitignore = Eio.Path.(monorepo_eio / ".gitignore") in 430 47 Log.debug (fun m -> m "Creating .gitignore"); 431 48 Eio.Path.save ~create:(`Or_truncate 0o644) gitignore gitignore_content; ··· 489 106 Log.debug (fun m -> 490 107 m "Monorepo already initialized at %a" Fpath.pp monorepo); 491 108 ensure_file ~proc ~monorepo_eio ~filename:"CLAUDE.md" 492 - ~content:claude_md_content; 109 + ~content:Root.claude_md; 493 110 ensure_file ~proc ~monorepo_eio ~filename:".gitignore" 494 111 ~content:gitignore_content; 495 112 Ok ()
+5 -39
monopam/lib/init.mli
··· 1 - (** Monorepo initialization and file generation. 1 + (** Monorepo initialization. 2 2 3 - Handles creating and updating the monorepo git repository, README.md, 4 - CLAUDE.md, .gitignore, and dune-project files. *) 3 + Ensures the monorepo directory is a git repository with a minimal 4 + dune-project, CLAUDE.md, and .gitignore. Does not generate the full 5 + root-file suite — {!Root.regenerate} owns that. *) 5 6 6 7 val ensure : 7 8 sw:Eio.Switch.t -> ··· 15 16 (** [bootstrap_files ~fs ~target] writes CLAUDE.md and .gitignore at [target] if 16 17 they don't already exist. Safe to call from [monopam init] before any config 17 18 exists, and idempotent — an existing CLAUDE.md with the current shipped 18 - content is left alone, and a stale one (e.g. referencing the old 19 - [monopam sync] command) is overwritten. *) 20 - 21 - val write_readme : 22 - proc:_ Eio.Process.mgr -> 23 - fs:Eio.Fs.dir_ty Eio.Path.t -> 24 - config:Config.t -> 25 - Package.t list -> 26 - unit 27 - (** [write_readme ~proc ~fs ~config pkgs] updates README.md if changed. *) 28 - 29 - val write_llms_txt : 30 - proc:_ Eio.Process.mgr -> 31 - fs:Eio.Fs.dir_ty Eio.Path.t -> 32 - config:Config.t -> 33 - Package.t list -> 34 - unit 35 - (** [write_llms_txt ~proc ~fs ~config pkgs] updates llms.txt if changed. Follows 36 - the spec at https://llmstxt.org/ — an LLM-friendly index with relative paths 37 - to each package's README.md. *) 38 - 39 - val write_claude_md : 40 - proc:_ Eio.Process.mgr -> 41 - fs:Eio.Fs.dir_ty Eio.Path.t -> 42 - config:Config.t -> 43 - unit 44 - (** [write_claude_md ~proc ~fs ~config] updates CLAUDE.md if changed. *) 45 - 46 - val write_dune_project : 47 - proc:_ Eio.Process.mgr -> 48 - fs:Eio.Fs.dir_ty Eio.Path.t -> 49 - config:Config.t -> 50 - Package.t list -> 51 - unit 52 - (** [write_dune_project ~proc ~fs ~config pkgs] updates dune-project if changed. 53 - *) 19 + content is left alone, and a stale one is overwritten. *)
+8 -1
monopam/lib/lint.ml
··· 313 313 type result = { 314 314 issues : issue list; 315 315 source_issues : source_issue list; 316 + root_diffs : Root.diff list; 316 317 packages_scanned : int; 317 318 } 318 319 ··· 522 523 pkgs 523 524 end) 524 525 subdirs; 525 - { issues = sort_issues !issues; source_issues; packages_scanned = !scanned } 526 + let root_diffs = Root.check ~fs ~monorepo () in 527 + { 528 + issues = sort_issues !issues; 529 + source_issues; 530 + root_diffs; 531 + packages_scanned = !scanned; 532 + }
+4
monopam/lib/lint.mli
··· 33 33 type result = { 34 34 issues : issue list; (** Dependency issues found *) 35 35 source_issues : source_issue list; (** Source-URL mismatches found *) 36 + root_diffs : Root.diff list; 37 + (** Root files ([dune-project], [README.md], [llms.txt], [CLAUDE.md]) 38 + whose on-disk content differs from what {!Root.regenerate} would 39 + produce. *) 36 40 packages_scanned : int; (** Number of subtrees checked *) 37 41 } 38 42 (** Result of a lint run. *)
+1
monopam/lib/monopam.ml
··· 36 36 37 37 module Ctx = Ctx 38 38 module Init = Init 39 + module Root = Root 39 40 module Pull = Pull 40 41 module Push = Push 41 42 module Add = Add
+1
monopam/lib/monopam.mli
··· 58 58 59 59 module Ctx = Ctx 60 60 module Init = Init 61 + module Root = Root 61 62 module Pull = Pull 62 63 module Push = Push 63 64 module Add = Add
+8
monopam/lib/opam_repo.mli
··· 92 92 val dev_repo : Opam.Value.item list -> string option 93 93 (** [dev_repo items] extracts the dev-repo field from parsed opam file items. *) 94 94 95 + val synopsis : Opam.Value.item list -> string option 96 + (** [synopsis items] extracts the synopsis field from parsed opam file items. *) 97 + 98 + val depends : Opam.Value.item list -> string list 99 + (** [depends items] extracts the list of package names from the [depends] field 100 + of parsed opam file items. Returns the empty list if no depends field is 101 + present. *) 102 + 95 103 (** {1 Writing Packages} *) 96 104 97 105 val read_opam_file : fs:_ Eio.Path.t -> Fpath.t -> (string, error) result
+6 -6
monopam/lib/pull.ml
··· 547 547 inner_conflict_paths @ unresolved 548 548 549 549 (** Write post-pull metadata files or return a conflict error. *) 550 - let finalize_pull ~proc ~fs_t ~config ~all_pkgs unresolved = 550 + let finalize_pull ~sw ~fs_t ~config ~all_pkgs unresolved = 551 551 if unresolved = [] then begin 552 - Init.write_readme ~proc ~fs:fs_t ~config all_pkgs; 553 - Init.write_llms_txt ~proc ~fs:fs_t ~config all_pkgs; 554 - Init.write_claude_md ~proc ~fs:fs_t ~config; 555 - Init.write_dune_project ~proc ~fs:fs_t ~config all_pkgs; 552 + let monorepo = Config.Paths.monorepo config in 553 + let (_ : string list) = 554 + Root.regenerate ~sw ~fs:fs_t ~monorepo ~packages:all_pkgs () 555 + in 556 556 Ok () 557 557 end 558 558 else ··· 611 611 let unresolved = 612 612 resolve_conflicts ~sw ~fs_t ~config ~auto ~inner_conflict_paths results 613 613 in 614 - finalize_pull ~proc ~fs_t ~config ~all_pkgs unresolved 614 + finalize_pull ~sw ~fs_t ~config ~all_pkgs unresolved
+6 -3
monopam/lib/push.ml
··· 761 761 762 762 let export_and_push ~sw ~clock ~proc ~fs ~fs_t ~config ~sources ~upstream 763 763 ~push_mono ~clean ~force ~all_pkgs repos = 764 - (* Update README and llms.txt before push *) 765 - Init.write_readme ~proc ~fs:fs_t ~config all_pkgs; 766 - Init.write_llms_txt ~proc ~fs:fs_t ~config all_pkgs; 764 + (* Refresh root files before export so the push ships them. *) 765 + let monorepo_for_root = Config.Paths.monorepo config in 766 + let (_ : string list) = 767 + Root.regenerate ~sw ~fs:fs_t ~monorepo:monorepo_for_root ~packages:all_pkgs 768 + () 769 + in 767 770 let n_repos = List.length repos in 768 771 let total = if upstream then n_repos * 2 else n_repos in 769 772 let monorepo = Config.Paths.monorepo config in
+483
monopam/lib/root.ml
··· 1 + (** Root monorepo files: [dune-project], [README.md], [llms.txt], [CLAUDE.md]. 2 + *) 3 + 4 + let src = Logs.Src.create "monopam.root" ~doc:"Monorepo root file generation" 5 + 6 + module Log = (val Logs.src_log src : Logs.LOG) 7 + 8 + type t = { 9 + dune_project : string; 10 + readme : string; 11 + llms_txt : string; 12 + claude_md : string; 13 + } 14 + 15 + let pp ppf t = 16 + Fmt.pf ppf 17 + "@[<v>dune-project: %d B@ README.md: %d B@ llms.txt: %d B@ CLAUDE.md: %d \ 18 + B@]" 19 + (String.length t.dune_project) 20 + (String.length t.readme) (String.length t.llms_txt) 21 + (String.length t.claude_md) 22 + 23 + type diff = { file : string; expected : string; actual : string } 24 + 25 + (** {1 Static content: CLAUDE.md} *) 26 + 27 + let claude_md_header = 28 + {|# Monorepo Development Guide 29 + 30 + This is a monorepo managed by `monopam`. Each subdirectory is a subtree 31 + from a separate upstream repository. You edit everything in one tree, 32 + build with dune, then `pull` and `push` to move changes in and out. 33 + 34 + > **Note:** Check `CLAUDE.local.md` (if it exists) for additional local 35 + > configuration or preferences specific to this workspace. 36 + 37 + ## Quick Reference 38 + 39 + | Task | Command | 40 + |------------------------------------|------------------------------| 41 + | Check status | `monopam status` | 42 + | Fetch upstream changes | `monopam pull` | 43 + | Push changes to your remotes | `monopam push` | 44 + | Export to checkouts only (no push) | `monopam push --local` | 45 + | Operate on one package | `monopam <cmd> <name>` | 46 + | Build | `opam exec -- dune build` | 47 + | Test | `opam exec -- dune test` | 48 + |} 49 + 50 + let claude_md_workflow = 51 + {|## Daily Workflow 52 + 53 + ```bash 54 + # 1. See what needs attention 55 + monopam status 56 + 57 + # 2. Pull latest upstream changes into the monorepo 58 + monopam pull 59 + 60 + # 3. Make your changes anywhere in the tree, build and test 61 + opam exec -- dune build && opam exec -- dune test 62 + 63 + # 4. Commit your changes to the monorepo 64 + git add -A && git commit -m "Description of changes" 65 + 66 + # 5. Send them back to the upstream repos 67 + monopam push 68 + ``` 69 + 70 + `pull` and `push` are the only sync verbs. There is no `sync` command: 71 + pulling first, building, testing, then pushing keeps the two directions 72 + decoupled so you always know what state you're in. 73 + 74 + ## Understanding Status Output 75 + 76 + Run `monopam status` to see the sync state: 77 + 78 + - `local:=` — Monorepo and checkout in sync 79 + - `local:+N` — Monorepo has N commits not in checkout (run `monopam push --local`) 80 + - `local:-N` — Checkout has N commits not in monorepo (run `monopam pull`) 81 + - `local:sync` — Trees differ; run `monopam pull` then `monopam push` to reconcile 82 + - `remote:=` — Checkout and upstream in sync 83 + - `remote:+N` — You have N commits to push (run `monopam push`) 84 + - `remote:-N` — Upstream has N commits to pull (run `monopam pull`) 85 + 86 + ## Making Changes 87 + 88 + 1. **Edit code** in any subdirectory as normal 89 + 2. **Build and test**: `opam exec -- dune build && opam exec -- dune test` 90 + 3. **Commit** your changes: `git add -A && git commit` 91 + 4. **Push**: `monopam push` to send them upstream 92 + 93 + ## Important Notes 94 + 95 + - **Always commit before push**: `monopam push` only exports committed changes 96 + - **Check status first**: Run `monopam status` to see what needs attention 97 + - **One repo per directory**: Each subdirectory maps to exactly one git remote 98 + |} 99 + 100 + let claude_md_troubleshooting = 101 + {|## Troubleshooting 102 + 103 + ### `local:sync` in status 104 + The monorepo subtree and checkout have diverged trees and monopam can't 105 + pick a direction automatically. Pull first, then push: 106 + 107 + ```bash 108 + monopam pull 109 + monopam push 110 + ``` 111 + 112 + ### Merge conflicts after `monopam pull` 113 + Resolve conflicts in `mono/`, then commit and continue: 114 + 115 + ```bash 116 + git add -A && git commit -m "Resolve merge conflicts" 117 + monopam push # only if you want to publish the resolution 118 + ``` 119 + 120 + ### Push fails with non-fast-forward 121 + Another monorepo (or a direct commit) got there first. Pull, rebuild, then 122 + retry: 123 + 124 + ```bash 125 + monopam pull 126 + opam exec -- dune build && opam exec -- dune test 127 + monopam push 128 + ``` 129 + 130 + If the upstream history is intentionally diverged (e.g. after `git 131 + filter-repo`), `monopam push --force` overrides. 132 + 133 + ### A checkout is missing 134 + Usually means `src/<repo>` hasn't been cloned yet. `monopam pull` will 135 + clone missing checkouts as part of its normal flow. 136 + 137 + ## Getting Help 138 + 139 + ```bash 140 + monopam --help # List of all commands 141 + monopam pull --help # Pull command help 142 + monopam push --help # Push command help 143 + monopam status --help # Status command help 144 + ``` 145 + |} 146 + 147 + let claude_md = 148 + String.concat "\n" 149 + [ claude_md_header; claude_md_workflow; claude_md_troubleshooting ] 150 + 151 + (** {1 README and llms.txt generation} *) 152 + 153 + let strip_git_plus url = 154 + if String.starts_with ~prefix:"git+" url then 155 + String.sub url 4 (String.length url - 4) 156 + else url 157 + 158 + let repo_display_url pkg = strip_git_plus (Uri.to_string (Package.dev_repo pkg)) 159 + 160 + let repo_cell_for ~repo ~url ~index = 161 + if index <> 0 then "" 162 + else if url = "" then Fmt.str "**%s**" repo 163 + else Fmt.str "[**%s**](%s)" repo url 164 + 165 + let readme_row ~buf ~repo ~url ~index pkg = 166 + let synopsis = Option.value ~default:"" (Package.synopsis pkg) in 167 + let cell = repo_cell_for ~repo ~url ~index in 168 + Buffer.add_string buf 169 + (Fmt.str "| %s | %s | %s |\n" cell (Package.name pkg) synopsis) 170 + 171 + let readme_repo_group ~buf (repo, pkgs) = 172 + let url = match pkgs with p :: _ -> repo_display_url p | [] -> "" in 173 + List.iteri (fun i pkg -> readme_row ~buf ~repo ~url ~index:i pkg) pkgs 174 + 175 + let generate_readme pkgs = 176 + let grouped = Ctx.group_by_repo pkgs in 177 + let buf = Buffer.create 4096 in 178 + Buffer.add_string buf "# Monorepo Package Index\n\n"; 179 + Buffer.add_string buf 180 + "This monorepo contains the following packages, synchronized from their \ 181 + upstream repositories.\n\n"; 182 + Buffer.add_string buf "| Repository | Package | Synopsis |\n"; 183 + Buffer.add_string buf "|------------|---------|----------|\n"; 184 + List.iter (readme_repo_group ~buf) grouped; 185 + Buffer.add_string buf "\n---\n\n"; 186 + Buffer.add_string buf 187 + (Fmt.str "_Generated by monopam. %d packages from %d repositories._\n" 188 + (List.length pkgs) (List.length grouped)); 189 + Buffer.contents buf 190 + 191 + let generate_llms_txt pkgs = 192 + let grouped = Ctx.group_by_repo pkgs in 193 + let buf = Buffer.create 4096 in 194 + Buffer.add_string buf "# Blacksun Monorepo\n\n"; 195 + Buffer.add_string buf 196 + "> OCaml packages for space systems, cryptography, protocols, and monorepo \ 197 + tooling.\n\n"; 198 + Buffer.add_string buf 199 + "This monorepo aggregates OCaml libraries as git subtrees. Each package \ 200 + has its own README with API documentation and usage examples at the \ 201 + linked path.\n\n"; 202 + Buffer.add_string buf "## Packages\n\n"; 203 + List.iter 204 + (fun (repo, pkgs) -> 205 + List.iter 206 + (fun pkg -> 207 + let name = Package.name pkg in 208 + let synopsis = Option.value ~default:"" (Package.synopsis pkg) in 209 + Buffer.add_string buf 210 + (Fmt.str "- [%s](%s/README.md): %s\n" name repo synopsis)) 211 + pkgs) 212 + grouped; 213 + Buffer.add_string buf "\n## Optional\n\n"; 214 + Buffer.add_string buf 215 + "- [CLAUDE.md](CLAUDE.md): Development workflow and monorepo conventions\n"; 216 + Buffer.add_string buf 217 + "- [README.md](README.md): Human-facing package index with upstream repo \ 218 + links\n"; 219 + Buffer.contents buf 220 + 221 + (** {1 dune-project generation} 222 + 223 + Regenerated from scratch on every run. Identity fields — the project 224 + [(name ...)] and the root package's [(maintainers ...)] / [(authors ...)] — 225 + are carried forward from the existing dune-project when set, and guessed 226 + from the global git user when absent. The guess lands in the file on the 227 + first write, so it is preserved from that point on and hand-edits survive 228 + regeneration. *) 229 + 230 + type identity = { 231 + project_name : string; 232 + maintainers : string list; 233 + authors : string list; 234 + } 235 + 236 + let atom_field name stanzas = 237 + List.find_map 238 + (function 239 + | Sexp.List [ Sexp.Atom n; Sexp.Atom v ] when n = name -> Some v 240 + | _ -> None) 241 + stanzas 242 + 243 + let string_list_field name stanzas = 244 + List.find_map 245 + (function 246 + | Sexp.List (Sexp.Atom n :: rest) when n = name -> 247 + Some 248 + (List.filter_map 249 + (function Sexp.Atom s -> Some s | Sexp.List _ -> None) 250 + rest) 251 + | _ -> None) 252 + stanzas 253 + 254 + let guess_identity_string ~fs = 255 + match Git_cli.global_git_user ~fs () with 256 + | None -> None 257 + | Some u -> Some (Fmt.str "%s <%s>" (Git.User.name u) (Git.User.email u)) 258 + 259 + let identity ~fs content = 260 + let guess = Option.to_list (guess_identity_string ~fs) in 261 + let content = Dune_project.preprocess_dune_strings content in 262 + let sexps = 263 + match Sexp.Value.parse_string_many content with Ok s -> s | Error _ -> [] 264 + in 265 + let project_name = Option.value ~default:"root" (atom_field "name" sexps) in 266 + let root_package = 267 + List.find_map 268 + (function 269 + | Sexp.List (Sexp.Atom "package" :: rest) 270 + when atom_field "name" rest = Some project_name -> 271 + Some rest 272 + | _ -> None) 273 + sexps 274 + in 275 + let from_package field = 276 + match root_package with 277 + | None -> None 278 + | Some rest -> string_list_field field rest 279 + in 280 + let maintainers = Option.value ~default:guess (from_package "maintainers") in 281 + let authors = Option.value ~default:guess (from_package "authors") in 282 + { project_name; maintainers; authors } 283 + 284 + let format_string_list keyword items = 285 + match items with 286 + | [] -> "" 287 + | _ -> 288 + Fmt.str " (%s %s)\n" keyword 289 + (String.concat " " (List.map (Fmt.str "%S") items)) 290 + 291 + let generate_dune_project ident deps = 292 + let buf = Buffer.create 1024 in 293 + Buffer.add_string buf "(lang dune 3.21)\n"; 294 + Buffer.add_string buf (Fmt.str "(name %s)\n\n" ident.project_name); 295 + Buffer.add_string buf "(generate_opam_files true)\n\n"; 296 + Buffer.add_string buf "(package\n"; 297 + Buffer.add_string buf (Fmt.str " (name %s)\n" ident.project_name); 298 + Buffer.add_string buf 299 + " (synopsis \"Monorepo root package with external dependencies\")\n"; 300 + Buffer.add_string buf (format_string_list "maintainers" ident.maintainers); 301 + Buffer.add_string buf (format_string_list "authors" ident.authors); 302 + (match deps with [] -> Buffer.add_string buf " (allow_empty)\n" | _ -> ()); 303 + Buffer.add_string buf " (depends\n"; 304 + List.iter (fun d -> Buffer.add_string buf (Fmt.str " %s\n" d)) deps; 305 + Buffer.add_string buf " ))\n"; 306 + Buffer.contents buf 307 + 308 + (** {1 Packages: rich or reconstructed from fs} *) 309 + 310 + let subtree_dirs ~fs monorepo = 311 + let eio = Eio.Path.(fs / Fpath.to_string monorepo) in 312 + let entries = try Eio.Path.read_dir eio with Eio.Io _ -> [] in 313 + List.filter 314 + (fun name -> 315 + (not (String.starts_with ~prefix:"." name)) 316 + && (not (String.starts_with ~prefix:"_" name)) 317 + && name <> "src" 318 + && 319 + try Eio.Path.kind ~follow:true Eio.Path.(eio / name) = `Directory 320 + with Eio.Io _ -> false) 321 + entries 322 + |> List.sort String.compare 323 + 324 + let opam_files_in ~fs subtree_path = 325 + let eio = Eio.Path.(fs / Fpath.to_string subtree_path) in 326 + try 327 + Eio.Path.read_dir eio 328 + |> List.filter (fun n -> Filename.check_suffix n ".opam") 329 + with Eio.Io _ -> [] 330 + 331 + (** Reconstruct a [Package.t] from an on-disk [.opam] file. Missing fields fall 332 + back to empty strings so README/llms.txt still render something for 333 + freshly-imported subtrees that haven't published metadata yet. *) 334 + let package_of_opam_file ~fs ~subtree ~monorepo opam_file = 335 + let opam_path = Fpath.(monorepo / subtree / opam_file) in 336 + let file = Fpath.to_string opam_path in 337 + let eio = Eio.Path.(fs / file) in 338 + try 339 + Eio.Path.with_open_in eio (fun flow -> 340 + let r = Bytesrw_eio.bytes_reader_of_flow flow in 341 + let opamfile = Opam_bytesrw.of_reader ~file r in 342 + let items = opamfile.contents in 343 + let name = Filename.chop_suffix opam_file ".opam" in 344 + let dev_repo = 345 + match Opam_repo.dev_repo items with 346 + | Some url -> Opam_repo.normalize_git_url url 347 + | None -> Uri.of_string "" 348 + in 349 + let synopsis = Opam_repo.synopsis items in 350 + let depends = Opam_repo.depends items in 351 + Some (Package.v ~name ~version:"dev" ~dev_repo ~depends ?synopsis ())) 352 + with Eio.Io _ | Opam.Error _ -> None 353 + 354 + let packages_from_fs ~fs ~monorepo = 355 + subtree_dirs ~fs monorepo 356 + |> List.concat_map (fun subtree -> 357 + opam_files_in ~fs Fpath.(monorepo / subtree) 358 + |> List.filter_map (package_of_opam_file ~fs ~subtree ~monorepo)) 359 + 360 + (** {1 Dependency collection} *) 361 + 362 + let collect_external_deps ~fs ~monorepo pkgs = 363 + let seen = Hashtbl.create 16 in 364 + let repos = 365 + List.filter 366 + (fun pkg -> 367 + let repo = Package.repo_name pkg in 368 + if Hashtbl.mem seen repo then false 369 + else begin 370 + Hashtbl.add seen repo (); 371 + true 372 + end) 373 + pkgs 374 + in 375 + let scan_dirs = 376 + match repos with 377 + | [] -> subtree_dirs ~fs monorepo 378 + | _ -> List.map Package.subtree_prefix repos 379 + in 380 + let all_deps = 381 + List.concat_map 382 + (fun dir -> Opam_repo.scan_opam_files_for_deps ~fs Fpath.(monorepo / dir)) 383 + scan_dirs 384 + |> List.sort_uniq String.compare 385 + in 386 + let pkg_names = 387 + List.concat_map 388 + (fun dir -> opam_files_in ~fs Fpath.(monorepo / dir)) 389 + scan_dirs 390 + |> List.map (fun n -> Filename.chop_suffix n ".opam") 391 + |> List.sort_uniq String.compare 392 + in 393 + List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps 394 + 395 + (** {1 Top-level API} *) 396 + 397 + let load_existing ~fs path = 398 + let eio = Eio.Path.(fs / Fpath.to_string path) in 399 + try Eio.Path.load eio with Eio.Io _ -> "" 400 + 401 + let compute ~fs ~monorepo ?packages () = 402 + let pkgs = 403 + match packages with Some p -> p | None -> packages_from_fs ~fs ~monorepo 404 + in 405 + let external_deps = collect_external_deps ~fs ~monorepo pkgs in 406 + let existing = load_existing ~fs Fpath.(monorepo / "dune-project") in 407 + let ident = identity ~fs existing in 408 + { 409 + dune_project = generate_dune_project ident external_deps; 410 + readme = generate_readme pkgs; 411 + llms_txt = generate_llms_txt pkgs; 412 + claude_md; 413 + } 414 + 415 + let load_actual ~fs ~monorepo file = load_existing ~fs Fpath.(monorepo / file) 416 + 417 + let files_of t = 418 + [ 419 + ("dune-project", t.dune_project); 420 + ("README.md", t.readme); 421 + ("llms.txt", t.llms_txt); 422 + ("CLAUDE.md", t.claude_md); 423 + ] 424 + 425 + let check ~fs ~monorepo ?packages () = 426 + let t = compute ~fs ~monorepo ?packages () in 427 + List.filter_map 428 + (fun (file, expected) -> 429 + let actual = load_actual ~fs ~monorepo file in 430 + if actual = expected then None else Some { file; expected; actual }) 431 + (files_of t) 432 + 433 + let write_if_changed ~monorepo_eio file content = 434 + let path = Eio.Path.(monorepo_eio / file) in 435 + let changed = 436 + match Eio.Path.load path with 437 + | existing -> existing <> content 438 + | exception Eio.Io _ -> true 439 + in 440 + if changed then Eio.Path.save ~create:(`Or_truncate 0o644) path content; 441 + changed 442 + 443 + let git_user ~fs () = 444 + match Git_cli.global_git_user ~fs () with 445 + | Some u -> u 446 + | None -> 447 + Git.User.v ~name:"monopam" ~email:"monopam@localhost" 448 + ~date:(Int64.of_float (Unix.time ())) 449 + () 450 + 451 + let regenerate ~sw ~fs ~monorepo ?packages () = 452 + let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 453 + let t = compute ~fs ~monorepo ?packages () in 454 + let changed = 455 + List.filter_map 456 + (fun (file, content) -> 457 + if write_if_changed ~monorepo_eio file content then Some file else None) 458 + (files_of t) 459 + in 460 + (match changed with 461 + | [] -> Log.debug (fun m -> m "Root files already up to date") 462 + | files when not (Git.Repository.is_repo ~fs monorepo) -> 463 + Log.app (fun m -> 464 + m "Regenerated root files: %a" Fmt.(list ~sep:(any ", ") string) files) 465 + | files -> ( 466 + let repo = Git.Repository.open_repo ~sw ~fs monorepo in 467 + match Git.Repository.add_to_index repo files with 468 + | Error (`Msg e) -> 469 + Log.warn (fun m -> m "Failed to stage root files: %s" e) 470 + | Ok () -> ( 471 + let user = git_user ~fs () in 472 + match 473 + Git.Repository.commit_index repo ~author:user ~committer:user 474 + ~message:"Regenerate root files" () 475 + with 476 + | Error (`Msg e) -> 477 + Log.warn (fun m -> m "Failed to commit root files: %s" e) 478 + | Ok _ -> 479 + Log.app (fun m -> 480 + m "Regenerated root files: %a" 481 + Fmt.(list ~sep:(any ", ") string) 482 + files)))); 483 + changed
+71
monopam/lib/root.mli
··· 1 + (** Root monorepo files: [dune-project], [README.md], [llms.txt], [CLAUDE.md]. 2 + 3 + Single source of truth for the four files that live at the monorepo root and 4 + describe it to humans, LLMs, and the build system. [compute] is a pure 5 + transformation returning the expected content; [regenerate] writes stale 6 + files and stages them in git; [check] reports stale files without touching 7 + them. *) 8 + 9 + type t = { 10 + dune_project : string; 11 + readme : string; 12 + llms_txt : string; 13 + claude_md : string; 14 + } 15 + (** Expected content for each root file. *) 16 + 17 + val pp : t Fmt.t 18 + (** [pp] pretty-prints [t] as a summary: one line per file with its byte size, 19 + intended for diagnostics and logs rather than reconstructing the content. *) 20 + 21 + val claude_md : string 22 + (** Static CLAUDE.md content shipped with monopam. Exposed so bootstrap paths 23 + ([monopam init], fresh monorepo setup) can write the file before any 24 + packages exist. *) 25 + 26 + val compute : 27 + fs:Eio.Fs.dir_ty Eio.Path.t -> 28 + monorepo:Fpath.t -> 29 + ?packages:Package.t list -> 30 + unit -> 31 + t 32 + (** [compute ~fs ~monorepo ?packages ()] builds the expected content for the 33 + four root files. If [packages] is given (from [Ctx.discover_packages]), it 34 + is used for README, llms.txt, and the dune-project dependency list. 35 + Otherwise the monorepo's subtree directories are scanned for .opam files and 36 + the package list is reconstructed from them, so callers without a [Config.t] 37 + (e.g. [monopam init] or [monopam add]) can still regenerate. 38 + 39 + The dune-project field is regenerated from scratch on every call. The 40 + top-level project [(name ...)] and the root package's [(maintainers ...)] / 41 + [(authors ...)] are carried forward from the existing file when present, and 42 + guessed from the global git user otherwise. The guess lands in the file on 43 + the first write, so it is preserved from that point on. *) 44 + 45 + type diff = { file : string; expected : string; actual : string } 46 + (** One stale file. [expected] is what {!compute} produced, [actual] is the 47 + current on-disk content (empty string if missing). *) 48 + 49 + val check : 50 + fs:Eio.Fs.dir_ty Eio.Path.t -> 51 + monorepo:Fpath.t -> 52 + ?packages:Package.t list -> 53 + unit -> 54 + diff list 55 + (** [check ~fs ~monorepo ?packages ()] returns the root files whose on-disk 56 + content differs from {!compute}. An empty list means the tree is up to date. 57 + *) 58 + 59 + val regenerate : 60 + sw:Eio.Switch.t -> 61 + fs:Eio.Fs.dir_ty Eio.Path.t -> 62 + monorepo:Fpath.t -> 63 + ?packages:Package.t list -> 64 + unit -> 65 + string list 66 + (** [regenerate ~sw ~fs ~monorepo ?packages ()] writes the expected content for 67 + each stale root file, stages it via {!Git.Repository.add_to_index}, and — 68 + only if at least one file actually changed — commits with message 69 + ["Regenerate root files"]. Returns the list of filenames that were rewritten 70 + (empty when everything was already up to date, in which case nothing is 71 + committed). *)
+3 -3
monopam/test/cram/init.t/run.t
··· 35 35 > | grep -v '^Updated dune-project' 36 36 [init] root: <WS> 37 37 [init] handle: alice.example.org 38 + Regenerated root files: dune-project, README.md, llms.txt 38 39 ✓ Workspace initialized. 39 40 Next: monopam add <git-url> # or: monopam pull 40 41 ··· 59 60 $ test $(grep -c "monopam push" CLAUDE.md) -gt 0 && echo "mentions push" 60 61 mentions push 61 62 62 - The dune-project and root.opam should be regenerated by init: 63 + Init should regenerate dune-project (root.opam is produced by dune 64 + from [(generate_opam_files true)] at build time, not by monopam): 63 65 64 66 $ test -f dune-project && echo "dune-project exists" 65 67 dune-project exists 66 - $ test -f root.opam && echo "root.opam exists" 67 - root.opam exists 68 68 69 69 Re-running init is idempotent — it should not crash, should print the 70 70 same root, and should leave CLAUDE.md unchanged:
+5 -1
monopam/test/cram/quickstart.t/run.t
··· 79 79 > | sed 's/(upstream \?([0-9a-f]*)/(upstream (<SHA>)/' \ 80 80 > | sed -E 's/\+ upstream \([0-9a-f]+\)/+ upstream (<SHA>)/' 81 81 Imported upstream at <SHA> 82 - Updated dune-project with 0 external dependencies 82 + Regenerated root files: dune-project, README.md, llms.txt, CLAUDE.md 83 83 + upstream (<SHA>) 84 84 ✓ Added 1 subtree. 85 85 Next: dune build && dune test ··· 89 89 the imported subtree from HEAD): 90 90 91 91 $ git ls-tree HEAD | awk '{print $2, $4}' | sort 92 + blob CLAUDE.md 93 + blob README.md 94 + blob dune-project 95 + blob llms.txt 92 96 blob sources.toml 93 97 tree upstream 94 98
+6 -8
monopam/test/cram/remove.t/run.t
··· 41 41 42 42 $ test -d upstream && echo "subtree present" 43 43 subtree present 44 - $ grep -c "^\[upstream\]" sources.toml 44 + $ grep -c "^upstream=" sources.toml 45 45 1 46 46 47 47 Dry run — preview without touching disk ··· 54 54 55 55 $ test -d upstream && echo "subtree still present" 56 56 subtree still present 57 - $ grep -c "^\[upstream\]" sources.toml 57 + $ grep -c "^upstream=" sources.toml 58 58 1 59 59 60 60 Real removal ··· 64 64 Updated sources.toml 65 65 Removed upstream 66 66 67 - The subtree directory is gone and the sources.toml entry has been 68 - deleted. The only untracked files are the pre-existing dune-project 69 - and root.opam regenerated by the earlier add: 67 + The subtree directory is gone, the sources.toml entry has been 68 + deleted, and the tree is clean — the root files regenerated by the 69 + earlier add are already committed: 70 70 71 71 $ test -d upstream || echo "subtree removed" 72 72 subtree removed 73 - $ grep -c "^\[upstream\]" sources.toml || true 73 + $ grep -c "^upstream=" sources.toml || true 74 74 0 75 75 $ git status --porcelain | sort 76 - ?? dune-project 77 - ?? root.opam
+11 -11
monopam/test/cram/sources.t/run.t
··· 45 45 46 46 $ monopam add "$TROOT/upstream-lib.git" 2>&1 | sed '/Added/ s/ (.*$//' 47 47 Imported upstream-lib at 66f5f30 48 - Updated dune-project with 0 external dependencies 48 + Regenerated root files: dune-project, README.md, llms.txt, CLAUDE.md 49 49 + upstream-lib (66f5f30) 50 50 ✓ Added 1 subtree. 51 51 Next: dune build && dune test 52 52 53 53 Verify sources.toml has source and ref fields: 54 54 55 - $ grep -c 'source = ' sources.toml 55 + $ grep -c 'source=' sources.toml 56 56 1 57 - $ grep -c 'ref = ' sources.toml 57 + $ grep -c 'ref=' sources.toml 58 58 1 59 - $ grep '\[upstream-lib\]' sources.toml 60 - [upstream-lib] 59 + $ grep -c '^upstream-lib=' sources.toml 60 + 1 61 61 62 62 The source field should contain the URL: 63 63 64 - $ grep 'source' sources.toml 65 - source = "git+$TESTCASE_ROOT/upstream-lib.git" 64 + $ grep -oE 'source="[^"]*"' sources.toml | sed "s|$TROOT|<TROOT>|" 65 + source="git+<TROOT>/upstream-lib.git" 66 66 67 67 The ref field should contain a full 40-char SHA: 68 68 69 - $ grep 'ref' sources.toml | grep -cE '"[0-9a-f]{40}"' 69 + $ grep -oE 'ref="[0-9a-f]{40}"' sources.toml | wc -l | tr -d ' ' 70 70 1 71 71 72 72 $ cd "$TROOT" ··· 123 123 > | head -4 124 124 [add] resolved tool to <URL> 125 125 Imported tool at <SHA> 126 - Updated dune-project with 0 external dependencies 126 + Regenerated root files: dune-project, README.md, llms.txt, CLAUDE.md 127 127 + tool (<SHA>) 128 128 129 - $ grep '\[tool\]' sources.toml 130 - [tool] 129 + $ grep -c '^tool=' sources.toml 130 + 1 131 131 $ cd "$TROOT" 132 132 133 133 Add by unknown name produces a hint
+8 -5
monopam/test/cram/subtree_path.t/run.t
··· 84 84 $ monopam add "file://$TROOT/global.git" --path eio > /tmp/add-out 2>&1 85 85 $ awk '{ gsub(/[0-9a-f]{7}/, "<SHA>"); gsub(/ \([0-9.]+s\)/, ""); print }' /tmp/add-out 86 86 Imported eio at <SHA> 87 - Updated dune-project with 0 external dependencies 87 + Regenerated root files: dune-project, README.md, llms.txt, CLAUDE.md 88 88 + eio (<SHA>) 89 89 ✓ Added 1 subtree. 90 90 Next: dune build && dune test ··· 92 92 The working tree has eio/ but not cohttp/ — we only imported one path: 93 93 94 94 $ ls -1 | grep -v root.opam 95 + CLAUDE.md 96 + README.md 95 97 dune-project 96 98 eio 99 + llms.txt 97 100 sources.toml 98 101 99 102 sources.toml records the path override so subsequent pulls and pushes 100 103 know where to split from and merge into: 101 104 102 - $ grep -E "source|path" sources.toml | sed "s|$TROOT|<TROOT>|" 103 - source = "git+file://<TROOT>/global.git" 104 - path = "eio" 105 + $ grep -oE 'source="[^"]*"|path="[^"]*"' sources.toml | sed "s|$TROOT|<TROOT>|" 106 + source="git+file://<TROOT>/global.git" 107 + path="eio" 105 108 106 109 The imported subtree has the expected content: 107 110 ··· 120 123 Next: dune build && dune test 121 124 $ test -d cohttp && echo "cohttp present" 122 125 cohttp present 123 - $ grep -c "^path =" sources.toml 126 + $ grep -oE 'path="[^"]*"' sources.toml | wc -l | tr -d ' ' 124 127 2 125 128 126 129 Stage 3: edit eio locally and push back
+1 -1
monopam/test/dune
··· 1 1 (test 2 2 (name test) 3 - (libraries monopam merge3 alcotest eio_main fpath uri)) 3 + (libraries monopam merge3 alcotest eio_main fpath uri astring))
+1
monopam/test/test.ml
··· 33 33 Test_pull.suite; 34 34 Test_quality.suite; 35 35 Test_push.suite; 36 + Test_root.suite; 36 37 Test_remote_cache.suite; 37 38 Test_remove.suite; 38 39 Test_site.suite;
+2 -2
monopam/test/test_lint.ml
··· 21 21 22 22 let test_result_construction () = 23 23 let r : Monopam.Lint.result = 24 - { issues = []; source_issues = []; packages_scanned = 0 } 24 + { issues = []; source_issues = []; root_diffs = []; packages_scanned = 0 } 25 25 in 26 26 Alcotest.(check int) "no issues" 0 (List.length r.issues); 27 27 Alcotest.(check int) "no packages" 0 r.packages_scanned ··· 34 34 ] 35 35 in 36 36 let r : Monopam.Lint.result = 37 - { issues; source_issues = []; packages_scanned = 2 } 37 + { issues; source_issues = []; root_diffs = []; packages_scanned = 2 } 38 38 in 39 39 Alcotest.(check int) "two issues" 2 (List.length r.issues); 40 40 Alcotest.(check int) "two packages" 2 r.packages_scanned
+225
monopam/test/test_root.ml
··· 1 + (** Tests for [Root] — especially the dune-project generator's identity field 2 + (name / maintainers / authors) preservation. *) 3 + 4 + module Root = Monopam.Root 5 + 6 + (** {1 Helpers: scratch monorepo} *) 7 + 8 + let with_tmp_dir f = 9 + let tmp = Filename.temp_file "monopam_root_test_" "" in 10 + Sys.remove tmp; 11 + Unix.mkdir tmp 0o755; 12 + Fun.protect 13 + ~finally:(fun () -> 14 + let rec rmrf path = 15 + match Unix.lstat path with 16 + | { st_kind = S_DIR; _ } -> 17 + let entries = Sys.readdir path in 18 + Array.iter (fun e -> rmrf (Filename.concat path e)) entries; 19 + Unix.rmdir path 20 + | _ -> Unix.unlink path 21 + | exception Unix.Unix_error _ -> () 22 + in 23 + rmrf tmp) 24 + (fun () -> f (Fpath.v tmp)) 25 + 26 + let write_file fs path content = 27 + let eio = Eio.Path.(fs / Fpath.to_string path) in 28 + Eio.Path.save ~create:(`Or_truncate 0o644) eio content 29 + 30 + let read_file fs path = 31 + try Eio.Path.load Eio.Path.(fs / Fpath.to_string path) with Eio.Io _ -> "" 32 + 33 + (** Compute and return just the dune-project content. *) 34 + let dune_project_of ~fs ~monorepo = (Root.compute ~fs ~monorepo ()).dune_project 35 + 36 + (** {1 Fresh-generation tests} *) 37 + 38 + let test_fresh_lang_version () = 39 + Eio_main.run @@ fun env -> 40 + let fs = Eio.Stdenv.fs env in 41 + with_tmp_dir @@ fun monorepo -> 42 + let s = dune_project_of ~fs ~monorepo in 43 + Alcotest.(check bool) 44 + "declares (lang dune 3.21)" true 45 + (Astring.String.is_infix ~affix:"(lang dune 3.21)" s) 46 + 47 + let test_fresh_default_name () = 48 + Eio_main.run @@ fun env -> 49 + let fs = Eio.Stdenv.fs env in 50 + with_tmp_dir @@ fun monorepo -> 51 + let s = dune_project_of ~fs ~monorepo in 52 + Alcotest.(check bool) 53 + "top-level (name root)" true 54 + (Astring.String.is_infix ~affix:"(name root)" s); 55 + Alcotest.(check bool) 56 + "package (name root)" true 57 + (Astring.String.is_infix ~affix:"(package\n (name root)" s) 58 + 59 + let test_fresh_allow_empty () = 60 + Eio_main.run @@ fun env -> 61 + let fs = Eio.Stdenv.fs env in 62 + with_tmp_dir @@ fun monorepo -> 63 + let s = dune_project_of ~fs ~monorepo in 64 + Alcotest.(check bool) 65 + "empty workspace gets (allow_empty)" true 66 + (Astring.String.is_infix ~affix:"(allow_empty)" s) 67 + 68 + (** {1 Identity preservation} *) 69 + 70 + let test_preserves_custom_project_name () = 71 + Eio_main.run @@ fun env -> 72 + let fs = Eio.Stdenv.fs env in 73 + with_tmp_dir @@ fun monorepo -> 74 + write_file fs 75 + Fpath.(monorepo / "dune-project") 76 + {|(lang dune 3.21) 77 + (name my-workspace) 78 + 79 + (generate_opam_files true) 80 + 81 + (package 82 + (name my-workspace) 83 + (synopsis "Stale synopsis that will be rewritten") 84 + (allow_empty) 85 + (depends)) 86 + |}; 87 + let s = dune_project_of ~fs ~monorepo in 88 + Alcotest.(check bool) 89 + "top-level name preserved" true 90 + (Astring.String.is_infix ~affix:"(name my-workspace)" s); 91 + Alcotest.(check bool) 92 + "no stray (name root)" false 93 + (Astring.String.is_infix ~affix:"(name root)" s) 94 + 95 + let test_preserves_maintainers_list () = 96 + Eio_main.run @@ fun env -> 97 + let fs = Eio.Stdenv.fs env in 98 + with_tmp_dir @@ fun monorepo -> 99 + write_file fs 100 + Fpath.(monorepo / "dune-project") 101 + {|(lang dune 3.21) 102 + (name root) 103 + 104 + (generate_opam_files true) 105 + 106 + (package 107 + (name root) 108 + (synopsis "x") 109 + (maintainers "Alice <alice@example.org>" "Bob <bob@example.org>") 110 + (allow_empty) 111 + (depends)) 112 + |}; 113 + let s = dune_project_of ~fs ~monorepo in 114 + Alcotest.(check bool) 115 + "first maintainer kept" true 116 + (Astring.String.is_infix ~affix:"Alice <alice@example.org>" s); 117 + Alcotest.(check bool) 118 + "second maintainer kept" true 119 + (Astring.String.is_infix ~affix:"Bob <bob@example.org>" s) 120 + 121 + let test_preserves_authors_list () = 122 + Eio_main.run @@ fun env -> 123 + let fs = Eio.Stdenv.fs env in 124 + with_tmp_dir @@ fun monorepo -> 125 + write_file fs 126 + Fpath.(monorepo / "dune-project") 127 + {|(lang dune 3.21) 128 + (name root) 129 + 130 + (generate_opam_files true) 131 + 132 + (package 133 + (name root) 134 + (synopsis "x") 135 + (authors "Carol <carol@example.org>") 136 + (allow_empty) 137 + (depends)) 138 + |}; 139 + let s = dune_project_of ~fs ~monorepo in 140 + Alcotest.(check bool) 141 + "author kept" true 142 + (Astring.String.is_infix ~affix:"Carol <carol@example.org>" s) 143 + 144 + (** {1 Idempotence after a regenerate cycle} *) 145 + 146 + let test_idempotent_roundtrip () = 147 + Eio_main.run @@ fun env -> 148 + let fs = Eio.Stdenv.fs env in 149 + with_tmp_dir @@ fun monorepo -> 150 + write_file fs 151 + Fpath.(monorepo / "dune-project") 152 + {|(lang dune 3.21) 153 + (name my-ws) 154 + 155 + (generate_opam_files true) 156 + 157 + (package 158 + (name my-ws) 159 + (synopsis "x") 160 + (maintainers "Dana <dana@example.org>") 161 + (allow_empty) 162 + (depends)) 163 + |}; 164 + (* Simulate first regen: write compute's output back to disk *) 165 + let first = dune_project_of ~fs ~monorepo in 166 + write_file fs Fpath.(monorepo / "dune-project") first; 167 + (* Second regen should produce the same string — identity fields survive *) 168 + let second = dune_project_of ~fs ~monorepo in 169 + Alcotest.(check string) "idempotent after writeback" first second; 170 + Alcotest.(check bool) 171 + "project name still custom" true 172 + (Astring.String.is_infix ~affix:"(name my-ws)" second); 173 + Alcotest.(check bool) 174 + "maintainer still there" true 175 + (Astring.String.is_infix ~affix:"Dana <dana@example.org>" second) 176 + 177 + (** {1 Check detects staleness} *) 178 + 179 + let test_check_missing_files () = 180 + Eio_main.run @@ fun env -> 181 + let fs = Eio.Stdenv.fs env in 182 + with_tmp_dir @@ fun monorepo -> 183 + let diffs = Root.check ~fs ~monorepo () in 184 + Alcotest.(check bool) "empty tree is stale" true (diffs <> []); 185 + let names = List.map (fun (d : Root.diff) -> d.file) diffs in 186 + Alcotest.(check bool) 187 + "dune-project flagged stale" true 188 + (List.mem "dune-project" names) 189 + 190 + let test_check_clean_after_writeback () = 191 + Eio_main.run @@ fun env -> 192 + let fs = Eio.Stdenv.fs env in 193 + with_tmp_dir @@ fun monorepo -> 194 + let t = Root.compute ~fs ~monorepo () in 195 + write_file fs Fpath.(monorepo / "dune-project") t.dune_project; 196 + write_file fs Fpath.(monorepo / "README.md") t.readme; 197 + write_file fs Fpath.(monorepo / "llms.txt") t.llms_txt; 198 + write_file fs Fpath.(monorepo / "CLAUDE.md") t.claude_md; 199 + let diffs = Root.check ~fs ~monorepo () in 200 + Alcotest.(check (list string)) 201 + "no diffs after writeback" [] 202 + (List.map (fun (d : Root.diff) -> d.file) diffs); 203 + ignore read_file 204 + 205 + let suite = 206 + ( "root", 207 + [ 208 + Alcotest.test_case "fresh: (lang dune 3.21)" `Quick 209 + test_fresh_lang_version; 210 + Alcotest.test_case "fresh: default name=root" `Quick 211 + test_fresh_default_name; 212 + Alcotest.test_case "fresh: allow_empty when no deps" `Quick 213 + test_fresh_allow_empty; 214 + Alcotest.test_case "preserves custom project name" `Quick 215 + test_preserves_custom_project_name; 216 + Alcotest.test_case "preserves maintainers list" `Quick 217 + test_preserves_maintainers_list; 218 + Alcotest.test_case "preserves authors list" `Quick 219 + test_preserves_authors_list; 220 + Alcotest.test_case "idempotent roundtrip" `Quick test_idempotent_roundtrip; 221 + Alcotest.test_case "check reports missing dune-project" `Quick 222 + test_check_missing_files; 223 + Alcotest.test_case "check clean after writeback" `Quick 224 + test_check_clean_after_writeback; 225 + ] )
+2
monopam/test/test_root.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the Alcotest suite covering {!Monopam.Root}. *)