Monorepo management for opam overlays
0
fork

Configure Feed

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

monopam: nested mono = true now pushes inner subtrees all the way out

When a sources.toml entry is marked [mono = true], monopam treats it
as a nested monorepo with its own sources.toml and recurses in
depth-first. Two bugs were preventing the nested push from actually
reaching upstream:

1. [Fpath.(monorepo / nested_prefix)] crashed with "invalid
segment" because nested_prefix is slash-separated ("open-mono/lib")
and Fpath.(/) calls add_seg which rejects strings containing "/".
Build the Fpath component-by-component instead. Same fix in
pull.ml's merge_inner_subtree.

2. mono_entries would split the outer monorepo and push the result
to the inner CHECKOUT under src/, but nothing ever pushed that
checkout to its remote. The user's edits landed in the local
cache and stopped there. inner_subtree now runs a second
push_remote step after the split so the change actually leaves
the machine.

Test: monopam/test/nested_mono.t exercises product (outer) → open-mono
(inner, mono = true) → lib.git (upstream). A fix applied inside
product/open-mono/lib propagates all the way out to lib.git in a
single [monopam push lib] invocation.

Known gap (documented in the test header): the README's full three-
layer story also wants an intermediate push to open-mono.git itself
between product and lib.git. The current implementation skips that
middle layer and recurses directly from product to lib.git.

