Monorepo management for opam overlays
0
fork

Configure Feed

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

monopam: propagate nested-mono push/pull failures + drop pull auto-add

The nested-mono push/pull paths were swallowing inner-subtree failures
as warnings, so a non-fast-forward upstream push or a real merge
conflict in an inner subtree would silently report success. Three
adversarial cram tests now pin the workflow:

- pull_conflict_multi.t: alice and bob each edit two of three files,
bob also adds a fourth; pull must report both content conflicts
and accept the new file cleanly.
- pull_conflict_structural.t: modify/delete + delete/modify + add/add
in one merge; covers the tree-structure code paths the existing
pull_conflict.t never touched.
- nested_mono_conflict.t: bob pushes to lib.git between alice's edit
and her push in the product workspace; the conflict surfaces
through three layers (product -> open-mono -> lib).

Fixes:

- inner_subtree (push) now returns its push error instead of
logging a warning, so non-fast-forward at lib.git stops the run
with exit 4.
- mono_inner / mono_entries collect inner errors and bubble them
up to push.run, which reports the first one as the command exit
status.
- merge_inner_subtree (pull) collects conflicts and returns them so
the outer pull can include them in the Pull_conflict error.
- fetch_or_clone_inner now resets the local checkout to the remote
HEAD instead of just fetching — without this the depth-first
inner pull merges from the stale local checkout (still at the
pre-push commit) instead of the actual upstream tip.
- subtree (pull) returns Skipped when the prefix doesn't exist in
the mono. Pull no longer auto-creates subtrees: that behaviour
interfered with nested workspaces by materialising libraries at
the outer level when they actually live inside a nested mono.
Use [monopam add] explicitly when you want a new subtree.
- run skips Init.write_readme/claude_md/dune_project on the
conflict path so the git-commit subprocess output doesn't
interleave with the CONFLICT log lines.

