Monorepo management for opam overlays
0
fork

Configure Feed

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

monopam pull --auto: pluggable conflict-resolution strategies

Add a new Auto_resolve module with four strategies for resolving merge
conflicts surfaced by Pull.run:

- ours: always keep the local version of every conflicted region
- theirs: always take the upstream version
- union: emit both versions deduplicated (good for import lists,
changelog entries, lists of registered packages, etc.)
- claude: call the Claude CLI to summarise the two intents and
propose a merge, returning structured JSON for the resolution

The strategy is selected via the MONOPAM_AUTO environment variable
(default: claude). The CLI exposes --auto to opt in and --auto-yes
to skip the confirmation prompt for the claude strategy. The
deterministic strategies always auto-accept since their behaviour
is predictable.

Wiring:

- ocaml-git: Merge.conflict already carries the diff3 chunks plus
the three blob hashes (added in the previous commit), so the
resolver has everything it needs to either deterministically
transform the chunks or feed them to Claude as context.
- monopam: Pull.run takes an ?auto:auto_options arg with the client
plus the confirm callback. After the merge, conflicted blobs are
passed to the resolver one at a time and the accepted resolutions
are written back to the working tree.
- cmd_pull.ml: --auto resolves the strategy from MONOPAM_AUTO,
builds the appropriate client, and provides a confirm callback
that prints the rationale and (optionally) prompts.

Cram tests use the deterministic strategies — pull_auto_ours.t pins
both the ours and theirs paths end-to-end, no Claude API call, no
JSON fixtures needed.

Also: switched cram tests off /tmp/pull.out (which races between
parallel test runs) onto local pull.out / push.out paths inside
each cram sandbox.

