Monorepo management for opam overlays
0
fork

Configure Feed

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

ocaml-linkedin: apply dune fmt

Pure formatting changes from `dune fmt`: doc comment placement moves
from above the binding to below it for `type`s, multi-line `match`
expressions collapse onto one line where they fit, and infix operator
applications pick up spaces (`Soup.($?)` -> `Soup.( $? )`). No
semantic changes.

+1014 -604
+54 -36
README.md
··· 15 15 16 16 Push always goes to your monorepo's git remote — a repo you own. You 17 17 never accidentally push to someone else's repo. Per-subtree source 18 - overrides let you pull from upstream repos you don't own. 18 + overrides 19 + let you pull from upstream repos you don't own. 19 20 20 21 ## Installation 21 22 23 + Install with opam: 24 + 25 + <!-- $MDX skip --> 26 + ```sh 27 + $ opam install monopam 22 28 ``` 23 - opam install monopam 29 + 30 + If opam cannot find the package, it may not yet be released in the public 31 + `opam-repository`. Add the overlay repository, then install it: 32 + 33 + <!-- $MDX skip --> 34 + ```sh 35 + $ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git 36 + $ opam update 37 + $ opam install monopam 24 38 ``` 25 39 26 40 ## Quick Start 27 41 28 - ```bash 29 - # Initialize a workspace 30 - monopam init --handle yourname.bsky.social 42 + <!-- $MDX non-deterministic=command --> 43 + ```sh 44 + $ # Initialize a workspace 45 + $ monopam init --handle yourname.bsky.social 31 46 32 - # Add packages (pulls from upstream, pushes to your monorepo) 33 - monopam add https://github.com/mirage/eio.git 34 - monopam add crowbar # resolve from opam 47 + $ # Add packages (pulls from upstream, pushes to your monorepo) 48 + $ monopam add https://github.com/mirage/eio.git 49 + $ monopam add crowbar # resolve from opam 35 50 36 - # Pull upstream changes 37 - monopam pull 51 + $ # Pull upstream changes 52 + $ monopam pull 38 53 39 - # Make changes, build, test, commit 40 - dune build && dune test 41 - git add -A && git commit -m "Add feature" 54 + $ # Make changes, build, test, commit 55 + $ dune build && dune test 56 + $ git add -A && git commit -m "Add feature" 42 57 43 - # Push to your monorepo remote 44 - monopam push 58 + $ # Push to your monorepo remote 59 + $ monopam push 45 60 ``` 46 61 47 62 ## Commands ··· 169 184 170 185 ## Daily Workflow 171 186 172 - ```bash 173 - # Get latest from upstream 174 - monopam pull 187 + <!-- $MDX non-deterministic=command --> 188 + ```sh 189 + $ # Get latest from upstream 190 + $ monopam pull 175 191 176 - # Work 177 - dune build && dune test 178 - git add -A && git commit -m "Description" 192 + $ # Work 193 + $ dune build && dune test 194 + $ git add -A && git commit -m "Description" 179 195 180 - # Send changes to your repos 181 - monopam push 196 + $ # Send changes to your repos 197 + $ monopam push 182 198 ``` 183 199 184 200 ## Diff 185 201 186 - ```bash 187 - monopam diff # What you would push 188 - monopam diff --incoming # What pull would bring in 189 - monopam diff eio # Specific subtree 202 + <!-- $MDX non-deterministic=command --> 203 + ```sh 204 + $ monopam diff # What you would push 205 + $ monopam diff --incoming # What pull would bring in 206 + $ monopam diff eio # Specific subtree 190 207 ``` 191 208 192 209 ## Collaboration (Verse) 193 210 194 211 Verse lets you browse and pull from collaborators' monorepos. 195 212 196 - ```bash 197 - # See what collaborators have 198 - monopam verse diff 213 + <!-- $MDX non-deterministic=command --> 214 + ```sh 215 + $ # See what collaborators have 216 + $ monopam verse diff 199 217 200 - # Pull their changes 201 - monopam verse pull alice.bsky.social 218 + $ # Pull their changes 219 + $ monopam verse pull alice.bsky.social 202 220 203 - # Cherry-pick a specific commit 204 - monopam verse cherrypick <sha> 221 + $ # Cherry-pick a specific commit 222 + $ monopam verse cherrypick <sha> 205 223 206 - # List members 207 - monopam verse members 224 + $ # List members 225 + $ monopam verse members 208 226 ``` 209 227 210 228 Collaboration is just "pull from a different source." A collaborator's
+4 -2
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"
+57 -4
bin/cmd_lint.ml
··· 169 169 if Tty.is_tty () then pp_table issues else pp_plain issues; 170 170 pp_source_issues source_issues 171 171 172 - let run filter () = 172 + let claude_dir_has_override ~fs ~monorepo = 173 + let path = 174 + Eio.Path.(fs / Fpath.to_string Fpath.(monorepo / ".claude" / "CLAUDE.md")) 175 + in 176 + match Eio.Path.kind ~follow:true path with 177 + | `Regular_file -> true 178 + | _ | (exception Eio.Io _) -> false 179 + 180 + let print_root_diff ~migrated d = 181 + let file = d.Monopam.Root.file in 182 + if file = "CLAUDE.md" && not migrated then ( 183 + Fmt.pr "%a %s: has local content@." 184 + Fmt.(styled (`Fg `Yellow) string) 185 + "!" file; 186 + Fmt.pr 187 + " Move hand-written sections to .claude/CLAUDE.md, then run monopam \ 188 + lint --fix.@.") 189 + else 190 + Fmt.pr "%a %s: out of date (run monopam lint --fix)@." 191 + Fmt.(styled (`Fg `Yellow) string) 192 + "!" file 193 + 194 + let run_fix ~env ~fs ~monorepo ~root_diffs = 195 + let migrated = claude_dir_has_override ~fs ~monorepo in 196 + let skip = if migrated then [] else [ "CLAUDE.md" ] in 197 + Eio.Switch.run @@ fun sw -> 198 + let _ = env in 199 + let rewritten = 200 + Monopam.Root.regenerate ~sw 201 + ~fs:(fs :> Eio.Fs.dir_ty Eio.Path.t) 202 + ~monorepo ~skip () 203 + in 204 + List.iter 205 + (fun file -> 206 + Fmt.pr "%a %s: regenerated@." Fmt.(styled (`Fg `Green) string) "✓" file) 207 + rewritten; 208 + let skipped = 209 + List.filter (fun d -> List.mem d.Monopam.Root.file skip) root_diffs 210 + in 211 + List.iter (print_root_diff ~migrated) skipped 212 + 213 + let run filter fix () = 173 214 Eio_main.run @@ fun env -> 174 215 Common.with_config env @@ fun config -> 175 216 let fs = Eio.Stdenv.fs env in 176 217 let monorepo = Monopam.Config.Paths.monorepo config in 177 - let { Monopam.Lint.issues; source_issues; packages_scanned } = 218 + let { Monopam.Lint.issues; source_issues; root_diffs; packages_scanned } = 178 219 Monopam.Lint.run ~fs:(fs :> Eio.Fs.dir_ty Eio.Path.t) ~monorepo () 179 220 in 180 221 let issues = filter_dep_issues filter issues in 181 222 let source_issues = filter_source_issues filter source_issues in 182 223 let label = scanned_label filter packages_scanned in 183 - if issues = [] && source_issues = [] then ( 224 + if issues = [] && source_issues = [] && root_diffs = [] then ( 184 225 Fmt.pr "%a All checks passed (%s).@." 185 226 Fmt.(styled (`Fg `Green) string) 186 227 "✓" label; 187 228 `Ok ()) 188 229 else ( 189 230 print_issues issues source_issues; 231 + (if fix && root_diffs <> [] then run_fix ~env ~fs ~monorepo ~root_diffs 232 + else 233 + let migrated = claude_dir_has_override ~fs ~monorepo in 234 + List.iter (print_root_diff ~migrated) root_diffs); 190 235 print_summary ~issues ~source_issues ~label; 191 236 `Ok ()) 192 237 ··· 197 242 let doc = "Only lint specific subtrees." in 198 243 Arg.(value & pos_all string [] & info [] ~docv:"SUBTREE" ~doc) 199 244 in 200 - Cmd.v info Term.(ret (const run $ filter_arg $ Common.logging_term)) 245 + let fix_arg = 246 + let doc = 247 + "Regenerate stale root files in place. CLAUDE.md is only regenerated \ 248 + when $(b,.claude/CLAUDE.md) exists (indicating doctrine has been \ 249 + migrated); otherwise a migration hint is printed." 250 + in 251 + Arg.(value & flag & info [ "fix" ] ~doc) 252 + in 253 + Cmd.v info Term.(ret (const run $ filter_arg $ fix_arg $ Common.logging_term))
+4 -89
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
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
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
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
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
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
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
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
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
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
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
+512
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:** Project-specific doctrine lives in `.claude/CLAUDE.md` 35 + > (committed, team-shared). User-local overrides belong in a 36 + > gitignored `.claude/CLAUDE.md` that shadows the committed one. 37 + 38 + ## Quick Reference 39 + 40 + | Task | Command | 41 + |------------------------------------|------------------------------| 42 + | Check status | `monopam status` | 43 + | Fetch upstream changes | `monopam pull` | 44 + | Push changes to your remotes | `monopam push` | 45 + | Export to checkouts only (no push) | `monopam push --local` | 46 + | Operate on one package | `monopam <cmd> <name>` | 47 + | Build | `opam exec -- dune build` | 48 + | Test | `opam exec -- dune test` | 49 + |} 50 + 51 + let claude_md_daily_workflow = 52 + {|## Daily Workflow 53 + 54 + ```bash 55 + # 1. See what needs attention 56 + monopam status 57 + 58 + # 2. Pull latest upstream changes into the monorepo 59 + monopam pull 60 + 61 + # 3. Make your changes anywhere in the tree, build and test 62 + opam exec -- dune build && opam exec -- dune test 63 + 64 + # 4. Commit your changes to the monorepo (stage specific files — avoid 65 + # `git add -A`, which sweeps in unrelated edits other sessions may 66 + # have staged and can include secrets or build artefacts) 67 + git add path/to/file ... 68 + git commit -m "Description of changes" 69 + 70 + # 5. Send them back to the upstream repos 71 + monopam push 72 + ``` 73 + 74 + `pull` and `push` are the sync verbs. Pulling first, building, testing, 75 + then pushing keeps the two directions decoupled so you always know what 76 + state you're in. 77 + |} 78 + 79 + let claude_md_status = 80 + {|## Understanding Status Output 81 + 82 + Run `monopam status` to see the sync state: 83 + 84 + - `local:=` — Monorepo and checkout in sync 85 + - `local:+N` — Monorepo has N commits not in checkout (run `monopam push --local`) 86 + - `local:-N` — Checkout has N commits not in monorepo (run `monopam pull`) 87 + - `local:sync` — Trees differ; run `monopam pull` then `monopam push` to reconcile 88 + - `remote:=` — Checkout and upstream in sync 89 + - `remote:+N` — You have N commits to push (run `monopam push`) 90 + - `remote:-N` — Upstream has N commits to pull (run `monopam pull`) 91 + |} 92 + 93 + let claude_md_making_changes = 94 + {|## Making Changes 95 + 96 + 1. **Edit code** in any subdirectory as normal 97 + 2. **Build and test**: `opam exec -- dune build && opam exec -- dune test` 98 + 3. **Commit** your changes: `git add <files>` for the specific paths you 99 + touched, then `git commit` (avoid `git add -A` — concurrent sessions 100 + may have staged unrelated work) 101 + 4. **Push**: `monopam push` to send them upstream 102 + 103 + ## Important Notes 104 + 105 + - **Always commit before push**: `monopam push` only exports committed changes 106 + - **Check status first**: Run `monopam status` to see what needs attention 107 + - **One repo per directory**: Each subdirectory maps to exactly one git remote 108 + |} 109 + 110 + let claude_md_workflow = 111 + String.concat "\n" 112 + [ claude_md_daily_workflow; claude_md_status; claude_md_making_changes ] 113 + 114 + let claude_md_troubleshooting = 115 + {|## Troubleshooting 116 + 117 + ### `local:sync` in status 118 + The monorepo subtree and checkout have diverged trees and monopam can't 119 + pick a direction automatically. Pull first, then push: 120 + 121 + ```bash 122 + monopam pull 123 + monopam push 124 + ``` 125 + 126 + ### Merge conflicts after `monopam pull` 127 + Resolve conflicts in `mono/`, then stage the resolved files (not `-A`) 128 + and commit: 129 + 130 + ```bash 131 + git add <resolved files> 132 + git commit -m "Resolve merge conflicts" 133 + monopam push # only if you want to publish the resolution 134 + ``` 135 + 136 + ### Push fails with non-fast-forward 137 + Another monorepo (or a direct commit) got there first. Pull, rebuild, then 138 + retry: 139 + 140 + ```bash 141 + monopam pull 142 + opam exec -- dune build && opam exec -- dune test 143 + monopam push 144 + ``` 145 + 146 + If the upstream history is intentionally diverged (e.g. after `git 147 + filter-repo`), `monopam push --force` overrides. 148 + 149 + ### A checkout is missing 150 + Usually means `src/<repo>` hasn't been cloned yet. `monopam pull` will 151 + clone missing checkouts as part of its normal flow. 152 + 153 + ## Getting Help 154 + 155 + ```bash 156 + monopam --help # List of all commands 157 + monopam pull --help # Pull command help 158 + monopam push --help # Push command help 159 + monopam status --help # Status command help 160 + ``` 161 + |} 162 + 163 + let claude_md = 164 + String.concat "\n" 165 + [ claude_md_header; claude_md_workflow; claude_md_troubleshooting ] 166 + 167 + (** {1 README and llms.txt generation} *) 168 + 169 + let strip_git_plus url = 170 + if String.starts_with ~prefix:"git+" url then 171 + String.sub url 4 (String.length url - 4) 172 + else url 173 + 174 + let repo_display_url pkg = strip_git_plus (Uri.to_string (Package.dev_repo pkg)) 175 + 176 + let repo_cell_for ~repo ~url ~index = 177 + if index <> 0 then "" 178 + else if url = "" then Fmt.str "**%s**" repo 179 + else Fmt.str "[**%s**](%s)" repo url 180 + 181 + let readme_row ~buf ~repo ~url ~index pkg = 182 + let synopsis = Option.value ~default:"" (Package.synopsis pkg) in 183 + let cell = repo_cell_for ~repo ~url ~index in 184 + Buffer.add_string buf 185 + (Fmt.str "| %s | %s | %s |\n" cell (Package.name pkg) synopsis) 186 + 187 + let readme_repo_group ~buf (repo, pkgs) = 188 + let url = match pkgs with p :: _ -> repo_display_url p | [] -> "" in 189 + List.iteri (fun i pkg -> readme_row ~buf ~repo ~url ~index:i pkg) pkgs 190 + 191 + let generate_readme pkgs = 192 + let grouped = Ctx.group_by_repo pkgs in 193 + let buf = Buffer.create 4096 in 194 + Buffer.add_string buf "# Monorepo Package Index\n\n"; 195 + Buffer.add_string buf 196 + "This monorepo contains the following packages, synchronized from their \ 197 + upstream repositories.\n\n"; 198 + Buffer.add_string buf "| Repository | Package | Synopsis |\n"; 199 + Buffer.add_string buf "|------------|---------|----------|\n"; 200 + List.iter (readme_repo_group ~buf) grouped; 201 + Buffer.add_string buf "\n---\n\n"; 202 + Buffer.add_string buf 203 + (Fmt.str "_Generated by monopam. %d packages from %d repositories._\n" 204 + (List.length pkgs) (List.length grouped)); 205 + Buffer.contents buf 206 + 207 + let generate_llms_txt pkgs = 208 + let grouped = Ctx.group_by_repo pkgs in 209 + let buf = Buffer.create 4096 in 210 + Buffer.add_string buf "# Blacksun Monorepo\n\n"; 211 + Buffer.add_string buf 212 + "> OCaml packages for space systems, cryptography, protocols, and monorepo \ 213 + tooling.\n\n"; 214 + Buffer.add_string buf 215 + "This monorepo aggregates OCaml libraries as git subtrees. Each package \ 216 + has its own README with API documentation and usage examples at the \ 217 + linked path.\n\n"; 218 + Buffer.add_string buf "## Packages\n\n"; 219 + List.iter 220 + (fun (repo, pkgs) -> 221 + List.iter 222 + (fun pkg -> 223 + let name = Package.name pkg in 224 + let synopsis = Option.value ~default:"" (Package.synopsis pkg) in 225 + Buffer.add_string buf 226 + (Fmt.str "- [%s](%s/README.md): %s\n" name repo synopsis)) 227 + pkgs) 228 + grouped; 229 + Buffer.add_string buf "\n## Optional\n\n"; 230 + Buffer.add_string buf 231 + "- [CLAUDE.md](CLAUDE.md): Development workflow and monorepo conventions\n"; 232 + Buffer.add_string buf 233 + "- [README.md](README.md): Human-facing package index with upstream repo \ 234 + links\n"; 235 + Buffer.contents buf 236 + 237 + (** {1 dune-project generation} 238 + 239 + Regenerated from scratch on every run. Identity fields — the project 240 + [(name ...)] and the root package's [(maintainers ...)] / [(authors ...)] — 241 + are carried forward from the existing dune-project when set, and guessed 242 + from the global git user when absent. The guess lands in the file on the 243 + first write, so it is preserved from that point on and hand-edits survive 244 + regeneration. *) 245 + 246 + type identity = { 247 + project_name : string; 248 + maintainers : string list; 249 + authors : string list; 250 + using : Sexp.Value.t list; 251 + } 252 + 253 + let atom_field name stanzas = 254 + List.find_map 255 + (function 256 + | Sexp.List [ Sexp.Atom n; Sexp.Atom v ] when n = name -> Some v 257 + | _ -> None) 258 + stanzas 259 + 260 + let string_list_field name stanzas = 261 + List.find_map 262 + (function 263 + | Sexp.List (Sexp.Atom n :: rest) when n = name -> 264 + Some 265 + (List.filter_map 266 + (function Sexp.Atom s -> Some s | Sexp.List _ -> None) 267 + rest) 268 + | _ -> None) 269 + stanzas 270 + 271 + let guess_identity_string ~fs = 272 + match Git_cli.global_git_user ~fs () with 273 + | None -> None 274 + | Some u -> Some (Fmt.str "%s <%s>" (Git.User.name u) (Git.User.email u)) 275 + 276 + let identity ~fs content = 277 + let guess = Option.to_list (guess_identity_string ~fs) in 278 + let content = Dune_project.preprocess_dune_strings content in 279 + let sexps = 280 + match Sexp.Value.parse_string_many content with Ok s -> s | Error _ -> [] 281 + in 282 + let project_name = Option.value ~default:"root" (atom_field "name" sexps) in 283 + let root_package = 284 + List.find_map 285 + (function 286 + | Sexp.List (Sexp.Atom "package" :: rest) 287 + when atom_field "name" rest = Some project_name -> 288 + Some rest 289 + | _ -> None) 290 + sexps 291 + in 292 + let from_package field = 293 + match root_package with 294 + | None -> None 295 + | Some rest -> string_list_field field rest 296 + in 297 + let maintainers = Option.value ~default:guess (from_package "maintainers") in 298 + let authors = Option.value ~default:guess (from_package "authors") in 299 + let using = 300 + List.filter 301 + (function Sexp.List (Sexp.Atom "using" :: _) -> true | _ -> false) 302 + sexps 303 + in 304 + { project_name; maintainers; authors; using } 305 + 306 + let format_string_list keyword items = 307 + match items with 308 + | [] -> "" 309 + | _ -> 310 + Fmt.str " (%s %s)\n" keyword 311 + (String.concat " " (List.map (Fmt.str "%S") items)) 312 + 313 + let generate_dune_project ident deps = 314 + let buf = Buffer.create 1024 in 315 + Buffer.add_string buf "(lang dune 3.21)\n"; 316 + List.iter 317 + (fun sexp -> 318 + Buffer.add_string buf (Sexp.Value.to_string_compact sexp); 319 + Buffer.add_char buf '\n') 320 + ident.using; 321 + Buffer.add_string buf (Fmt.str "(name %s)\n\n" ident.project_name); 322 + Buffer.add_string buf "(generate_opam_files true)\n\n"; 323 + Buffer.add_string buf "(package\n"; 324 + Buffer.add_string buf (Fmt.str " (name %s)\n" ident.project_name); 325 + Buffer.add_string buf 326 + " (synopsis \"Monorepo root package with external dependencies\")\n"; 327 + Buffer.add_string buf (format_string_list "maintainers" ident.maintainers); 328 + Buffer.add_string buf (format_string_list "authors" ident.authors); 329 + Buffer.add_string buf " (allow_empty)\n"; 330 + Buffer.add_string buf " (depends\n"; 331 + List.iter (fun d -> Buffer.add_string buf (Fmt.str " %s\n" d)) deps; 332 + Buffer.add_string buf " ))\n"; 333 + Buffer.contents buf 334 + 335 + (** {1 Packages: rich or reconstructed from fs} *) 336 + 337 + let subtree_dirs ~fs monorepo = 338 + let eio = Eio.Path.(fs / Fpath.to_string monorepo) in 339 + let entries = try Eio.Path.read_dir eio with Eio.Io _ -> [] in 340 + List.filter 341 + (fun name -> 342 + (not (String.starts_with ~prefix:"." name)) 343 + && (not (String.starts_with ~prefix:"_" name)) 344 + && name <> "src" 345 + && 346 + try Eio.Path.kind ~follow:true Eio.Path.(eio / name) = `Directory 347 + with Eio.Io _ -> false) 348 + entries 349 + |> List.sort String.compare 350 + 351 + let opam_files_in ~fs subtree_path = 352 + let eio = Eio.Path.(fs / Fpath.to_string subtree_path) in 353 + try 354 + Eio.Path.read_dir eio 355 + |> List.filter (fun n -> Filename.check_suffix n ".opam") 356 + with Eio.Io _ -> [] 357 + 358 + (** Reconstruct a [Package.t] from an on-disk [.opam] file. Missing fields fall 359 + back to empty strings so README/llms.txt still render something for 360 + freshly-imported subtrees that haven't published metadata yet. *) 361 + let package_of_opam_file ~fs ~subtree ~monorepo opam_file = 362 + let opam_path = Fpath.(monorepo / subtree / opam_file) in 363 + let file = Fpath.to_string opam_path in 364 + let eio = Eio.Path.(fs / file) in 365 + try 366 + Eio.Path.with_open_in eio (fun flow -> 367 + let r = Bytesrw_eio.bytes_reader_of_flow flow in 368 + let opamfile = Opam_bytesrw.of_reader ~file r in 369 + let items = opamfile.contents in 370 + let name = Filename.chop_suffix opam_file ".opam" in 371 + let dev_repo = 372 + match Opam_repo.dev_repo items with 373 + | Some url -> Opam_repo.normalize_git_url url 374 + | None -> Uri.of_string "" 375 + in 376 + let synopsis = Opam_repo.synopsis items in 377 + let depends = Opam_repo.depends items in 378 + Some (Package.v ~name ~version:"dev" ~dev_repo ~depends ?synopsis ())) 379 + with Eio.Io _ | Opam.Error _ -> None 380 + 381 + let packages_from_fs ~fs ~monorepo = 382 + subtree_dirs ~fs monorepo 383 + |> List.concat_map (fun subtree -> 384 + opam_files_in ~fs Fpath.(monorepo / subtree) 385 + |> List.filter_map (package_of_opam_file ~fs ~subtree ~monorepo)) 386 + 387 + (** {1 Dependency collection} *) 388 + 389 + let collect_external_deps ~fs ~monorepo pkgs = 390 + let seen = Hashtbl.create 16 in 391 + let repos = 392 + List.filter 393 + (fun pkg -> 394 + let repo = Package.repo_name pkg in 395 + if Hashtbl.mem seen repo then false 396 + else begin 397 + Hashtbl.add seen repo (); 398 + true 399 + end) 400 + pkgs 401 + in 402 + let scan_dirs = 403 + match repos with 404 + | [] -> subtree_dirs ~fs monorepo 405 + | _ -> List.map Package.subtree_prefix repos 406 + in 407 + let all_deps = 408 + List.concat_map 409 + (fun dir -> Opam_repo.scan_opam_files_for_deps ~fs Fpath.(monorepo / dir)) 410 + scan_dirs 411 + |> List.sort_uniq String.compare 412 + in 413 + let pkg_names = 414 + List.concat_map 415 + (fun dir -> opam_files_in ~fs Fpath.(monorepo / dir)) 416 + scan_dirs 417 + |> List.map (fun n -> Filename.chop_suffix n ".opam") 418 + |> List.sort_uniq String.compare 419 + in 420 + List.filter (fun dep -> not (List.mem dep pkg_names)) all_deps 421 + 422 + (** {1 Top-level API} *) 423 + 424 + let load_existing ~fs path = 425 + let eio = Eio.Path.(fs / Fpath.to_string path) in 426 + try Eio.Path.load eio with Eio.Io _ -> "" 427 + 428 + let compute ~fs ~monorepo ?packages () = 429 + let pkgs = 430 + match packages with Some p -> p | None -> packages_from_fs ~fs ~monorepo 431 + in 432 + let external_deps = collect_external_deps ~fs ~monorepo pkgs in 433 + let existing = load_existing ~fs Fpath.(monorepo / "dune-project") in 434 + let ident = identity ~fs existing in 435 + { 436 + dune_project = generate_dune_project ident external_deps; 437 + readme = generate_readme pkgs; 438 + llms_txt = generate_llms_txt pkgs; 439 + claude_md; 440 + } 441 + 442 + let load_actual ~fs ~monorepo file = load_existing ~fs Fpath.(monorepo / file) 443 + 444 + let files_of t = 445 + [ 446 + ("dune-project", t.dune_project); 447 + ("README.md", t.readme); 448 + ("llms.txt", t.llms_txt); 449 + ("CLAUDE.md", t.claude_md); 450 + ] 451 + 452 + let check ~fs ~monorepo ?packages () = 453 + let t = compute ~fs ~monorepo ?packages () in 454 + List.filter_map 455 + (fun (file, expected) -> 456 + let actual = load_actual ~fs ~monorepo file in 457 + if actual = expected then None else Some { file; expected; actual }) 458 + (files_of t) 459 + 460 + let write_if_changed ~monorepo_eio file content = 461 + let path = Eio.Path.(monorepo_eio / file) in 462 + let changed = 463 + match Eio.Path.load path with 464 + | existing -> existing <> content 465 + | exception Eio.Io _ -> true 466 + in 467 + if changed then Eio.Path.save ~create:(`Or_truncate 0o644) path content; 468 + changed 469 + 470 + let git_user ~fs () = 471 + match Git_cli.global_git_user ~fs () with 472 + | Some u -> u 473 + | None -> 474 + Git.User.v ~name:"monopam" ~email:"monopam@localhost" 475 + ~date:(Int64.of_float (Unix.time ())) 476 + () 477 + 478 + let regenerate ~sw ~fs ~monorepo ?packages ?(skip = []) () = 479 + let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 480 + let t = compute ~fs ~monorepo ?packages () in 481 + let changed = 482 + List.filter_map 483 + (fun (file, content) -> 484 + if List.mem file skip then None 485 + else if write_if_changed ~monorepo_eio file content then Some file 486 + else None) 487 + (files_of t) 488 + in 489 + (match changed with 490 + | [] -> Log.debug (fun m -> m "Root files already up to date") 491 + | files when not (Git.Repository.is_repo ~fs monorepo) -> 492 + Log.app (fun m -> 493 + m "Regenerated root files: %a" Fmt.(list ~sep:(any ", ") string) files) 494 + | files -> ( 495 + let repo = Git.Repository.open_repo ~sw ~fs monorepo in 496 + match Git.Repository.add_to_index repo files with 497 + | Error (`Msg e) -> 498 + Log.warn (fun m -> m "Failed to stage root files: %s" e) 499 + | Ok () -> ( 500 + let user = git_user ~fs () in 501 + match 502 + Git.Repository.commit_index repo ~author:user ~committer:user 503 + ~message:"Regenerate root files" () 504 + with 505 + | Error (`Msg e) -> 506 + Log.warn (fun m -> m "Failed to commit root files: %s" e) 507 + | Ok _ -> 508 + Log.app (fun m -> 509 + m "Regenerated root files: %a" 510 + Fmt.(list ~sep:(any ", ") string) 511 + files)))); 512 + changed
+74
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 + ?skip:string list -> 65 + unit -> 66 + string list 67 + (** [regenerate ~sw ~fs ~monorepo ?packages ?skip ()] writes the expected 68 + content for each stale root file, stages it via 69 + {!Git.Repository.add_to_index}, and — only if at least one file actually 70 + changed — commits with message ["Regenerate root files"]. [skip] names files 71 + (e.g. ["CLAUDE.md"]) that should be left untouched even when stale, used by 72 + callers that want migration-aware behavior. Returns the list of filenames 73 + that were rewritten (empty when everything was already up to date, in which 74 + case nothing is committed). *)
+3 -3
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
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
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
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
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
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
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
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
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
test/test_root.mli
··· 1 + val suite : string * unit Alcotest.test_case list 2 + (** [suite] is the Alcotest suite covering {!Monopam.Root}. *)