+711 -204
+178 -154
lib/pull.ml
··· 12 12 repo_name : string; 13 13 cloned : bool; 14 14 commits_pulled : int; 15 - subtree_added : bool; 16 15 conflicts : Git.Merge.conflict list; 17 16 } 18 17 19 18 (** {1 Subtree Operations} *) 20 19 21 - type subtree_result = Added | Merged | Conflict of Git.Merge.conflict list 20 + type subtree_result = 21 + | Skipped 22 + (** The package's prefix doesn't exist in the mono. Either the user hasn't 23 + run [monopam add] yet, or the package is owned by a nested monorepo 24 + (and is being handled by the depth-first inner-pull walk). Pull never 25 + auto-creates subtrees — that's [monopam add]'s job. *) 26 + | Merged 27 + | Conflict of Git.Merge.conflict list 22 28 23 29 let checkout_prefix_after git_repo new_head ~prefix ~verb = 24 30 let ( let* ) = Result.bind in ··· 42 48 index to the working tree. *) 43 49 wrap_err (Git.Repository.add_all git_repo) 44 50 45 - let subtree_merge_or_add ~git_repo ~prefix ~commit ~user ~url ~hash_hex 46 - ~subtree_exists = 47 - let _ = hash_hex in 48 - let message verb = 49 - Fmt.str "%s '%s/' from %s\n\ngit-subtree-dir: %s\n" verb prefix url prefix 51 + (** Merge an upstream subtree into an existing local prefix. The caller must 52 + have already verified that the prefix exists in the mono — pull does not 53 + auto-create subtrees, that's [monopam add]'s job. Returns [Merged] on a 54 + clean merge or [Conflict] when the merge produced markers. *) 55 + let merge_subtree ~git_repo ~prefix ~commit ~user ~url = 56 + let message = 57 + Fmt.str "Merge '%s/' from %s\n\ngit-subtree-dir: %s\n" prefix url prefix 50 58 in 51 - if subtree_exists then 52 - match 53 - Git.Subtree.merge git_repo ~prefix ~commit ~author:user ~committer:user 54 - ~message:(message "Merge") () 55 - with 56 - | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg)) 57 - | Ok (Git.Subtree.Merged new_head) -> 58 - let ( let* ) = Result.bind in 59 - let* () = 60 - checkout_prefix_after git_repo new_head ~prefix ~verb:"merge" 61 - in 62 - Ok Merged 63 - | Ok (Git.Subtree.Conflicts (new_head, conflicts)) -> 64 - let ( let* ) = Result.bind in 65 - let* () = 66 - checkout_prefix_after git_repo new_head ~prefix ~verb:"merge" 67 - in 68 - Ok (Conflict conflicts) 69 - else 70 - match 71 - Git.Subtree.add git_repo ~prefix ~commit ~author:user ~committer:user 72 - ~message:(message "Add") () 73 - with 74 - | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg)) 75 - | Ok new_head -> 76 - let ( let* ) = Result.bind in 77 - let* () = checkout_prefix_after git_repo new_head ~prefix ~verb:"add" in 78 - Ok Added 59 + match 60 + Git.Subtree.merge git_repo ~prefix ~commit ~author:user ~committer:user 61 + ~message () 62 + with 63 + | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg)) 64 + | Ok (Git.Subtree.Merged new_head) -> 65 + let ( let* ) = Result.bind in 66 + let* () = checkout_prefix_after git_repo new_head ~prefix ~verb:"merge" in 67 + Ok Merged 68 + | Ok (Git.Subtree.Conflicts (new_head, conflicts)) -> 69 + let ( let* ) = Result.bind in 70 + let* () = checkout_prefix_after git_repo new_head ~prefix ~verb:"merge" in 71 + Ok (Conflict conflicts) 79 72 80 73 (** Look up the sources.toml entry for a package. See the identical helper in 81 74 [push.ml] for the rationale — sources.toml is keyed by the local subtree ··· 96 89 | Some r -> r 97 90 | None -> (None, Package.subtree_prefix pkg))) 98 91 92 + (** Resolve the git user to attribute the merge commit to. Falls back to a 93 + placeholder when no global git config is set. *) 94 + let resolve_user ~fs = 95 + match Git_cli.global_git_user ~fs () with 96 + | Some u -> u 97 + | None -> 98 + Git.User.v ~name:"monopam" ~email:"monopam@localhost" 99 + ~date:(Int64.of_float (Unix.time ())) 100 + () 101 + 102 + (** For [--path] subtrees: split the checkout at the upstream path to get a 103 + standalone commit chain we can merge into the local prefix. *) 104 + let split_checkout_for_path ~sw ~proc ~fs ~monorepo ~url ~branch ~checkout_dir 105 + ~path = 106 + let checkout_repo = Git.Repository.open_repo ~sw ~fs checkout_dir in 107 + match Git.Repository.head checkout_repo with 108 + | None -> 109 + Error 110 + (Ctx.Git_error (Git_cli.Io_error "checkout has no HEAD, cannot pull")) 111 + | Some head -> ( 112 + match Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url ~branch () with 113 + | Error e -> Error (Ctx.Git_error e) 114 + | Ok _ -> ( 115 + let git_repo = Git.Repository.open_repo ~sw ~fs monorepo in 116 + match Git.Subtree.split git_repo ~prefix:path ~head () with 117 + | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg)) 118 + | Ok None -> 119 + Error (Ctx.Git_error (Git_cli.Subtree_prefix_missing path)) 120 + | Ok (Some h) -> Ok (git_repo, h))) 121 + 99 122 let subtree ~sw ~proc ~fs ~config ?sources pkg = 100 123 let fs = Ctx.fs_typed fs in 101 124 let monorepo = Config.Paths.monorepo config in ··· 104 127 let branch = Ctx.branch ~config pkg in 105 128 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 106 129 let url = Fpath.to_string checkout_dir in 107 - let subtree_exists = Ctx.is_directory ~fs Fpath.(monorepo / prefix) in 108 - let path_override = 109 - Option.bind entry (fun (e : Sources_registry.entry) -> e.path) 110 - in 111 - match path_override with 112 - | Some path -> ( 113 - (* Fetch the checkout (which is a clone of the source monorepo) 114 - by ref name so its objects are visible from the monorepo 115 - repo, then split that ref at [path] to get the subtree 116 - history and merge it in at [prefix]. *) 117 - let checkout_repo = Git.Repository.open_repo ~sw ~fs checkout_dir in 118 - let checkout_head = Git.Repository.head checkout_repo in 119 - match checkout_head with 120 - | None -> 121 - Error 122 - (Ctx.Git_error 123 - (Git_cli.Io_error "checkout has no HEAD, cannot pull")) 124 - | Some head -> ( 125 - (* Fetch the checkout into the monorepo's object db so 126 - the split result's parents are reachable. *) 127 - match Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url ~branch () with 128 - | Error e -> Error (Ctx.Git_error e) 129 - | Ok _ -> ( 130 - let git_repo = Git.Repository.open_repo ~sw ~fs monorepo in 131 - match Git.Subtree.split git_repo ~prefix:path ~head () with 132 - | Error (`Msg msg) -> Error (Ctx.Git_error (Git_cli.Io_error msg)) 133 - | Ok None -> 134 - Error (Ctx.Git_error (Git_cli.Subtree_prefix_missing path)) 135 - | Ok (Some split_hash) -> 136 - let user = 137 - match Git_cli.global_git_user ~fs () with 138 - | Some u -> u 139 - | None -> 140 - Git.User.v ~name:"monopam" ~email:"monopam@localhost" 141 - ~date:(Int64.of_float (Unix.time ())) 142 - () 143 - in 144 - Log.info (fun m -> 145 - m "%s subtree %s from %a (path=%s)" 146 - (if subtree_exists then "Pulling" else "Adding") 147 - prefix Fpath.pp checkout_dir path); 148 - subtree_merge_or_add ~git_repo ~prefix ~commit:split_hash 149 - ~user ~url 150 - ~hash_hex:(Git.Hash.to_hex split_hash) 151 - ~subtree_exists))) 152 - | None -> ( 153 - match Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url ~branch () with 154 - | Error e -> Error (Ctx.Git_error e) 155 - | Ok hash_hex -> 156 - let git_repo = Git.Repository.open_repo ~sw ~fs monorepo in 157 - let commit = Git.Hash.of_hex hash_hex in 158 - let user = 159 - match Git_cli.global_git_user ~fs () with 160 - | Some u -> u 161 - | None -> 162 - Git.User.v ~name:"monopam" ~email:"monopam@localhost" 163 - ~date:(Int64.of_float (Unix.time ())) 164 - () 165 - in 166 - Log.info (fun m -> 167 - m "%s subtree %s from %a" 168 - (if subtree_exists then "Pulling" else "Adding") 169 - prefix Fpath.pp checkout_dir); 170 - subtree_merge_or_add ~git_repo ~prefix ~commit ~user ~url ~hash_hex 171 - ~subtree_exists) 130 + if not (Ctx.is_directory ~fs Fpath.(monorepo / prefix)) then begin 131 + (* The prefix doesn't exist in the mono. Either the user hasn't 132 + run [monopam add] yet, or this package is owned by a nested 133 + mono and the depth-first walk above already handled it. Either 134 + way, pull never auto-creates subtrees — that would silently 135 + materialise libraries at the wrong location in nested workspaces. *) 136 + Log.debug (fun m -> m "Pull: prefix %s not in monorepo, skipping" prefix); 137 + Ok Skipped 138 + end 139 + else 140 + let path_override = 141 + Option.bind entry (fun (e : Sources_registry.entry) -> e.path) 142 + in 143 + let user = resolve_user ~fs in 144 + match path_override with 145 + | Some path -> ( 146 + match 147 + split_checkout_for_path ~sw ~proc ~fs ~monorepo ~url ~branch 148 + ~checkout_dir ~path 149 + with 150 + | Error e -> Error e 151 + | Ok (git_repo, split_hash) -> 152 + Log.info (fun m -> 153 + m "Pulling subtree %s from %a (path=%s)" prefix Fpath.pp 154 + checkout_dir path); 155 + merge_subtree ~git_repo ~prefix ~commit:split_hash ~user ~url) 156 + | None -> ( 157 + match Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url ~branch () with 158 + | Error e -> Error (Ctx.Git_error e) 159 + | Ok hash_hex -> 160 + let git_repo = Git.Repository.open_repo ~sw ~fs monorepo in 161 + let commit = Git.Hash.of_hex hash_hex in 162 + Log.info (fun m -> 163 + m "Pulling subtree %s from %a" prefix Fpath.pp checkout_dir); 164 + merge_subtree ~git_repo ~prefix ~commit ~user ~url) 172 165 173 166 (** {1 Main Pull Operation} *) 174 167 ··· 256 249 repo_name; 257 250 cloned = not existed; 258 251 commits_pulled; 259 - subtree_added = false; 260 252 conflicts = []; 261 253 } 262 254 in ··· 280 272 total); 281 273 Log.info (fun m -> m "Subtree %s" name); 282 274 match subtree ~sw ~proc ~fs ~config ?sources pkg with 283 - | Ok Added -> 275 + | Ok Skipped -> 284 276 Tty.Progress.tick progress; 285 - let result = { cr with subtree_added = true; conflicts = [] } in 286 - loop (result :: results_acc) rest_repos rest_cr 277 + loop (cr :: results_acc) rest_repos rest_cr 287 278 | Ok Merged -> 288 279 Tty.Progress.tick progress; 289 - let result = { cr with subtree_added = false; conflicts = [] } in 280 + let result = { cr with conflicts = [] } in 290 281 loop (result :: results_acc) rest_repos rest_cr 291 282 | Ok (Conflict conflicts) -> 292 283 Tty.Progress.tick progress; 293 - let result = { cr with subtree_added = false; conflicts } in 284 + let result = { cr with conflicts } in 294 285 loop (result :: results_acc) rest_repos rest_cr 295 286 | Error e -> 296 287 Tty.Progress.clear progress; ··· 306 297 let updated = 307 298 List.filter (fun r -> (not r.cloned) && r.commits_pulled > 0) results 308 299 in 309 - let added = List.filter (fun r -> r.subtree_added) results in 310 300 let conflicted = List.filter (fun r -> r.conflicts <> []) results in 311 301 List.iter (fun r -> Log.app (fun m -> m " + %s (cloned)" r.repo_name)) cloned; 312 302 List.iter 313 303 (fun r -> 314 304 Log.app (fun m -> m " ✓ %s (%d commits)" r.repo_name r.commits_pulled)) 315 305 updated; 316 - List.iter (fun r -> Log.app (fun m -> m " + %s (added)" r.repo_name)) added; 317 306 List.iter 318 307 (fun r -> 319 308 List.iter ··· 323 312 conflicted; 324 313 let unchanged = 325 314 List.length results - List.length cloned - List.length updated 326 - - List.length added - List.length conflicted 315 + - List.length conflicted 327 316 in 328 - if cloned = [] && updated = [] && added = [] && conflicted = [] then 317 + if cloned = [] && updated = [] && conflicted = [] then 329 318 Log.app (fun m -> 330 319 m " All %d repositories up to date." (List.length results)) 331 320 else if unchanged > 0 then Log.app (fun m -> m " %d unchanged." unchanged) ··· 344 333 | exception _ -> false 345 334 in 346 335 if is_repo then ( 347 - match Git_cli.fetch ~proc ~fs:(fs_t :> _ Eio.Path.t) checkout_dir with 336 + (* Bring the checkout fully up to date with the remote — fetch and 337 + reset to upstream HEAD. The reset is necessary because a previous 338 + failed [monopam push] may have left the local checkout pointing at 339 + a now-divergent commit (e.g. Alice's split, but the remote was 340 + updated by Bob in between). The checkout is a derived cache; the 341 + authoritative state is the remote. *) 342 + match 343 + Git_cli.fetch_and_reset ~proc 344 + ~fs:(fs_t :> _ Eio.Path.t) 345 + ~branch checkout_dir 346 + with 348 347 | Ok () -> true 349 348 | Error e -> 350 349 Log.warn (fun m -> m "Failed to fetch %s: %a" name Git_cli.pp_error e); ··· 362 361 false 363 362 end 364 363 365 - (** Fetch a checkout into the monorepo and merge as a subtree. 364 + (** Pull one inner subtree of a nested mono. Returns the conflicts (if any) so 365 + the outer pull can decide whether to exit 4. Fetch failures are logged and 366 + yield no conflicts (the merge wasn't attempted). The inner subtree must 367 + already exist — if it doesn't, this is the wrong code path (the user should 368 + have run [monopam add] inside the nested mono). 366 369 367 370 [prefix] is a slash-separated path inside the monorepo (e.g. 368 - "open-mono/lib") — [Git.Subtree.merge] handles the slashes, but the 369 - existence check needs to walk the components manually because [Fpath.(/)] 370 - refuses strings containing "/". *) 371 + [open-mono/lib]). [Git.Subtree.merge] handles the slashes, but the existence 372 + check walks components manually because [Fpath.(/)] refuses strings 373 + containing slashes. *) 371 374 let merge_inner_subtree ~sw ~proc ~fs_t ~monorepo ~prefix ~checkout_dir ~branch 372 375 = 373 376 let url = Fpath.to_string checkout_dir in ··· 377 380 monorepo 378 381 (String.split_on_char '/' prefix |> List.filter (fun s -> s <> "")) 379 382 in 380 - let subtree_exists = Ctx.is_directory ~fs:fs_t subtree_path in 381 - match Git_cli.fetch_url ~proc ~fs:fs_t ~repo:monorepo ~url ~branch () with 382 - | Error e -> 383 - Log.warn (fun m -> 384 - m "Failed to fetch %s into monorepo: %a" prefix Git_cli.pp_error e) 385 - | Ok hash_hex -> ( 386 - let git_repo = Git.Repository.open_repo ~sw ~fs:fs_t monorepo in 387 - let commit = Git.Hash.of_hex hash_hex in 388 - let user = 389 - match Git_cli.global_git_user ~fs:fs_t () with 390 - | Some u -> u 391 - | None -> 392 - Git.User.v ~name:"monopam" ~email:"monopam@localhost" 393 - ~date:(Int64.of_float (Unix.time ())) 394 - () 395 - in 396 - match 397 - subtree_merge_or_add ~git_repo ~prefix ~commit ~user ~url ~hash_hex 398 - ~subtree_exists 399 - with 400 - | Ok _added -> Log.info (fun m -> m "Pulled mono inner subtree %s" prefix) 401 - | Error e -> 402 - Log.warn (fun m -> 403 - m "Failed to merge mono inner subtree %s: %a" prefix 404 - Ctx.pp_error_with_hint e)) 383 + if not (Ctx.is_directory ~fs:fs_t subtree_path) then begin 384 + Log.debug (fun m -> m "Inner subtree %s not in monorepo, skipping" prefix); 385 + [] 386 + end 387 + else 388 + match Git_cli.fetch_url ~proc ~fs:fs_t ~repo:monorepo ~url ~branch () with 389 + | Error e -> 390 + Log.warn (fun m -> 391 + m "Failed to fetch %s into monorepo: %a" prefix Git_cli.pp_error e); 392 + [] 393 + | Ok hash_hex -> ( 394 + let git_repo = Git.Repository.open_repo ~sw ~fs:fs_t monorepo in 395 + let commit = Git.Hash.of_hex hash_hex in 396 + let user = resolve_user ~fs:fs_t in 397 + match merge_subtree ~git_repo ~prefix ~commit ~user ~url with 398 + | Ok Skipped | Ok Merged -> 399 + Log.info (fun m -> m "Pulled mono inner subtree %s" prefix); 400 + [] 401 + | Ok (Conflict cs) -> 402 + List.iter 403 + (fun (c : Git.Merge.conflict) -> 404 + Log.app (fun m -> m "CONFLICT in %s/%s" prefix c.path)) 405 + cs; 406 + List.map (fun (c : Git.Merge.conflict) -> prefix ^ "/" ^ c.path) cs 407 + | Error e -> 408 + Log.warn (fun m -> 409 + m "Failed to merge mono inner subtree %s: %a" prefix 410 + Ctx.pp_error_with_hint e); 411 + []) 405 412 406 - (** Pull inner subtrees of every nested monorepo found in the workspace. A 407 - subtree is a nested monorepo iff its directory contains a [sources.toml] 408 - file — no flag, no marker required. *) 413 + (** Pull inner subtrees of every nested monorepo found in the workspace. Returns 414 + the list of conflicted paths so the outer pull can exit 4 with the right 415 + [Pull_conflict] error. A subtree is a nested monorepo iff its directory 416 + contains a [sources.toml] file. *) 409 417 let mono_entries ~sw ~proc ~fs ~config = 410 418 let fs_t = Ctx.fs_typed fs in 411 419 let monorepo = Config.Paths.monorepo config in ··· 417 425 | Error _ -> None 418 426 in 419 427 let nested = Ctx.nested_monos ~fs:fs_t ~monorepo ~sources:outer_sources in 420 - if nested <> [] then begin 428 + if nested = [] then [] 429 + else begin 421 430 Log.info (fun m -> 422 431 m "Processing %d nested monorepo(s) for inner subtree pull" 423 432 (List.length nested)); 433 + let conflicts = ref [] in 424 434 List.iter 425 435 (fun (mono_name, _entry) -> 426 436 let inner_sources_path = ··· 447 457 ~name:inner_name ~label:nested_prefix ~branch 448 458 in 449 459 if fetched then 450 - merge_inner_subtree ~sw ~proc ~fs_t ~monorepo 451 - ~prefix:nested_prefix ~checkout_dir ~branch) 460 + let cs = 461 + merge_inner_subtree ~sw ~proc ~fs_t ~monorepo 462 + ~prefix:nested_prefix ~checkout_dir ~branch 463 + in 464 + conflicts := cs @ !conflicts) 452 465 inner_entries) 453 - nested 466 + nested; 467 + List.rev !conflicts 454 468 end 455 469 456 470 let run ~sw ~proc ~fs ~config ?(packages = []) ?opam_repo_url () = ··· 480 494 in 481 495 if dirty <> [] then Error (Ctx.Dirty_state dirty) 482 496 else begin 483 - (* Pull mono inner subtrees first (depth-first) *) 484 - mono_entries ~sw ~proc ~fs ~config; 497 + (* Pull mono inner subtrees first (depth-first); collect any 498 + conflicts from the inner merges so they're reported alongside 499 + outer conflicts at the end. *) 500 + let inner_conflict_paths = mono_entries ~sw ~proc ~fs ~config in 485 501 (* Load sources.toml so [subtree] can honor per-entry [path] 486 502 overrides. For legacy workspaces without sources.toml the 487 503 load returns an empty registry; the path field is always ··· 503 519 process_subtrees ~sw ~proc ~fs ~config ?sources repos checkout_results 504 520 in 505 521 log_pull_results results; 506 - Init.write_readme ~proc ~fs:fs_t ~config all_pkgs; 507 - Init.write_claude_md ~proc ~fs:fs_t ~config; 508 - Init.write_dune_project ~proc ~fs:fs_t ~config all_pkgs; 509 - (* Check for merge conflicts *) 510 - let all_conflicts = 522 + (* Collect conflicts BEFORE running Init.write_* so we can short 523 + circuit cleanly. The write_* helpers spawn git commit 524 + subprocesses whose output is interleaved with our logs; 525 + leaving them out on the conflict path keeps stdout 526 + deterministic for the user (and for cram tests grepping the 527 + output). *) 528 + let outer_conflicts = 511 529 List.concat_map 512 530 (fun r -> 513 531 List.map ··· 515 533 r.conflicts) 516 534 results 517 535 in 536 + let all_conflicts = inner_conflict_paths @ outer_conflicts in 537 + if all_conflicts = [] then begin 538 + Init.write_readme ~proc ~fs:fs_t ~config all_pkgs; 539 + Init.write_claude_md ~proc ~fs:fs_t ~config; 540 + Init.write_dune_project ~proc ~fs:fs_t ~config all_pkgs 541 + end; 518 542 if all_conflicts <> [] then 519 543 Error 520 544 (Ctx.Pull_conflict
+9 -5
lib/pull.mli
··· 4 4 repo_name : string; 5 5 cloned : bool; 6 6 commits_pulled : int; 7 - subtree_added : bool; 8 7 conflicts : Git.Merge.conflict list; 9 8 } 10 9 (** Result of a pull operation for a single repository. *) 11 10 12 - type subtree_result = Added | Merged | Conflict of Git.Merge.conflict list 11 + type subtree_result = 12 + | Skipped (** Prefix not present in the mono. *) 13 + | Merged (** Clean merge applied. *) 14 + | Conflict of Git.Merge.conflict list (** Merge produced conflict markers. *) 13 15 14 16 val subtree : 15 17 sw:Eio.Switch.t -> ··· 19 21 ?sources:Sources_registry.t -> 20 22 Package.t -> 21 23 (subtree_result, Ctx.error) Stdlib.result 22 - (** [subtree ~sw ~proc ~fs ~config ?sources pkg] merges or adds the subtree for 23 - [pkg]. Returns [Added] if newly added, [Merged] for a clean merge, or 24 - [Conflict conflicts] when the merge produced conflict markers. *) 24 + (** [subtree ~sw ~proc ~fs ~config ?sources pkg] merges upstream changes into 25 + the subtree for [pkg]. Returns [Skipped] when the prefix doesn't exist in 26 + the mono (pull never auto-creates subtrees — that's [monopam add]'s job), 27 + [Merged] for a clean merge, or [Conflict conflicts] when conflict markers 28 + were produced. *) 25 29 26 30 val run : 27 31 sw:Eio.Switch.t ->
+67 -45
lib/push.ml
··· 495 495 (** Configure a checkout, push a subtree split to it, then send the updated 496 496 checkout out to its configured remote. 497 497 498 - The second step used to be skipped: [mono_entries] updated the local cache 499 - under [src/] but nothing ever pushed it upstream, so the user's edits never 500 - left the machine. *) 498 + Returns [Error] when the upstream push fails so the outer push command can 499 + report the right exit code. The local-checkout step is wrapped in a warning 500 + because it should only fail on a corrupt working copy, not on a remote race. 501 + *) 501 502 let inner_subtree ~sw ~proc ~fs_t ~monorepo ~git_repo ~prefix ~checkout_dir 502 503 ~name ~clean ~force ~branch = 503 504 (match ··· 512 513 split_and_push ~proc ~fs:fs_t ~monorepo ~git_repo ~prefix ~checkout_url 513 514 ~checkout_tree ~clean ~force ~branch 514 515 with 515 - | Error e -> 516 - Log.warn (fun m -> 517 - m "Failed to push mono inner subtree %s: %a" prefix 518 - Ctx.pp_error_with_hint e) 516 + | Error e -> Error e 519 517 | Ok () -> ( 520 518 Log.info (fun m -> 521 519 m "Split mono inner subtree %s into %a" prefix Fpath.pp checkout_dir); ··· 526 524 with 527 525 | Ok () -> 528 526 Log.app (fun m -> 529 - m " ✓ %s (nested) → %a" prefix Fpath.pp checkout_dir) 530 - | Error e -> 531 - Log.warn (fun m -> 532 - m "Failed to push mono inner subtree %s to its remote: %a" prefix 533 - Git_cli.pp_error e)) 527 + m " ✓ %s (nested) → %a" prefix Fpath.pp checkout_dir); 528 + Ok () 529 + | Error e -> Error (Ctx.Git_error e)) 534 530 535 531 (** Process one mono entry: load its inner sources.toml and push each inner 536 - subtree. *) 532 + subtree. Returns the list of errors encountered (empty on success). *) 537 533 let mono_inner ~sw ~proc ~fs_t ~monorepo ~checkouts_root ~git_repo ~clean ~force 538 534 mono_name = 539 535 let inner_sources_path = Fpath.(monorepo / mono_name / "sources.toml") in 540 536 match Sources_registry.load ~fs:(fs_t :> _ Eio.Path.t) inner_sources_path with 541 537 | Error msg -> 542 538 Log.warn (fun m -> 543 - m "Failed to load %a: %s" Fpath.pp inner_sources_path msg) 539 + m "Failed to load %a: %s" Fpath.pp inner_sources_path msg); 540 + [] 544 541 | Ok inner_sources -> 542 + let errors = ref [] in 545 543 let inner_entries = Sources_registry.to_list inner_sources in 546 544 List.iter 547 545 (fun (inner_name, (inner_entry : Sources_registry.entry)) -> ··· 565 563 ~name:inner_name ~label:nested_prefix ~branch 566 564 in 567 565 if cloned then 568 - inner_subtree ~sw ~proc ~fs_t ~monorepo ~git_repo 569 - ~prefix:nested_prefix ~checkout_dir ~name:inner_name ~clean 570 - ~force ~branch 566 + match 567 + inner_subtree ~sw ~proc ~fs_t ~monorepo ~git_repo 568 + ~prefix:nested_prefix ~checkout_dir ~name:inner_name ~clean 569 + ~force ~branch 570 + with 571 + | Ok () -> () 572 + | Error e -> errors := e :: !errors 571 573 end) 572 - inner_entries 574 + inner_entries; 575 + List.rev !errors 573 576 574 577 (** Push the outer subtree of a nested monorepo to the inner mono's own remote. 575 578 This is the middle layer of the depth-first push: ··· 604 607 ~name:mono_name ~label:mono_name ~branch 605 608 in 606 609 if cloned then 607 - inner_subtree ~sw ~proc ~fs_t ~monorepo ~git_repo ~prefix:mono_name 608 - ~checkout_dir ~name:mono_name ~clean ~force ~branch 610 + match 611 + inner_subtree ~sw ~proc ~fs_t ~monorepo ~git_repo ~prefix:mono_name 612 + ~checkout_dir ~name:mono_name ~clean ~force ~branch 613 + with 614 + | Ok () -> () 615 + | Error e -> 616 + Log.warn (fun m -> 617 + m "Failed to push mono outer subtree %s: %a" mono_name 618 + Ctx.pp_error_with_hint e) 609 619 end 610 620 611 621 (** Push every nested monorepo found in the workspace. A subtree is a nested ··· 616 626 let monorepo = Config.Paths.monorepo config in 617 627 let checkouts_root = Config.Paths.checkouts config in 618 628 let nested = Ctx.nested_monos ~fs:fs_t ~monorepo ~sources in 619 - if nested <> [] then begin 629 + if nested = [] then [] 630 + else begin 620 631 Log.info (fun m -> 621 632 m "Processing %d nested monorepo(s) for inner subtree push" 622 633 (List.length nested)); 623 634 let git_repo = Git.Repository.open_repo ~sw ~fs:fs_t monorepo in 624 635 (* Depth-first: inner subtrees first, then the outer mono itself. *) 625 - List.iter 626 - (fun (mono_name, _entry) -> 627 - mono_inner ~sw ~proc ~fs_t ~monorepo ~checkouts_root ~git_repo ~clean 628 - ~force mono_name) 629 - nested; 636 + let inner_errors = 637 + List.concat_map 638 + (fun (mono_name, _entry) -> 639 + mono_inner ~sw ~proc ~fs_t ~monorepo ~checkouts_root ~git_repo ~clean 640 + ~force mono_name) 641 + nested 642 + in 630 643 List.iter 631 644 (fun (mono_name, entry) -> 632 645 push_mono_outer_subtree ~sw ~proc ~fs_t ~monorepo ~checkouts_root 633 646 ~git_repo ~clean ~force mono_name entry) 634 - nested 647 + nested; 648 + inner_errors 635 649 end 636 650 637 651 let log_missing_repos ~all_pkgs missing = ··· 755 769 Log.warn (fun m -> m "Opam sync failed: %s" msg)); 756 770 let sources = load_sources ~fs:fs_t ~config in 757 771 (* Push mono inner subtrees first (depth-first) *) 758 - mono_entries ~sw ~proc ~fs ~config ~sources ~clean ~force; 759 - let to_push = repos_to_push statuses pkgs in 760 - Log.info (fun m -> m "Pushing %d unique repos" (List.length to_push)); 761 - let push_mono = packages = [] in 762 - if to_push = [] then begin 763 - Log.app (fun m -> m "Nothing to push (all repos in sync)"); 764 - let ws_errors = 765 - if upstream then 766 - workspace_repos ~sw ~proc ~fs:fs_t ~config ~force ~push_mono 767 - else [] 768 - in 769 - if ws_errors <> [] then 770 - let _name, e = List.hd ws_errors in 771 - Error (Ctx.Git_error e) 772 - else Ok () 773 - end 772 + let inner_errors = 773 + mono_entries ~sw ~proc ~fs ~config ~sources ~clean ~force 774 + in 775 + if inner_errors <> [] then 776 + (* Failure inside a nested mono push (e.g. non-fast-forward 777 + on lib.git). Report the first one — they all need attention. *) 778 + Error (List.hd inner_errors) 774 779 else 775 - export_and_push ~sw ~proc ~fs ~fs_t ~config ~sources ~upstream 776 - ~push_mono ~clean ~force ~all_pkgs:pkgs to_push 780 + let to_push = repos_to_push statuses pkgs in 781 + Log.info (fun m -> 782 + m "Pushing %d unique repos" (List.length to_push)); 783 + let push_mono = packages = [] in 784 + if to_push = [] then begin 785 + Log.app (fun m -> m "Nothing to push (all repos in sync)"); 786 + let ws_errors = 787 + if upstream then 788 + workspace_repos ~sw ~proc ~fs:fs_t ~config ~force ~push_mono 789 + else [] 790 + in 791 + if ws_errors <> [] then 792 + let _name, e = List.hd ws_errors in 793 + Error (Ctx.Git_error e) 794 + else Ok () 795 + end 796 + else 797 + export_and_push ~sw ~proc ~fs ~fs_t ~config ~sources ~upstream 798 + ~push_mono ~clean ~force ~all_pkgs:pkgs to_push 777 799 end 778 800 end
+147
test/nested_mono_conflict.t/run.t
··· 1 + Nested monorepos: conflict in the inner subtree 2 + ================================================= 3 + 4 + Same three-layer setup as nested_mono.t (product → open-mono → 5 + lib), but a third-party developer pushes to lib.git between the 6 + product workspace's edits and its push. The conflict surfaces in 7 + product/open-mono/lib/src/main.ml — the deepest layer — and the 8 + pull must propagate it correctly without leaving the inner 9 + checkouts in a half-merged state. 10 + 11 + Setup 12 + ----- 13 + 14 + $ export NO_COLOR=1 15 + $ export GIT_AUTHOR_NAME="Alice" 16 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 17 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 18 + $ export GIT_COMMITTER_NAME="Alice" 19 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 20 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 21 + $ export HOME="$PWD/home" 22 + $ mkdir -p "$HOME" 23 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 24 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 25 + $ TROOT=$(pwd) 26 + 27 + Stage 1: empty bare upstreams 28 + ------------------------------ 29 + 30 + $ git init -q --bare lib.git 31 + $ git init -q --bare open-mono.git 32 + $ cat > lib.opam << OPAM 33 + > opam-version: "2.0" 34 + > name: "lib" 35 + > version: "dev" 36 + > synopsis: "A library" 37 + > dev-repo: "git+file://$TROOT/lib.git" 38 + > OPAM 39 + 40 + Stage 2: product workspace with open-mono nested inside 41 + -------------------------------------------------------- 42 + 43 + $ mkdir -p opam-repo/packages/lib/lib.dev 44 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 45 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 46 + $ mkdir -p "$HOME/.config/monopam" 47 + $ cat > "$HOME/.config/monopam/opamverse.toml" << EOF 48 + > [workspace] 49 + > root = "$TROOT" 50 + > [identity] 51 + > handle = "alice.example.org" 52 + > knot = "git.example.org" 53 + > EOF 54 + $ mkdir -p mono && cd mono && git init -q 55 + $ mkdir -p open-mono/lib/src 56 + $ cat > open-mono/sources.toml << EOF 57 + > [lib] 58 + > source = "git+file://$TROOT/lib.git" 59 + > EOF 60 + $ echo "let banner = \"v1\"" > open-mono/lib/src/main.ml 61 + $ cp "$TROOT/lib.opam" open-mono/lib/lib.opam 62 + $ cat > sources.toml << EOF 63 + > [open-mono] 64 + > source = "git+file://$TROOT/open-mono.git" 65 + > EOF 66 + $ git add . && git commit -q -m "initial product with open-mono subtree" 67 + $ monopam push lib > /dev/null 2>&1 68 + 69 + Stage 3: Alice edits the inner library and commits in product 70 + -------------------------------------------------------------- 71 + 72 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 73 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 74 + $ printf 'let banner = "v2-alice"\n' > open-mono/lib/src/main.ml 75 + $ git add -A && git commit -q -m "lib: alice's banner via product" 76 + 77 + Stage 4: Bob pushes a divergent edit directly to lib.git 78 + --------------------------------------------------------- 79 + 80 + $ cd "$TROOT" 81 + $ git clone -q lib.git lib-other 2>/dev/null 82 + $ cd lib-other 83 + $ export GIT_AUTHOR_NAME="Bob" 84 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 85 + $ export GIT_AUTHOR_DATE="2025-02-01T01:00:00+00:00" 86 + $ export GIT_COMMITTER_NAME="Bob" 87 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 88 + $ export GIT_COMMITTER_DATE="2025-02-01T01:00:00+00:00" 89 + $ printf 'let banner = "v2-bob"\n' > src/main.ml 90 + $ git add -A && git commit -q -m "lib: bob's banner" 91 + $ git push -q origin main 2>/dev/null 92 + $ cd "$TROOT/mono" 93 + 94 + Stage 5: Alice's push fails because lib.git has diverged 95 + --------------------------------------------------------- 96 + 97 + $ export GIT_AUTHOR_NAME="Alice" 98 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 99 + $ export GIT_COMMITTER_NAME="Alice" 100 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 101 + $ monopam push lib > /tmp/push.out 2>&1 102 + [4] 103 + 104 + Stage 6: Pull surfaces the conflict deep inside the nested mono 105 + ---------------------------------------------------------------- 106 + 107 + The conflict path is reported relative to the inner mono prefix. 108 + Pull exits 4 with the standard hint. 109 + 110 + $ monopam pull lib > /tmp/pull.out 2>&1 111 + [4] 112 + $ grep -F "Hint:" /tmp/pull.out 113 + Hint: Edit the conflicted files under mono/, stage the resolution with 'git add', 'git commit', and run 'monopam push' again. 114 + $ grep -E "^CONFLICT" /tmp/pull.out 115 + CONFLICT in open-mono/lib/src/main.ml 116 + 117 + The conflict path is reported relative to the outer mono root — 118 + the [open-mono/lib] prefix is the inner mono's mount point and 119 + [src/main.ml] is the path inside the lib subtree. 120 + 121 + The deeply nested file has standard git conflict markers: 122 + 123 + $ grep -c "<<<<<<< " open-mono/lib/src/main.ml 124 + 1 125 + $ grep "v2-alice" open-mono/lib/src/main.ml 126 + let banner = "v2-alice" 127 + $ grep "v2-bob" open-mono/lib/src/main.ml 128 + let banner = "v2-bob" 129 + 130 + Stage 7: Resolve and push back through all three layers 131 + -------------------------------------------------------- 132 + 133 + $ printf 'let banner = "v2-alice+bob"\n' > open-mono/lib/src/main.ml 134 + $ git add open-mono/lib/src/main.ml 135 + $ git commit -q -m "lib: resolve banner conflict in nested mono" 136 + $ monopam push lib 2>&1 \ 137 + > | grep -F "Changes pushed" \ 138 + > | sed 's/ ([0-9.]*s)//' 139 + ✓ Changes pushed to your remotes. 140 + 141 + The resolution reaches lib.git through the depth-first push: 142 + 143 + $ rm -rf "$TROOT/verify" 144 + $ git clone -q "$TROOT/lib.git" "$TROOT/verify" 2>/dev/null 145 + $ cd "$TROOT/verify" 146 + $ cat src/main.ml 147 + let banner = "v2-alice+bob"
+159
test/pull_conflict_multi.t/run.t
··· 1 + monopam pull: multiple conflicting files in one merge 2 + ======================================================== 3 + 4 + A library has three files. Alice edits two of them locally. 5 + Bob pushes edits to the same two files (different content) 6 + plus a third file Alice didn't touch. Alice's pull should 7 + cleanly accept Bob's edit to the third file, conflict on both 8 + files Alice and Bob both edited, and exit 4 with one CONFLICT 9 + line per conflicted file. 10 + 11 + This pins multi-file batching for the future --auto resolver. 12 + The pull_conflict.t test only proves the single-file case; 13 + this proves the resolver visits every conflict in one pass 14 + without short-circuiting after the first. 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: empty lib upstream + opam-repo overlay 33 + ------------------------------------------------- 34 + 35 + $ git init -q --bare lib.git 36 + $ cat > lib.opam << OPAM 37 + > opam-version: "2.0" 38 + > name: "lib" 39 + > version: "dev" 40 + > synopsis: "L" 41 + > dev-repo: "git+file://$TROOT/lib.git" 42 + > OPAM 43 + $ mkdir -p opam-repo/packages/lib/lib.dev 44 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 45 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 46 + $ cat > opamverse.toml << EOF 47 + > [workspace] 48 + > root = "$TROOT" 49 + > [identity] 50 + > handle = "alice.example.org" 51 + > knot = "git.example.org" 52 + > EOF 53 + 54 + Stage 2: Alice's mono with lib/ holding three files 55 + ---------------------------------------------------- 56 + 57 + $ mkdir -p mono && cd mono 58 + $ git init -q 59 + $ mkdir -p lib/src 60 + $ cp "$TROOT/lib.opam" lib/lib.opam 61 + $ printf 'let banner = "v1"\n' > lib/src/banner.ml 62 + $ printf 'let greeting = "hello"\n' > lib/src/greeting.ml 63 + $ printf 'let unedited = "stable"\n' > lib/src/unedited.ml 64 + $ git add . && git commit -q -m "initial mono" 65 + $ monopam push lib > /dev/null 2>&1 66 + 67 + Stage 3: Alice edits banner.ml and greeting.ml, leaves unedited.ml 68 + ------------------------------------------------------------------- 69 + 70 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 71 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 72 + $ printf 'let banner = "v2-alice"\n' > lib/src/banner.ml 73 + $ printf 'let greeting = "hi-alice"\n' > lib/src/greeting.ml 74 + $ git add -A && git commit -q -m "lib: alice edits two files" 75 + 76 + Stage 4: Bob pushes divergent edits + adds a fourth file 77 + --------------------------------------------------------- 78 + 79 + $ cd "$TROOT" 80 + $ git clone -q lib.git lib-other 2>/dev/null 81 + $ cd lib-other 82 + $ export GIT_AUTHOR_NAME="Bob" 83 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 84 + $ export GIT_AUTHOR_DATE="2025-02-01T01:00:00+00:00" 85 + $ export GIT_COMMITTER_NAME="Bob" 86 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 87 + $ export GIT_COMMITTER_DATE="2025-02-01T01:00:00+00:00" 88 + $ printf 'let banner = "v2-bob"\n' > src/banner.ml 89 + $ printf 'let greeting = "hi-bob"\n' > src/greeting.ml 90 + $ printf 'let added = "by bob"\n' > src/added.ml 91 + $ git add -A && git commit -q -m "lib: bob edits two and adds one" 92 + $ git push -q origin main 2>/dev/null 93 + $ cd "$TROOT/mono" 94 + 95 + Stage 5: Alice's push fails because upstream diverged 96 + ------------------------------------------------------ 97 + 98 + $ export GIT_AUTHOR_NAME="Alice" 99 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 100 + $ export GIT_COMMITTER_NAME="Alice" 101 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 102 + $ monopam push lib > /tmp/push.out 2>&1 103 + [4] 104 + 105 + Stage 6: Pull reports BOTH conflicts and accepts the new file cleanly 106 + ---------------------------------------------------------------------- 107 + 108 + $ monopam pull lib > /tmp/pull.out 2>&1 109 + [4] 110 + $ grep -E "^CONFLICT" /tmp/pull.out | sort 111 + CONFLICT in lib/src/banner.ml 112 + CONFLICT in lib/src/greeting.ml 113 + 114 + Bob's added.ml comes through cleanly — it's new content with no 115 + local counterpart, so no conflict markers and no CONFLICT line: 116 + 117 + $ cat lib/src/added.ml 118 + let added = "by bob" 119 + 120 + Both conflicted files have markers: 121 + 122 + $ grep -c "<<<<<<< " lib/src/banner.ml 123 + 1 124 + $ grep -c "<<<<<<< " lib/src/greeting.ml 125 + 1 126 + 127 + The pull stops at the resolution boundary. Files Alice didn't 128 + touch and Bob didn't edit are unchanged: 129 + 130 + $ cat lib/src/unedited.ml 131 + let unedited = "stable" 132 + 133 + Stage 7: Alice resolves both conflicts in one commit 134 + ----------------------------------------------------- 135 + 136 + $ printf 'let banner = "v2-alice+bob"\n' > lib/src/banner.ml 137 + $ printf 'let greeting = "hi-alice+bob"\n' > lib/src/greeting.ml 138 + $ git add lib/src/banner.ml lib/src/greeting.ml 139 + $ git commit -q -m "lib: resolve both conflicts" 140 + 141 + Stage 8: Push succeeds — both resolutions reach the upstream 142 + ------------------------------------------------------------- 143 + 144 + $ monopam push lib 2>&1 \ 145 + > | grep -F "Changes pushed" \ 146 + > | sed 's/ ([0-9.]*s)//' 147 + ✓ Changes pushed to your remotes. 148 + 149 + $ rm -rf "$TROOT/verify" 150 + $ git clone -q "$TROOT/lib.git" "$TROOT/verify" 2>/dev/null 151 + $ cd "$TROOT/verify" 152 + $ cat src/banner.ml 153 + let banner = "v2-alice+bob" 154 + $ cat src/greeting.ml 155 + let greeting = "hi-alice+bob" 156 + $ cat src/added.ml 157 + let added = "by bob" 158 + $ cat src/unedited.ml 159 + let unedited = "stable"
+151
test/pull_conflict_structural.t/run.t
··· 1 + monopam pull: structural conflicts (modify/delete, add/add) 2 + ============================================================= 3 + 4 + Tree-structure conflicts take a different code path than 5 + content conflicts inside an existing file. This test pins 6 + three at once: modify/delete (Alice deletes a file Bob 7 + modified), delete/modify (Alice modifies a file Bob deleted), 8 + and add/add (both add the same path with different content). 9 + 10 + A naive "rerun the merge" resolver would silently drop the 11 + modified content in modify/delete or take one side blindly in 12 + add/add. The pull must report all three as conflicts. 13 + 14 + Setup 15 + ----- 16 + 17 + $ export NO_COLOR=1 18 + $ export GIT_AUTHOR_NAME="Alice" 19 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 20 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 21 + $ export GIT_COMMITTER_NAME="Alice" 22 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 23 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 24 + $ export HOME="$PWD/home" 25 + $ mkdir -p "$HOME" 26 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 27 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 28 + $ TROOT=$(pwd) 29 + 30 + Stage 1: empty lib upstream + opam-repo overlay 31 + ------------------------------------------------- 32 + 33 + $ git init -q --bare lib.git 34 + $ cat > lib.opam << OPAM 35 + > opam-version: "2.0" 36 + > name: "lib" 37 + > version: "dev" 38 + > synopsis: "L" 39 + > dev-repo: "git+file://$TROOT/lib.git" 40 + > OPAM 41 + $ mkdir -p opam-repo/packages/lib/lib.dev 42 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 43 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 44 + $ cat > opamverse.toml << EOF 45 + > [workspace] 46 + > root = "$TROOT" 47 + > [identity] 48 + > handle = "alice.example.org" 49 + > knot = "git.example.org" 50 + > EOF 51 + 52 + Stage 2: Alice's mono with two files in lib/ 53 + ---------------------------------------------- 54 + 55 + $ mkdir -p mono && cd mono 56 + $ git init -q 57 + $ mkdir -p lib/src 58 + $ cp "$TROOT/lib.opam" lib/lib.opam 59 + $ printf 'let to_delete = "v1"\n' > lib/src/to_delete.ml 60 + $ printf 'let to_modify = "v1"\n' > lib/src/to_modify.ml 61 + $ git add . && git commit -q -m "initial mono" 62 + $ monopam push lib > /dev/null 2>&1 63 + 64 + Stage 3: Alice deletes one file, modifies the other, and adds a new file 65 + ------------------------------------------------------------------------- 66 + 67 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 68 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 69 + $ rm lib/src/to_delete.ml 70 + $ printf 'let to_modify = "v2-alice"\n' > lib/src/to_modify.ml 71 + $ printf 'let new_one = "by alice"\n' > lib/src/new_one.ml 72 + $ git add -A && git commit -q -m "lib: alice's structural changes" 73 + 74 + Stage 4: Bob makes opposing structural changes upstream 75 + -------------------------------------------------------- 76 + 77 + $ cd "$TROOT" 78 + $ git clone -q lib.git lib-other 2>/dev/null 79 + $ cd lib-other 80 + $ export GIT_AUTHOR_NAME="Bob" 81 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 82 + $ export GIT_AUTHOR_DATE="2025-02-01T01:00:00+00:00" 83 + $ export GIT_COMMITTER_NAME="Bob" 84 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 85 + $ export GIT_COMMITTER_DATE="2025-02-01T01:00:00+00:00" 86 + $ printf 'let to_delete = "v2-bob"\n' > src/to_delete.ml 87 + $ rm src/to_modify.ml 88 + $ printf 'let new_one = "by bob"\n' > src/new_one.ml 89 + $ git add -A && git commit -q -m "lib: bob's opposing changes" 90 + $ git push -q origin main 2>/dev/null 91 + $ cd "$TROOT/mono" 92 + 93 + Stage 5: Pull reports all three structural conflicts 94 + ------------------------------------------------------ 95 + 96 + $ export GIT_AUTHOR_NAME="Alice" 97 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 98 + $ export GIT_COMMITTER_NAME="Alice" 99 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 100 + $ monopam pull lib > /tmp/pull.out 2>&1 101 + [4] 102 + $ grep -E "^CONFLICT" /tmp/pull.out | sort 103 + CONFLICT in lib/src/new_one.ml 104 + CONFLICT in lib/src/to_delete.ml 105 + CONFLICT in lib/src/to_modify.ml 106 + 107 + The modify/delete pair: Bob's modified version is restored 108 + into the working tree (the user can decide whether to keep 109 + the deletion or accept the new content): 110 + 111 + $ cat lib/src/to_delete.ml 112 + let to_delete = "v2-bob" 113 + 114 + The delete/modify pair: Alice's modified version stays in 115 + the working tree (same logic, the other side): 116 + 117 + $ cat lib/src/to_modify.ml 118 + let to_modify = "v2-alice" 119 + 120 + The add/add pair: the file exists with conflict markers 121 + showing both versions: 122 + 123 + $ grep -c "<<<<<<< " lib/src/new_one.ml 124 + 1 125 + $ grep "by alice" lib/src/new_one.ml 126 + let new_one = "by alice" 127 + $ grep "by bob" lib/src/new_one.ml 128 + let new_one = "by bob" 129 + 130 + Stage 6: Alice resolves and pushes 131 + ------------------------------------ 132 + 133 + $ rm lib/src/to_delete.ml 134 + $ printf 'let to_modify = "v2-alice"\n' > lib/src/to_modify.ml 135 + $ printf 'let new_one = "by alice and bob"\n' > lib/src/new_one.ml 136 + $ git add -A 137 + $ git commit -q -m "lib: resolve structural conflicts" 138 + $ monopam push lib 2>&1 \ 139 + > | grep -F "Changes pushed" \ 140 + > | sed 's/ ([0-9.]*s)//' 141 + ✓ Changes pushed to your remotes. 142 + 143 + $ rm -rf "$TROOT/verify" 144 + $ git clone -q "$TROOT/lib.git" "$TROOT/verify" 2>/dev/null 145 + $ cd "$TROOT/verify" 146 + $ test -f src/to_delete.ml && echo "still present" || echo "deleted" 147 + deleted 148 + $ cat src/to_modify.ml 149 + let to_modify = "v2-alice" 150 + $ cat src/new_one.ml 151 + let new_one = "by alice and bob"