+606 -35
+97 -11
bin/cmd_pull.ml
··· 1 1 open Cmdliner 2 2 3 + let auto_arg = 4 + let doc = 5 + "Auto-resolve merge conflicts. The strategy comes from the \ 6 + $(b,MONOPAM_AUTO) environment variable: $(b,claude) (default) calls the \ 7 + Claude CLI to summarise both intents and propose a merge; $(b,ours) keeps \ 8 + the local version of every conflicted region; $(b,theirs) takes the \ 9 + upstream version; $(b,union) emits both versions deduplicated. Without \ 10 + this flag, conflicts produce git markers and exit 4." 11 + in 12 + Arg.(value & flag & info [ "auto" ] ~doc) 13 + 14 + let auto_yes_arg = 15 + let doc = 16 + "Auto-accept resolutions without prompting. Defaults to false for \ 17 + $(b,claude); deterministic strategies (ours/theirs/union) always \ 18 + auto-accept since their behaviour is predictable." 19 + in 20 + Arg.(value & flag & info [ "auto-yes" ] ~doc) 21 + 22 + (** Resolve the strategy from the [MONOPAM_AUTO] environment variable, with 23 + [claude] as the default. Invalid values fall back to [claude] with a 24 + warning. *) 25 + let strategy_from_env () = 26 + match Sys.getenv_opt "MONOPAM_AUTO" with 27 + | None | Some "" | Some "claude" -> `Claude 28 + | Some "ours" -> `Ours 29 + | Some "theirs" -> `Theirs 30 + | Some "union" -> `Union 31 + | Some other -> 32 + Fmt.epr "monopam: unknown MONOPAM_AUTO=%S, falling back to claude@." other; 33 + `Claude 34 + 3 35 let cmd = 4 36 let doc = "Pull changes from upstream repositories" in 5 37 let man = ··· 14 46 `P "Then build and test:"; 15 47 `Pre "dune build && dune test"; 16 48 `S "WHAT IT DOES"; 17 - `I ("1.", "Fetches from upstream git remotes"); 18 - `I ("2.", "Merges changes into your monorepo"); 19 - `I ("3.", "Reports any conflicts that need resolution"); 49 + `P 50 + "Fetches from upstream remotes, merges changes into your monorepo, and \ 51 + reports any conflicts that need resolution."; 20 52 `S "CONFLICT RESOLUTION"; 21 - `P "If there are conflicts:"; 22 - `I ("1.", "Conflicts appear in mono/ with standard markers"); 23 - `I ("2.", "Edit the conflicted files to resolve"); 24 - `I ("3.", "Run: git add -A && git commit"); 25 - `I ("4.", "Run: monopam push (if you want to push your resolution)"); 53 + `P 54 + "On conflict, monopam exits 4 with standard git markers in the \ 55 + affected files. Resolve them, run 'git add -A && git commit', then \ 56 + 'monopam push' to send the resolution upstream. The $(b,--auto) flag \ 57 + opts into automatic resolution; the strategy is selected via the \ 58 + $(b,MONOPAM_AUTO) environment variable."; 59 + `S "ENVIRONMENT"; 60 + `I 61 + ( "$(b,MONOPAM_AUTO)", 62 + "Strategy for $(b,--auto): claude (default) | ours | theirs | union." 63 + ); 26 64 `S Manpage.s_examples; 27 65 `P "Pull all upstream changes:"; 28 66 `Pre "monopam pull"; 29 67 `P "Pull changes for a specific package:"; 30 68 `Pre "monopam pull eio cohttp"; 69 + `P "Pull with Claude-assisted conflict resolution:"; 70 + `Pre "monopam pull --auto"; 71 + `P "Pull and always keep our version on conflict:"; 72 + `Pre "MONOPAM_AUTO=ours monopam pull --auto --auto-yes"; 31 73 ] 32 74 in 33 75 let info = Cmd.info "pull" ~doc ~man in 34 - let run packages () = 76 + let run packages auto auto_yes () = 35 77 let t0 = Unix.gettimeofday () in 36 78 Eio_main.run @@ fun env -> 37 79 Common.with_config env @@ fun config -> 38 80 let fs = Eio.Stdenv.fs env in 39 81 let proc = Eio.Stdenv.process_mgr env in 40 82 Eio.Switch.run @@ fun sw -> 41 - match Monopam.Pull.run ~sw ~proc ~fs ~config ~packages () with 83 + let auto_opts = 84 + if not auto then None 85 + else 86 + let strategy = strategy_from_env () in 87 + let client = 88 + match strategy with 89 + | `Ours -> Monopam.Auto_resolve.ours_client 90 + | `Theirs -> Monopam.Auto_resolve.theirs_client 91 + | `Union -> Monopam.Auto_resolve.union_client 92 + | `Claude -> 93 + Monopam.Auto_resolve.claude_client ~sw ~process_mgr:proc 94 + ~clock:(Eio.Stdenv.clock env) 95 + in 96 + let always_accept = 97 + match strategy with 98 + | `Ours | `Theirs | `Union -> true 99 + | `Claude -> auto_yes 100 + in 101 + let confirm (proposal : Monopam.Auto_resolve.proposal) = 102 + if always_accept then begin 103 + Fmt.pr " → %s@." proposal.rationale; 104 + true 105 + end 106 + else begin 107 + Fmt.pr "@.Proposed resolution:@."; 108 + Fmt.pr " ours: %s@." proposal.ours_summary; 109 + Fmt.pr " theirs: %s@." proposal.theirs_summary; 110 + Fmt.pr " rationale: %s@." proposal.rationale; 111 + Fmt.pr "Apply? [y/N] @?"; 112 + match read_line () with 113 + | exception End_of_file -> false 114 + | s -> 115 + let s = String.lowercase_ascii (String.trim s) in 116 + s = "y" || s = "yes" 117 + end 118 + in 119 + Some Monopam.Pull.{ client; confirm } 120 + in 121 + match 122 + Monopam.Pull.run ~sw ~proc ~fs ~config ~packages ?auto:auto_opts () 123 + with 42 124 | Ok () -> 43 125 let elapsed = Unix.gettimeofday () -. t0 in 44 126 Common.print_success ~elapsed ~next_step:"dune build && dune test" ··· 46 128 `Ok () 47 129 | Error e -> Common.fail_ctx e 48 130 in 49 - Cmd.v info Term.(ret (const run $ Common.packages_arg $ Common.logging_term)) 131 + Cmd.v info 132 + Term.( 133 + ret 134 + (const run $ Common.packages_arg $ auto_arg $ auto_yes_arg 135 + $ Common.logging_term))
+205
lib/auto_resolve.ml
··· 1 + (** AI-assisted (and deterministic-strategy) resolution of merge conflicts. *) 2 + 3 + let src = Logs.Src.create "monopam.auto_resolve" 4 + 5 + module Log = (val Logs.src_log src : Logs.LOG) 6 + 7 + type proposal = { 8 + merged : string; 9 + ours_summary : string; 10 + theirs_summary : string; 11 + rationale : string; 12 + } 13 + 14 + type client = { 15 + resolve_one : 16 + path:string -> 17 + base:string option -> 18 + ours:string -> 19 + theirs:string -> 20 + chunks:Merge3.merged_chunk list -> 21 + (proposal, string) Stdlib.result; 22 + } 23 + 24 + type outcome = Accepted of string | Skipped | Failed of string 25 + 26 + (** {1 Deterministic strategies} *) 27 + 28 + (** Walk the diff3 chunks, replacing each [Conflict] region with the result of 29 + [pick] applied to its base/ours/theirs lines. [Resolved] regions pass 30 + through unchanged. *) 31 + let resolve_chunks_with pick chunks = 32 + let buf = Buffer.create 256 in 33 + let first = ref true in 34 + let emit line = 35 + if !first then first := false else Buffer.add_char buf '\n'; 36 + Buffer.add_string buf line 37 + in 38 + List.iter 39 + (function 40 + | Merge3.Resolved lines -> List.iter emit lines 41 + | Merge3.Conflict { base_lines; ours_lines; theirs_lines } -> 42 + List.iter emit (pick ~base_lines ~ours_lines ~theirs_lines)) 43 + chunks; 44 + Buffer.contents buf 45 + 46 + let dedup_lines lines = 47 + let seen = Hashtbl.create 32 in 48 + List.filter 49 + (fun l -> 50 + if Hashtbl.mem seen l then false 51 + else begin 52 + Hashtbl.add seen l (); 53 + true 54 + end) 55 + lines 56 + 57 + let ours_pick ~base_lines:_ ~ours_lines ~theirs_lines:_ = ours_lines 58 + let theirs_pick ~base_lines:_ ~ours_lines:_ ~theirs_lines = theirs_lines 59 + 60 + let union_pick ~base_lines:_ ~ours_lines ~theirs_lines = 61 + dedup_lines (ours_lines @ theirs_lines) 62 + 63 + let mk_simple_client ~pick ~ours_summary ~theirs_summary ~rationale = 64 + let resolve_one ~path:_ ~base:_ ~ours:_ ~theirs:_ ~chunks = 65 + let merged = resolve_chunks_with pick chunks in 66 + Ok { merged; ours_summary; theirs_summary; rationale } 67 + in 68 + { resolve_one } 69 + 70 + let ours_client = 71 + mk_simple_client ~pick:ours_pick ~ours_summary:"local edits in the monorepo" 72 + ~theirs_summary:"upstream edits" 73 + ~rationale:"--auto-fix=ours: kept the local version of every conflict" 74 + 75 + let theirs_client = 76 + mk_simple_client ~pick:theirs_pick ~ours_summary:"local edits in the monorepo" 77 + ~theirs_summary:"upstream edits" 78 + ~rationale:"--auto-fix=theirs: kept the upstream version of every conflict" 79 + 80 + let union_client = 81 + mk_simple_client ~pick:union_pick ~ours_summary:"local edits in the monorepo" 82 + ~theirs_summary:"upstream edits" 83 + ~rationale: 84 + "--auto-fix=union: emitted both versions of every conflict, deduplicated" 85 + 86 + (** {1 Claude strategy} *) 87 + 88 + let proposal_jsont : proposal Jsont.t = 89 + let open Jsont in 90 + Object.( 91 + map (fun merged ours_summary theirs_summary rationale -> 92 + { merged; ours_summary; theirs_summary; rationale }) 93 + |> mem "merged" string ~enc:(fun p -> p.merged) 94 + |> mem "ours_summary" string ~enc:(fun p -> p.ours_summary) 95 + |> mem "theirs_summary" string ~enc:(fun p -> p.theirs_summary) 96 + |> mem "rationale" string ~enc:(fun p -> p.rationale) 97 + |> finish) 98 + 99 + let prompt_for path ~chunks = 100 + let buf = Buffer.create 512 in 101 + Buffer.add_string buf "You are resolving a 3-way merge conflict in the file "; 102 + Buffer.add_string buf path; 103 + Buffer.add_string buf 104 + ".\n\n\ 105 + The diff3 chunks below show stable regions ([Resolved]) and the \ 106 + conflicted region(s) ([Conflict]). Each Conflict carries the base, ours, \ 107 + and theirs versions of just the conflicting lines.\n\n"; 108 + List.iter 109 + (function 110 + | Merge3.Resolved lines -> 111 + Buffer.add_string buf "--- stable ---\n"; 112 + List.iter 113 + (fun l -> 114 + Buffer.add_string buf l; 115 + Buffer.add_char buf '\n') 116 + lines 117 + | Merge3.Conflict { base_lines; ours_lines; theirs_lines } -> 118 + Buffer.add_string buf "--- CONFLICT ---\n"; 119 + let dump label lines = 120 + Buffer.add_string buf label; 121 + Buffer.add_string buf ":\n"; 122 + List.iter 123 + (fun l -> 124 + Buffer.add_string buf " "; 125 + Buffer.add_string buf l; 126 + Buffer.add_char buf '\n') 127 + lines 128 + in 129 + dump "base" base_lines; 130 + dump "ours" ours_lines; 131 + dump "theirs" theirs_lines) 132 + chunks; 133 + Buffer.add_string buf 134 + "\n\ 135 + Return a JSON object with these fields:\n\ 136 + - merged: the FULL merged file content (no conflict markers, \ 137 + incorporating both intents)\n\ 138 + - ours_summary: one-sentence summary of what \"ours\" was trying to achieve\n\ 139 + - theirs_summary: one-sentence summary of what \"theirs\" was trying to \ 140 + achieve\n\ 141 + - rationale: free-text explanation of how the resolution combines both \ 142 + intents\n"; 143 + Buffer.contents buf 144 + 145 + let claude_client ~sw ~process_mgr ~clock = 146 + let resolve_one ~path ~base:_ ~ours:_ ~theirs:_ ~chunks = 147 + let prompt = prompt_for path ~chunks in 148 + let options = Claude.Options.default |> Claude.Options.with_max_turns 1 in 149 + let client = Claude.Client.v ~sw ~process_mgr ~clock ~options () in 150 + Claude.Client.query client prompt; 151 + let responses = Claude.Client.receive_all client in 152 + let result = ref (Error "no response from Claude") in 153 + List.iter 154 + (function 155 + | Claude.Response.Complete c -> ( 156 + match Claude.Response.Complete.result_text c with 157 + | Some text -> ( 158 + match Jsont_bytesrw.decode_string proposal_jsont text with 159 + | Ok p -> result := Ok p 160 + | Error e -> result := Error (Fmt.str "decode failed: %s" e)) 161 + | None -> ()) 162 + | Claude.Response.Error e -> 163 + result := Error (Claude.Response.Error.message e) 164 + | _ -> ()) 165 + responses; 166 + !result 167 + in 168 + { resolve_one } 169 + 170 + (** {1 Main entry point} *) 171 + 172 + let read_blob_content repo hash_opt = 173 + match hash_opt with 174 + | None -> None 175 + | Some h -> ( 176 + match Git.Repository.read repo h with 177 + | Ok (Git.Value.Blob b) -> Some (Git.Blob.to_string b) 178 + | _ -> None) 179 + 180 + let resolve ~repo ~client ~confirm ~conflict = 181 + let base_opt = read_blob_content repo conflict.Git.Merge.base in 182 + let ours_opt = read_blob_content repo conflict.Git.Merge.ours in 183 + let theirs_opt = read_blob_content repo conflict.Git.Merge.theirs in 184 + match (ours_opt, theirs_opt, conflict.Git.Merge.chunks) with 185 + | None, _, _ | _, None, _ -> 186 + Failed 187 + (Fmt.str 188 + "auto_resolve: structural conflict at %s (%s) — cannot read all \ 189 + three sides" 190 + conflict.Git.Merge.path conflict.Git.Merge.reason) 191 + | Some _, Some _, None -> 192 + Failed 193 + (Fmt.str 194 + "auto_resolve: structural conflict at %s (%s) — no diff3 chunks" 195 + conflict.Git.Merge.path conflict.Git.Merge.reason) 196 + | Some ours, Some theirs, Some chunks -> ( 197 + Log.info (fun m -> 198 + m "auto-resolving conflict at %s" conflict.Git.Merge.path); 199 + match 200 + client.resolve_one ~path:conflict.Git.Merge.path ~base:base_opt ~ours 201 + ~theirs ~chunks 202 + with 203 + | Error msg -> Failed msg 204 + | Ok proposal -> 205 + if confirm proposal then Accepted proposal.merged else Skipped)
+89
lib/auto_resolve.mli
··· 1 + (** Strategies for resolving merge conflicts surfaced by [monopam pull]. 2 + 3 + A strategy takes the diff3 chunks for one conflicted file and produces a 4 + merged result plus a one-line description of what it did. Strategies range 5 + from trivial deterministic ones ([Ours], [Theirs], [Union]) — used in tests 6 + and for users who know which side to take — to AI-driven summarisation via 7 + Claude. *) 8 + 9 + (** {1 Types} *) 10 + 11 + type proposal = { 12 + merged : string; (** Full file content with no conflict markers. *) 13 + ours_summary : string; 14 + (** One-sentence summary of what "ours" was trying to achieve. *) 15 + theirs_summary : string; 16 + (** One-sentence summary of what "theirs" was trying to achieve. *) 17 + rationale : string; 18 + (** Free-text explanation of the chosen resolution. Useful for the 19 + interactive confirm prompt and for audit logs. *) 20 + } 21 + (** A proposed resolution for one conflict. *) 22 + 23 + type client = { 24 + resolve_one : 25 + path:string -> 26 + base:string option -> 27 + ours:string -> 28 + theirs:string -> 29 + chunks:Merge3.merged_chunk list -> 30 + (proposal, string) Stdlib.result; 31 + } 32 + (** A pluggable resolver. The deterministic strategies don't need any runtime 33 + resources; the AI strategy needs an Eio process manager and clock to spawn 34 + the Claude CLI. *) 35 + 36 + type outcome = 37 + | Accepted of string 38 + (** The user accepted the proposal; the [string] is the merged content to 39 + write to the file. *) 40 + | Skipped (** The user declined; leave the conflict markers in place. *) 41 + | Failed of string 42 + (** The strategy returned an error or couldn't run (e.g. structural 43 + conflict it doesn't handle). *) 44 + 45 + (** {1 Strategy clients} 46 + 47 + Each function returns a [client] value that implements one strategy. Pass 48 + any of these to {!resolve}. *) 49 + 50 + val ours_client : client 51 + (** [ours_client]: for every conflict region, take the "ours" version. The 52 + rationale is fixed: "kept the local version". Deterministic, no I/O. *) 53 + 54 + val theirs_client : client 55 + (** [theirs_client]: for every conflict region, take the "theirs" version. 56 + Symmetric to {!ours_client}. *) 57 + 58 + val union_client : client 59 + (** [union_client]: for every conflict region, emit both the "ours" and "theirs" 60 + lines (deduplicated), in that order. Useful when both edits add new content 61 + that should coexist (e.g. import lists, changelog entries). *) 62 + 63 + val claude_client : 64 + sw:Eio.Switch.t -> 65 + process_mgr:_ Eio.Process.mgr -> 66 + clock:float Eio.Time.clock_ty Eio.Resource.t -> 67 + client 68 + (** [claude_client ~sw ~process_mgr ~clock]: call the Claude CLI for each 69 + conflict, with structured-output JSON for the proposal. Each call summarises 70 + what the two sides were trying to do and proposes a merge that combines both 71 + intents. *) 72 + 73 + (** {1 Main entry point} *) 74 + 75 + val resolve : 76 + repo:Git.Repository.t -> 77 + client:client -> 78 + confirm:(proposal -> bool) -> 79 + conflict:Git.Merge.conflict -> 80 + outcome 81 + (** [resolve ~repo ~client ~confirm ~conflict] runs the resolution flow for one 82 + conflict. 83 + 84 + 1. Read base/ours/theirs blob content from [repo] using the hashes on the 85 + conflict record. 2. Ask [client.resolve_one] for a {!proposal}. 3. Call 86 + [confirm proposal]. If [true], yield [Accepted merged]. Else [Skipped]. 87 + 88 + Structural conflicts (modify/delete, type change) currently return 89 + [Failed _] — they need a different handling than content conflicts. *)
+1
lib/monopam.ml
··· 41 41 module Remove = Remove 42 42 module Clean = Clean 43 43 module Diff = Diff 44 + module Auto_resolve = Auto_resolve 44 45 45 46 (** {1 Aliases for bin commands} *) 46 47
+1
lib/monopam.mli
··· 63 63 module Remove = Remove 64 64 module Clean = Clean 65 65 module Diff = Diff 66 + module Auto_resolve = Auto_resolve 66 67 67 68 (** {1 Aliases for bin commands} *) 68 69
+55 -6
lib/pull.ml
··· 467 467 List.rev !conflicts 468 468 end 469 469 470 - let run ~sw ~proc ~fs ~config ?(packages = []) ?opam_repo_url () = 470 + type auto_options = { 471 + client : Auto_resolve.client; 472 + confirm : Auto_resolve.proposal -> bool; 473 + } 474 + 475 + (** Walk the conflict list and try to auto-resolve each one. Returns a list of 476 + paths that were NOT resolved (either user declined or resolver failed) and a 477 + count of those that were applied. The applied resolutions are written to the 478 + filesystem inside [monorepo]. *) 479 + let auto_resolve_conflicts ~sw ~fs:fs_t ~monorepo ~auto conflicts = 480 + let mono_repo = Git.Repository.open_repo ~sw ~fs:fs_t monorepo in 481 + let unresolved = ref [] in 482 + let applied = ref 0 in 483 + List.iter 484 + (fun (c : Git.Merge.conflict) -> 485 + match 486 + Auto_resolve.resolve ~repo:mono_repo ~client:auto.client 487 + ~confirm:auto.confirm ~conflict:c 488 + with 489 + | Auto_resolve.Accepted merged -> 490 + let path = Eio.Path.(fs_t / Fpath.to_string monorepo / c.path) in 491 + Eio.Path.save ~create:(`Or_truncate 0o644) path merged; 492 + incr applied 493 + | Auto_resolve.Skipped | Auto_resolve.Failed _ -> 494 + unresolved := c.path :: !unresolved) 495 + conflicts; 496 + (List.rev !unresolved, !applied) 497 + 498 + let run ~sw ~proc ~fs ~config ?(packages = []) ?opam_repo_url ?auto () = 471 499 let ( let* ) = Result.bind in 472 500 let fs_t = Ctx.fs_typed fs in 473 501 let opam_repo = Config.Paths.opam_repo config in ··· 540 568 leaving them out on the conflict path keeps stdout 541 569 deterministic for the user (and for cram tests grepping the 542 570 output). *) 543 - let outer_conflicts = 571 + let outer_conflict_records = 544 572 List.concat_map 545 573 (fun r -> 546 574 List.map 547 - (fun (c : Git.Merge.conflict) -> r.repo_name ^ "/" ^ c.path) 575 + (fun (c : Git.Merge.conflict) -> 576 + ( r.repo_name ^ "/" ^ c.path, 577 + { c with path = r.repo_name ^ "/" ^ c.path } )) 548 578 r.conflicts) 549 579 results 550 580 in 581 + let outer_conflicts = List.map fst outer_conflict_records in 551 582 let all_conflicts = inner_conflict_paths @ outer_conflicts in 552 - if all_conflicts = [] then begin 583 + (* If --auto is enabled, try to resolve content conflicts via the AI 584 + resolver before deciding the final state. *) 585 + let unresolved_after_auto = 586 + match auto with 587 + | None -> all_conflicts 588 + | Some auto_opts -> 589 + let monorepo = Config.Paths.monorepo config in 590 + let conflict_records = List.map snd outer_conflict_records in 591 + let unresolved, applied = 592 + auto_resolve_conflicts ~sw ~fs:fs_t ~monorepo ~auto:auto_opts 593 + conflict_records 594 + in 595 + if applied > 0 then 596 + Log.app (fun m -> 597 + m " ✓ auto-resolved %d conflict%s" applied 598 + (if applied = 1 then "" else "s")); 599 + inner_conflict_paths @ unresolved 600 + in 601 + if unresolved_after_auto = [] then begin 553 602 Init.write_readme ~proc ~fs:fs_t ~config all_pkgs; 554 603 Init.write_claude_md ~proc ~fs:fs_t ~config; 555 604 Init.write_dune_project ~proc ~fs:fs_t ~config all_pkgs 556 605 end; 557 - if all_conflicts <> [] then 606 + if unresolved_after_auto <> [] then 558 607 Error 559 608 (Ctx.Pull_conflict 560 609 { 561 - paths = all_conflicts; 610 + paths = unresolved_after_auto; 562 611 hint = 563 612 "Edit the conflicted files under mono/, stage the resolution \ 564 613 with 'git add', 'git commit', and run 'monopam push' again.";
+12 -1
lib/pull.mli
··· 27 27 [Merged] for a clean merge, or [Conflict conflicts] when conflict markers 28 28 were produced. *) 29 29 30 + type auto_options = { 31 + client : Auto_resolve.client; 32 + confirm : Auto_resolve.proposal -> bool; 33 + } 34 + (** Auto-resolution wiring. When supplied to {!run}, conflicts are passed 35 + through [client] for a proposed resolution and [confirm] is called to ask 36 + the user (or auto-accept in tests / non-interactive contexts). *) 37 + 30 38 val run : 31 39 sw:Eio.Switch.t -> 32 40 proc:_ Eio.Process.mgr -> ··· 34 42 config:Config.t -> 35 43 ?packages:string list -> 36 44 ?opam_repo_url:string -> 45 + ?auto:auto_options -> 37 46 unit -> 38 47 (unit, Ctx.error) Stdlib.result 39 - (** [run ~sw ~proc ~fs ~config ()] fetches checkouts and merges subtrees. *) 48 + (** [run ~sw ~proc ~fs ~config ()] fetches checkouts and merges subtrees. When 49 + [auto] is supplied, conflicts are run through the AI resolver instead of 50 + immediately exiting with [Pull_conflict]. *)
+4 -4
test/nested_mono_conflict.t/run.t
··· 98 98 $ export GIT_AUTHOR_EMAIL="alice@example.com" 99 99 $ export GIT_COMMITTER_NAME="Alice" 100 100 $ export GIT_COMMITTER_EMAIL="alice@example.com" 101 - $ monopam push lib > /tmp/push.out 2>&1 101 + $ monopam push lib > push.out 2>&1 102 102 [4] 103 103 104 104 Stage 6: Pull surfaces the conflict deep inside the nested mono ··· 107 107 The conflict path is reported relative to the inner mono prefix. 108 108 Pull exits 4 with the standard hint. 109 109 110 - $ monopam pull lib > /tmp/pull.out 2>&1 110 + $ monopam pull lib > pull.out 2>&1 111 111 [4] 112 - $ grep -F "Hint:" /tmp/pull.out 112 + $ grep -F "Hint:" pull.out 113 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 114 + $ grep -E "^CONFLICT" pull.out 115 115 CONFLICT in open-mono/lib/src/main.ml 116 116 117 117 The conflict path is reported relative to the outer mono root —
+128
test/pull_auto_ours.t/run.t
··· 1 + monopam pull --auto with MONOPAM_AUTO=ours: keep local on conflict 2 + ==================================================================== 3 + 4 + The --auto flag opts into automatic conflict resolution. The strategy 5 + is selected via the MONOPAM_AUTO environment variable; this test uses 6 + "ours" which keeps the local version of every conflicted region. The 7 + deterministic strategies (ours/theirs/union) auto-accept by default, 8 + so the user doesn't get prompted. 9 + 10 + This is the cram-friendly path: no Claude API call, no recorded 11 + fixtures, just a deterministic transformation of the diff3 chunks. 12 + 13 + Setup 14 + ----- 15 + 16 + $ export NO_COLOR=1 17 + $ export GIT_AUTHOR_NAME="Alice" 18 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 19 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 20 + $ export GIT_COMMITTER_NAME="Alice" 21 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 22 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 23 + $ export HOME="$PWD/home" 24 + $ mkdir -p "$HOME" 25 + $ export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" 26 + $ printf '[init]\n\tdefaultBranch = main\n[user]\n\tname = Alice\n\temail = alice@example.com\n' > "$GIT_CONFIG_GLOBAL" 27 + $ TROOT=$(pwd) 28 + $ git init -q --bare lib.git 29 + $ cat > lib.opam << OPAM 30 + > opam-version: "2.0" 31 + > name: "lib" 32 + > version: "dev" 33 + > synopsis: "L" 34 + > dev-repo: "git+file://$TROOT/lib.git" 35 + > OPAM 36 + $ mkdir -p opam-repo/packages/lib/lib.dev 37 + $ cp lib.opam opam-repo/packages/lib/lib.dev/opam 38 + $ cd opam-repo && git init -q && git add . && git commit -q -m "init" && cd "$TROOT" 39 + $ cat > opamverse.toml << EOF 40 + > [workspace] 41 + > root = "$TROOT" 42 + > [identity] 43 + > handle = "alice.example.org" 44 + > knot = "git.example.org" 45 + > EOF 46 + $ mkdir -p mono && cd mono 47 + $ git init -q 48 + $ mkdir -p lib/src 49 + $ cp "$TROOT/lib.opam" lib/lib.opam 50 + $ printf 'let banner = "v1"\n' > lib/src/main.ml 51 + $ git add . && git commit -q -m "initial mono" 52 + $ monopam push lib > /dev/null 2>&1 53 + 54 + Stage 1: alice's local edit + bob's divergent push 55 + ---------------------------------------------------- 56 + 57 + $ export GIT_AUTHOR_DATE="2025-02-01T00:00:00+00:00" 58 + $ export GIT_COMMITTER_DATE="2025-02-01T00:00:00+00:00" 59 + $ printf 'let banner = "v2-alice"\n' > lib/src/main.ml 60 + $ git add -A && git commit -q -m "alice edits banner" 61 + $ cd "$TROOT" 62 + $ git clone -q lib.git lib-other 2>/dev/null 63 + $ cd lib-other 64 + $ export GIT_AUTHOR_NAME="Bob" 65 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 66 + $ export GIT_AUTHOR_DATE="2025-02-01T01:00:00+00:00" 67 + $ export GIT_COMMITTER_NAME="Bob" 68 + $ export GIT_COMMITTER_EMAIL="bob@example.com" 69 + $ export GIT_COMMITTER_DATE="2025-02-01T01:00:00+00:00" 70 + $ printf 'let banner = "v2-bob"\n' > src/main.ml 71 + $ git add -A && git commit -q -m "bob's banner" 72 + $ git push -q origin main 2>/dev/null 73 + 74 + Stage 2: pull --auto with MONOPAM_AUTO=ours 75 + --------------------------------------------- 76 + 77 + The strategy is auto-accepting (deterministic), so the pull just 78 + applies the resolution and reports it on the next-step line. 79 + 80 + $ cd "$TROOT/mono" 81 + $ export GIT_AUTHOR_NAME="Alice" 82 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 83 + $ export GIT_COMMITTER_NAME="Alice" 84 + $ export GIT_COMMITTER_EMAIL="alice@example.com" 85 + $ MONOPAM_AUTO=ours monopam pull lib --auto > pull.out 2>&1 86 + $ grep -F "auto-fix=ours" pull.out 87 + → --auto-fix=ours: kept the local version of every conflict 88 + $ grep -F "auto-resolved" pull.out 89 + ✓ auto-resolved 1 conflict 90 + 91 + The merged file has alice's content (no markers, no upstream content): 92 + 93 + $ cat lib/src/main.ml 94 + let banner = "v2-alice" 95 + $ grep -c "<<<<<<< " lib/src/main.ml 96 + 0 97 + [1] 98 + $ grep -F "v2-bob" lib/src/main.ml 99 + [1] 100 + 101 + Stage 3: same scenario with MONOPAM_AUTO=theirs 102 + ------------------------------------------------- 103 + 104 + Reset the mono and try the symmetric strategy. Build a fresh state by 105 + re-editing the file and re-running the same setup. 106 + 107 + $ printf 'let banner = "v2-alice"\n' > lib/src/main.ml 108 + $ git add -A 109 + $ git commit -q -m "alice edits banner again" --allow-empty 110 + 111 + The previous pull already resolved the conflict; bob now pushes a 112 + second divergent edit so we have something to test. 113 + 114 + $ cd "$TROOT/lib-other" 115 + $ export GIT_AUTHOR_NAME="Bob" 116 + $ export GIT_AUTHOR_EMAIL="bob@example.com" 117 + $ git pull -q origin main 2>/dev/null 118 + $ printf 'let banner = "v3-bob"\n' > src/main.ml 119 + $ git add -A && git commit -q -m "bob's second edit" 120 + $ git push -q origin main 2>/dev/null 121 + $ cd "$TROOT/mono" 122 + $ export GIT_AUTHOR_NAME="Alice" 123 + $ export GIT_AUTHOR_EMAIL="alice@example.com" 124 + $ MONOPAM_AUTO=theirs monopam pull lib --auto > pull2.out 2>&1 125 + $ grep -F "auto-fix=theirs" pull2.out 126 + → --auto-fix=theirs: kept the upstream version of every conflict 127 + $ cat lib/src/main.ml 128 + let banner = "v3-bob"
+4 -4
test/pull_conflict.t/run.t
··· 118 118 $ export GIT_AUTHOR_EMAIL="alice@example.com" 119 119 $ export GIT_COMMITTER_NAME="Alice" 120 120 $ export GIT_COMMITTER_EMAIL="alice@example.com" 121 - $ monopam push lib > /tmp/push.out 2>&1 121 + $ monopam push lib > push.out 2>&1 122 122 [4] 123 - $ grep -F "Hint:" /tmp/push.out 123 + $ grep -F "Hint:" push.out 124 124 Hint: Run 'monopam pull' to merge the upstream changes, resolve any conflicts, and push again. 125 125 126 126 The monorepo subtree still has Alice's local edit — nothing was ··· 132 132 Stage 6: Alice pulls and gets a merge conflict 133 133 ------------------------------------------------ 134 134 135 - $ monopam pull lib > /tmp/pull.out 2>&1 135 + $ monopam pull lib > pull.out 2>&1 136 136 [4] 137 - $ grep -E "CONFLICT|Hint:" /tmp/pull.out 137 + $ grep -E "CONFLICT|Hint:" pull.out 138 138 CONFLICT in lib/src/main.ml 139 139 Hint: Edit the conflicted files under mono/, stage the resolution with 'git add', 'git commit', and run 'monopam push' again. 140 140
+3 -3
test/pull_conflict_multi.t/run.t
··· 99 99 $ export GIT_AUTHOR_EMAIL="alice@example.com" 100 100 $ export GIT_COMMITTER_NAME="Alice" 101 101 $ export GIT_COMMITTER_EMAIL="alice@example.com" 102 - $ monopam push lib > /tmp/push.out 2>&1 102 + $ monopam push lib > push.out 2>&1 103 103 [4] 104 104 105 105 Stage 6: Pull reports BOTH conflicts and accepts the new file cleanly 106 106 ---------------------------------------------------------------------- 107 107 108 - $ monopam pull lib > /tmp/pull.out 2>&1 108 + $ monopam pull lib > pull.out 2>&1 109 109 [4] 110 - $ grep -E "^CONFLICT" /tmp/pull.out | sort 110 + $ grep -E "^CONFLICT" pull.out | sort 111 111 CONFLICT in lib/src/banner.ml 112 112 CONFLICT in lib/src/greeting.ml 113 113
+2 -2
test/pull_conflict_structural.t/run.t
··· 97 97 $ export GIT_AUTHOR_EMAIL="alice@example.com" 98 98 $ export GIT_COMMITTER_NAME="Alice" 99 99 $ export GIT_COMMITTER_EMAIL="alice@example.com" 100 - $ monopam pull lib > /tmp/pull.out 2>&1 100 + $ monopam pull lib > pull.out 2>&1 101 101 [4] 102 - $ grep -E "^CONFLICT" /tmp/pull.out | sort 102 + $ grep -E "^CONFLICT" pull.out | sort 103 103 CONFLICT in lib/src/new_one.ml 104 104 CONFLICT in lib/src/to_delete.ml 105 105 CONFLICT in lib/src/to_modify.ml
+2 -2
test/pull_dirty.t/run.t
··· 67 67 would silently overwrite the user's WIP. Pull must exit non-zero 68 68 without touching anything. 69 69 70 - $ monopam pull lib > /tmp/pull.out 2>&1 70 + $ monopam pull lib > pull.out 2>&1 71 71 [2] 72 - $ grep -F "Dirty" /tmp/pull.out 72 + $ grep -F "Dirty" pull.out 73 73 Error: Dirty packages: lib 74 74 75 75 The user's edit is preserved unchanged:
+2 -2
test/pull_no_trailing_newline.t/run.t
··· 89 89 $ export GIT_AUTHOR_EMAIL="alice@example.com" 90 90 $ export GIT_COMMITTER_NAME="Alice" 91 91 $ export GIT_COMMITTER_EMAIL="alice@example.com" 92 - $ monopam pull lib > /tmp/pull.out 2>&1 92 + $ monopam pull lib > pull.out 2>&1 93 93 [4] 94 - $ grep -a -E "^CONFLICT" /tmp/pull.out 94 + $ grep -a -E "^CONFLICT" pull.out 95 95 CONFLICT in lib/src/main.ml 96 96 97 97 Both versions are visible in the merged file:
+1
test/test_pull.ml
··· 9 9 config:_ -> 10 10 ?packages:_ -> 11 11 ?opam_repo_url:_ -> 12 + ?auto:_ -> 12 13 unit -> 13 14 _) 14 15