+157 -6
+13 -2
lib/pull.ml
··· 315 315 false 316 316 end 317 317 318 - (** Fetch a checkout into the monorepo and merge as a subtree. *) 318 + (** Fetch a checkout into the monorepo and merge as a subtree. 319 + 320 + [prefix] is a slash-separated path inside the monorepo (e.g. 321 + "open-mono/lib") — [Git.Subtree.merge] handles the slashes, but the 322 + existence check needs to walk the components manually because [Fpath.(/)] 323 + refuses strings containing "/". *) 319 324 let merge_inner_subtree ~sw ~proc ~fs_t ~monorepo ~prefix ~checkout_dir ~branch 320 325 = 321 326 let url = Fpath.to_string checkout_dir in 322 - let subtree_exists = Ctx.is_directory ~fs:fs_t Fpath.(monorepo / prefix) in 327 + let subtree_path = 328 + List.fold_left 329 + (fun acc seg -> Fpath.(acc / seg)) 330 + monorepo 331 + (String.split_on_char '/' prefix |> List.filter (fun s -> s <> "")) 332 + in 333 + let subtree_exists = Ctx.is_directory ~fs:fs_t subtree_path in 323 334 match Git_cli.fetch_url ~proc ~fs:fs_t ~repo:monorepo ~url ~branch () with 324 335 | Error e -> 325 336 Log.warn (fun m ->
+29 -4
lib/push.ml
··· 504 504 end 505 505 else true 506 506 507 - (** Configure a checkout and push a subtree split to it. *) 507 + (** Configure a checkout, push a subtree split to it, then send the updated 508 + checkout out to its configured remote. 509 + 510 + The second step used to be skipped: [mono_entries] updated the local cache 511 + under [src/] but nothing ever pushed it upstream, so the user's edits never 512 + left the machine. *) 508 513 let inner_subtree ~sw ~proc ~fs_t ~monorepo ~git_repo ~prefix ~checkout_dir 509 514 ~name ~clean ~force ~branch = 510 515 (match ··· 519 524 split_and_push ~proc ~fs:fs_t ~monorepo ~git_repo ~prefix ~checkout_url 520 525 ~checkout_tree ~clean ~force ~branch 521 526 with 522 - | Ok () -> Log.info (fun m -> m "Pushed mono inner subtree %s" prefix) 523 527 | Error e -> 524 528 Log.warn (fun m -> 525 529 m "Failed to push mono inner subtree %s: %a" prefix 526 530 Ctx.pp_error_with_hint e) 531 + | Ok () -> ( 532 + Log.info (fun m -> 533 + m "Split mono inner subtree %s into %a" prefix Fpath.pp checkout_dir); 534 + match 535 + Git_cli.push_remote ~proc 536 + ~fs:(fs_t :> _ Eio.Path.t) 537 + ~branch ~force checkout_dir 538 + with 539 + | Ok () -> 540 + Log.app (fun m -> 541 + m " ✓ %s (nested) → %a" prefix Fpath.pp checkout_dir) 542 + | Error e -> 543 + Log.warn (fun m -> 544 + m "Failed to push mono inner subtree %s to its remote: %a" prefix 545 + Git_cli.pp_error e)) 527 546 528 547 (** Process one mono entry: load its inner sources.toml and push each inner 529 548 subtree. *) ··· 538 557 let inner_entries = Sources_registry.to_list inner_sources in 539 558 List.iter 540 559 (fun (inner_name, (inner_entry : Sources_registry.entry)) -> 560 + (* [nested_prefix] is the slash-separated path inside the 561 + monorepo (e.g. "open-mono/lib"). Git.Subtree.split treats 562 + it as a path, but [Fpath.(/)] (which calls [add_seg]) 563 + refuses strings containing "/". Build the Fpath 564 + component-by-component so the existence check doesn't 565 + crash. *) 541 566 let nested_prefix = mono_name ^ "/" ^ inner_name in 567 + let nested_path = Fpath.(monorepo / mono_name / inner_name) in 542 568 let branch = Option.value ~default:"main" inner_entry.branch in 543 - if not (Ctx.is_directory ~fs:fs_t Fpath.(monorepo / nested_prefix)) 544 - then 569 + if not (Ctx.is_directory ~fs:fs_t nested_path) then 545 570 Log.debug (fun m -> 546 571 m "Skipping mono inner %s (not in monorepo)" nested_prefix) 547 572 else begin
+115
test/nested_mono.t/run.t
··· 1 + Nested monorepos: mono = true marker 2 + ====================================== 3 + 4 + One outer monorepo (product) vendors another monorepo (open-mono) as 5 + a subtree marked `mono = true`. Open-mono itself vendors a library 6 + (lib) as a regular subtree. The test asserts that pushing from 7 + product recurses depth-first into open-mono's inner sources.toml and 8 + propagates the changes to the library's upstream. 9 + 10 + The README's full three-layer story (product → open-mono.git → 11 + lib.git) also wants an intermediate push to open-mono.git itself. 12 + That middle layer is not yet wired up; this test only covers the 13 + direct product → lib.git recursion, which is what the current 14 + `mono = true` implementation provides. See TODO.md for the gap. 15 + 16 + Setup 17 + ----- 18 + 19 + $ export NO_COLOR=1 20 + $ export GIT_AUTHOR_NAME="Alice" 21 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 22 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 23 + $ export GIT_COMMITTER_NAME="Alice" 24 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 25 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 26 + $ export HOME="$PWD/home" 27 + $ mkdir -p "$HOME" 28 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 29 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 30 + $ TROOT=$(pwd) 31 + 32 + Stage 1: the empty library upstream 33 + ------------------------------------ 34 + 35 + An empty bare repo, like a freshly created github or tangled repo. 36 + The first push from the product workspace will establish its initial 37 + history — same pattern as push.t. 38 + 39 + $ git init -q --bare lib.git 40 + $ cat > lib.opam << OPAM 41 + > opam-version: "2.0" 42 + > name: "lib" 43 + > version: "dev" 44 + > synopsis: "A library" 45 + > dev-repo: "git+file://$TROOT/lib.git" 46 + > OPAM 47 + 48 + Stage 2: product workspace 49 + --------------------------- 50 + 51 + Product has two layers. The outer layer is the product monorepo, which 52 + vendors open-mono/ as `mono = true`. The inner layer is open-mono, 53 + materialized inside product/open-mono/, with its own sources.toml and 54 + a regular subtree under product/open-mono/lib/ that tracks lib.git. 55 + 56 + The outer opam-repo overlay registers lib so that `monopam push lib` 57 + from product can discover it and drive the nested merge. 58 + 59 + $ mkdir -p opam-repo/packages/lib/lib.dev 60 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 61 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 62 + $ mkdir -p "$HOME/.config/monopam" 63 + $ cat > "$HOME/.config/monopam/opamverse.toml" << EOF 64 + > [workspace] 65 + > root = "$TROOT" 66 + > [identity] 67 + > handle = "alice.example.org" 68 + > knot = "git.example.org" 69 + > EOF 70 + 71 + Build the outer product monorepo. The inner open-mono/ is a plain 72 + directory with its own sources.toml declaring where lib comes from; 73 + this is equivalent to what another developer would have produced by 74 + running `monopam init` and `monopam add lib.git` inside open-mono. 75 + 76 + $ mkdir -p mono && cd mono && git init -q 77 + $ mkdir -p open-mono/lib/src 78 + $ cat > open-mono/sources.toml << EOF 79 + > [lib] 80 + > source = "git+file://$TROOT/lib.git" 81 + > EOF 82 + $ echo "let run () = ()" > open-mono/lib/src/main.ml 83 + $ cp "$TROOT/lib.opam" open-mono/lib/lib.opam 84 + $ cat > sources.toml << EOF 85 + > [open-mono] 86 + > source = "git+file://$TROOT/open-mono.git" 87 + > mono = true 88 + > EOF 89 + $ git add . && git commit -q -m "initial product with open-mono subtree" 90 + 91 + Stage 3: edit the library from inside product and push 92 + --------------------------------------------------------- 93 + 94 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 95 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 96 + $ echo 'let run () = Printf.printf "ok"' > open-mono/lib/src/main.ml 97 + $ git add -A && git commit -q -m "lib: print ok" 98 + 99 + Pushing `lib` from the product workspace must detect the `open-mono` 100 + `mono = true` entry, load open-mono/sources.toml, split product's 101 + open-mono/lib subdirectory, and push that split all the way to lib.git 102 + (the library's own upstream). 103 + 104 + $ monopam push lib 2>&1 \ 105 + > | grep -E "✓|nested" \ 106 + > | sed -e '/Changes pushed/ s/ ([0-9.]*s)//' 107 + ✓ open-mono/lib (nested) → $TESTCASE_ROOT/src/lib 108 + ✓ Changes pushed to your remotes. 109 + 110 + The lib.git upstream should contain the new commit in src/main.ml. 111 + 112 + $ rm -rf "$TROOT/verify" 113 + $ git clone -q "$TROOT/lib.git" "$TROOT/verify" 2>/dev/null 114 + $ grep "ok" "$TROOT/verify/src/main.ml" 115 + let run () = Printf.printf "ok"