Monorepo management for opam overlays
0
fork

Configure Feed

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

Add pull and cherrypick commands for verse collaboration

- `monopam pull <handle> [repo]`: Pull commits from a verse member's
forks into local checkouts. Merges their changes (fast-forward when
possible, merge commit when diverged).

- `monopam cherrypick <sha>`: Apply a specific commit from a verse
member's fork to the appropriate local checkout.

Both commands integrate with the existing diff command workflow:
1. monopam diff - see available changes
2. monopam pull/cherrypick - apply changes
3. monopam sync - merge into monorepo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+347 -1
+120 -1
bin/main.ml
··· 886 886 in 887 887 Cmd.v info Term.(ret (const run $ arg $ refresh_arg $ patch_arg $ logging_term)) 888 888 889 + (* Pull command - pull from verse members *) 890 + 891 + let pull_cmd = 892 + let doc = "Pull commits from a verse member's forks" in 893 + let man = 894 + [ 895 + `S Manpage.s_description; 896 + `P 897 + "Pulls commits from a verse member's forks into your local checkouts. \ 898 + This merges their changes into your checkout branches, making them \ 899 + ready to be synced to the monorepo via $(b,monopam sync)."; 900 + `S "WORKFLOW"; 901 + `P "The typical workflow for incorporating changes from collaborators:"; 902 + `I ("1.", "$(b,monopam diff) - See what changes are available"); 903 + `I ("2.", "$(b,monopam pull <handle>) - Pull changes from a collaborator"); 904 + `I ("3.", "$(b,monopam sync) - Sync changes into your monorepo"); 905 + `S "MERGING BEHAVIOR"; 906 + `P "When you're behind (they have commits you don't):"; 907 + `I ("Fast-forward", "If your branch has no new commits, a fast-forward merge is used."); 908 + `P "When branches have diverged (both have new commits):"; 909 + `I ("Merge commit", "A merge commit is created to combine the histories."); 910 + `S Manpage.s_examples; 911 + `P "Pull all changes from a verse member:"; 912 + `Pre "monopam pull avsm.bsky.social"; 913 + `P "Pull changes for a specific repository:"; 914 + `Pre "monopam pull avsm.bsky.social eio"; 915 + `P "Force fresh fetches before pulling:"; 916 + `Pre "monopam pull --refresh avsm.bsky.social"; 917 + ] 918 + in 919 + let info = Cmd.info "pull" ~doc ~man in 920 + let handle_arg = 921 + let doc = "The verse member handle to pull from (e.g., avsm.bsky.social)." in 922 + Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc) 923 + in 924 + let repo_arg = 925 + let doc = "Optional repository to pull from. If not specified, pulls from all \ 926 + repositories where the handle has commits you don't have." in 927 + Arg.(value & pos 1 (some string) None & info [] ~docv:"REPO" ~doc) 928 + in 929 + let refresh_arg = 930 + let doc = "Force fresh fetches from all remotes, ignoring the 5-minute cache." in 931 + Arg.(value & flag & info [ "refresh"; "r" ] ~doc) 932 + in 933 + let run handle repo refresh () = 934 + Eio_main.run @@ fun env -> 935 + with_config env @@ fun config -> 936 + with_verse_config env @@ fun verse_config -> 937 + let fs = Eio.Stdenv.fs env in 938 + let proc = Eio.Stdenv.process_mgr env in 939 + match Monopam.pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ~refresh () with 940 + | Ok result -> 941 + Fmt.pr "%a" Monopam.pp_handle_pull_result result; 942 + if result.repos_failed <> [] then 943 + `Error (false, "some repos failed to pull") 944 + else if result.repos_pulled = [] then begin 945 + Fmt.pr "Nothing to pull from %s@." handle; 946 + `Ok () 947 + end 948 + else begin 949 + Fmt.pr "@.Run $(b,monopam sync) to merge changes into your monorepo.@."; 950 + `Ok () 951 + end 952 + | Error e -> 953 + Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; 954 + `Error (false, "pull failed") 955 + in 956 + Cmd.v info Term.(ret (const run $ handle_arg $ repo_arg $ refresh_arg $ logging_term)) 957 + 958 + (* Cherrypick command *) 959 + 960 + let cherrypick_cmd = 961 + let doc = "Cherry-pick a specific commit from a verse member's fork" in 962 + let man = 963 + [ 964 + `S Manpage.s_description; 965 + `P 966 + "Applies a specific commit from a verse member's fork to your local checkout. \ 967 + Use $(b,monopam diff) to see available commits and their hashes."; 968 + `S "WORKFLOW"; 969 + `P "The typical workflow for cherry-picking specific commits:"; 970 + `I ("1.", "$(b,monopam diff) - See available commits with their hashes"); 971 + `I ("2.", "$(b,monopam diff <sha>) - View the full patch for a commit"); 972 + `I ("3.", "$(b,monopam cherrypick <sha>) - Apply that commit"); 973 + `I ("4.", "$(b,monopam sync) - Sync changes into your monorepo"); 974 + `S Manpage.s_examples; 975 + `P "Cherry-pick a commit:"; 976 + `Pre "monopam cherrypick abc1234"; 977 + `P "View a commit's patch first, then cherry-pick:"; 978 + `Pre "monopam diff abc1234"; 979 + `Pre "monopam cherrypick abc1234"; 980 + ] 981 + in 982 + let info = Cmd.info "cherrypick" ~doc ~man in 983 + let sha_arg = 984 + let doc = "The commit SHA (or prefix) to cherry-pick. Must be at least 7 characters." in 985 + Arg.(required & pos 0 (some string) None & info [] ~docv:"SHA" ~doc) 986 + in 987 + let refresh_arg = 988 + let doc = "Force fresh fetches from all remotes, ignoring the 5-minute cache." in 989 + Arg.(value & flag & info [ "refresh"; "r" ] ~doc) 990 + in 991 + let run sha refresh () = 992 + Eio_main.run @@ fun env -> 993 + with_config env @@ fun config -> 994 + with_verse_config env @@ fun verse_config -> 995 + let fs = Eio.Stdenv.fs env in 996 + let proc = Eio.Stdenv.process_mgr env in 997 + match Monopam.cherrypick ~proc ~fs ~config ~verse_config ~sha ~refresh () with 998 + | Ok result -> 999 + Fmt.pr "%a" Monopam.pp_cherrypick_result result; 1000 + Fmt.pr "Run $(b,monopam sync) to merge changes into your monorepo.@."; 1001 + `Ok () 1002 + | Error e -> 1003 + Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; 1004 + `Error (false, "cherrypick failed") 1005 + in 1006 + Cmd.v info Term.(ret (const run $ sha_arg $ refresh_arg $ logging_term)) 1007 + 889 1008 (* Doctor command *) 890 1009 891 1010 let doctor_cmd = ··· 1212 1331 in 1213 1332 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in 1214 1333 Cmd.group info 1215 - [ status_cmd; diff_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd ] 1334 + [ status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd ] 1216 1335 1217 1336 let () = exit (Cmd.eval main_cmd)
+9
lib/git.ml
··· 492 492 let worktrees = list ~proc ~fs repo in 493 493 List.exists (fun e -> Fpath.equal e.path path) worktrees 494 494 end 495 + 496 + let cherry_pick ~proc ~fs ~commit path = 497 + let cwd = path_to_eio ~fs path in 498 + run_git_ok ~proc ~cwd [ "cherry-pick"; commit ] |> Result.map ignore 499 + 500 + let merge ~proc ~fs ~ref_name ?(ff_only=false) path = 501 + let cwd = path_to_eio ~fs path in 502 + let args = ["merge"] @ (if ff_only then ["--ff-only"] else []) @ [ref_name] in 503 + run_git_ok ~proc ~cwd args |> Result.map ignore
+26
lib/git.mli
··· 524 524 bool 525 525 (** [exists ~proc ~fs ~repo ~path] returns true if a worktree exists at [path]. *) 526 526 end 527 + 528 + (** {1 Cherry-pick Operations} *) 529 + 530 + val cherry_pick : 531 + proc:_ Eio.Process.mgr -> 532 + fs:Eio.Fs.dir_ty Eio.Path.t -> 533 + commit:string -> 534 + Fpath.t -> 535 + (unit, error) result 536 + (** [cherry_pick ~proc ~fs ~commit path] applies a single commit to the current branch. 537 + 538 + @param commit The commit hash to cherry-pick 539 + @param path Path to the repository *) 540 + 541 + val merge : 542 + proc:_ Eio.Process.mgr -> 543 + fs:Eio.Fs.dir_ty Eio.Path.t -> 544 + ref_name:string -> 545 + ?ff_only:bool -> 546 + Fpath.t -> 547 + (unit, error) result 548 + (** [merge ~proc ~fs ~ref_name ?ff_only path] merges a ref into the current branch. 549 + 550 + @param ref_name The ref to merge (e.g., "verse/handle/main") 551 + @param ff_only If true, only allow fast-forward merges (default: false) 552 + @param path Path to the repository *)
+125
lib/monopam.ml
··· 2108 2108 forks.repos 2109 2109 in 2110 2110 result 2111 + 2112 + (* ==================== Pull from Handle ==================== *) 2113 + 2114 + type handle_pull_result = { 2115 + repos_pulled : (string * int) list; 2116 + repos_skipped : string list; 2117 + repos_failed : (string * string) list; 2118 + } 2119 + 2120 + let pp_handle_pull_result ppf result = 2121 + if result.repos_pulled <> [] then begin 2122 + Fmt.pf ppf "@[<v>%a@," Fmt.(styled `Bold string) "Pulled:"; 2123 + List.iter (fun (repo, count) -> 2124 + Fmt.pf ppf " %s: %d commits@," repo count) 2125 + result.repos_pulled; 2126 + Fmt.pf ppf "@]" 2127 + end; 2128 + if result.repos_skipped <> [] then 2129 + Fmt.pf ppf "%a %s@," 2130 + Fmt.(styled `Faint string) "Skipped:" 2131 + (String.concat ", " result.repos_skipped); 2132 + if result.repos_failed <> [] then begin 2133 + Fmt.pf ppf "@[<v>%a@," Fmt.(styled `Red string) "Failed:"; 2134 + List.iter (fun (repo, err) -> 2135 + Fmt.pf ppf " %s: %s@," repo err) 2136 + result.repos_failed; 2137 + Fmt.pf ppf "@]" 2138 + end 2139 + 2140 + let pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ?(refresh=false) () = 2141 + let checkouts_path = Config.Paths.checkouts config in 2142 + 2143 + (* Compute fork analysis *) 2144 + let forks = Forks.compute ~proc ~fs ~verse_config ~monopam_config:config ~refresh () in 2145 + 2146 + (* Filter repos if specific one requested *) 2147 + let repos_to_check = match repo with 2148 + | None -> forks.repos 2149 + | Some name -> List.filter (fun r -> r.Forks.repo_name = name) forks.repos 2150 + in 2151 + 2152 + (* Find repos where this handle has commits we don't have *) 2153 + let repos_pulled = ref [] in 2154 + let repos_skipped = ref [] in 2155 + let repos_failed = ref [] in 2156 + 2157 + List.iter (fun (r : Forks.repo_analysis) -> 2158 + (* Check if this handle has commits for this repo *) 2159 + let handle_source = List.find_opt (fun (h, _, _) -> h = handle) r.verse_sources in 2160 + match handle_source with 2161 + | None -> 2162 + (* Handle doesn't have this repo *) 2163 + () 2164 + | Some (_, _, rel) -> 2165 + let checkout_path = Fpath.(checkouts_path / r.repo_name) in 2166 + if not (Git.is_repo ~proc ~fs checkout_path) then 2167 + repos_skipped := r.repo_name :: !repos_skipped 2168 + else begin 2169 + match rel with 2170 + | Forks.Same_url | Forks.Same_commit | Forks.I_am_ahead _ -> 2171 + repos_skipped := r.repo_name :: !repos_skipped 2172 + | Forks.Not_fetched | Forks.Unrelated -> 2173 + repos_skipped := r.repo_name :: !repos_skipped 2174 + | Forks.I_am_behind count -> 2175 + (* Merge their changes *) 2176 + let remote_ref = "verse/" ^ handle ^ "/main" in 2177 + (match Git.merge ~proc ~fs ~ref_name:remote_ref ~ff_only:true checkout_path with 2178 + | Ok () -> 2179 + repos_pulled := (r.repo_name, count) :: !repos_pulled 2180 + | Error e -> 2181 + repos_failed := (r.repo_name, Fmt.str "%a" Git.pp_error e) :: !repos_failed) 2182 + | Forks.Diverged { their_ahead; _ } -> 2183 + (* Merge their changes (may create a merge commit) *) 2184 + let remote_ref = "verse/" ^ handle ^ "/main" in 2185 + (match Git.merge ~proc ~fs ~ref_name:remote_ref checkout_path with 2186 + | Ok () -> 2187 + repos_pulled := (r.repo_name, their_ahead) :: !repos_pulled 2188 + | Error e -> 2189 + repos_failed := (r.repo_name, Fmt.str "%a" Git.pp_error e) :: !repos_failed) 2190 + end) 2191 + repos_to_check; 2192 + 2193 + Ok { 2194 + repos_pulled = List.rev !repos_pulled; 2195 + repos_skipped = List.rev !repos_skipped; 2196 + repos_failed = List.rev !repos_failed; 2197 + } 2198 + 2199 + (* ==================== Cherry-pick ==================== *) 2200 + 2201 + type cherrypick_result = { 2202 + repo_name : string; 2203 + commit_hash : string; 2204 + commit_subject : string; 2205 + } 2206 + 2207 + let pp_cherrypick_result ppf result = 2208 + let short_hash = String.sub result.commit_hash 0 (min 7 (String.length result.commit_hash)) in 2209 + Fmt.pf ppf "Cherry-picked %a %s into %s@." 2210 + Fmt.(styled `Yellow string) short_hash 2211 + result.commit_subject 2212 + result.repo_name 2213 + 2214 + let cherrypick ~proc ~fs ~config ~verse_config ~sha ?(refresh=false) () = 2215 + let checkouts_path = Config.Paths.checkouts config in 2216 + 2217 + (* First, find the commit *) 2218 + match diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh () with 2219 + | None -> 2220 + Error (Config_error (Printf.sprintf "Commit %s not found in any verse diff" sha)) 2221 + | Some info -> 2222 + let checkout_path = Fpath.(checkouts_path / info.commit_repo) in 2223 + if not (Git.is_repo ~proc ~fs checkout_path) then 2224 + Error (Config_error (Printf.sprintf "No checkout for repository %s" info.commit_repo)) 2225 + else begin 2226 + match Git.cherry_pick ~proc ~fs ~commit:info.commit_hash checkout_path with 2227 + | Ok () -> 2228 + Ok { 2229 + repo_name = info.commit_repo; 2230 + commit_hash = info.commit_hash; 2231 + commit_subject = info.commit_subject; 2232 + } 2233 + | Error e -> 2234 + Error (Git_error e) 2235 + end
+67
lib/monopam.mli
··· 453 453 matching the given SHA prefix. Returns [Some commit_info] if found, [None] otherwise. 454 454 455 455 @param sha Commit SHA prefix (7+ characters) to look up *) 456 + 457 + (** {1 Pull from Verse Members} *) 458 + 459 + (** Result of pulling from a handle. *) 460 + type handle_pull_result = { 461 + repos_pulled : (string * int) list; (** (repo_name, commit_count) for each repo pulled *) 462 + repos_skipped : string list; (** Repos skipped (already in sync or no checkout) *) 463 + repos_failed : (string * string) list; (** (repo_name, error_message) for failures *) 464 + } 465 + 466 + val pp_handle_pull_result : handle_pull_result Fmt.t 467 + (** [pp_handle_pull_result] formats a pull result. *) 468 + 469 + val pull_from_handle : 470 + proc:_ Eio.Process.mgr -> 471 + fs:Eio.Fs.dir_ty Eio.Path.t -> 472 + config:Config.t -> 473 + verse_config:Verse_config.t -> 474 + handle:string -> 475 + ?repo:string -> 476 + ?refresh:bool -> 477 + unit -> 478 + (handle_pull_result, error) result 479 + (** [pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ?refresh ()] 480 + pulls commits from a verse member's forks into your local checkouts. 481 + 482 + For each repository where the handle has commits you don't have: 483 + 1. Merges their commits into your checkout's main branch 484 + 2. The changes are then ready to be synced to the monorepo via [sync] 485 + 486 + If [repo] is specified, only pulls from that repository. 487 + Otherwise, pulls from all repositories where the handle is ahead. 488 + 489 + @param handle The verse member handle (e.g., "avsm.bsky.social") 490 + @param repo Optional specific repository to pull from 491 + @param refresh If true, force fresh fetches ignoring cache (default: false) *) 492 + 493 + (** {1 Cherry-pick} *) 494 + 495 + (** Result of cherry-picking a commit. *) 496 + type cherrypick_result = { 497 + repo_name : string; 498 + commit_hash : string; 499 + commit_subject : string; 500 + } 501 + 502 + val pp_cherrypick_result : cherrypick_result Fmt.t 503 + (** [pp_cherrypick_result] formats a cherry-pick result. *) 504 + 505 + val cherrypick : 506 + proc:_ Eio.Process.mgr -> 507 + fs:Eio.Fs.dir_ty Eio.Path.t -> 508 + config:Config.t -> 509 + verse_config:Verse_config.t -> 510 + sha:string -> 511 + ?refresh:bool -> 512 + unit -> 513 + (cherrypick_result, error) result 514 + (** [cherrypick ~proc ~fs ~config ~verse_config ~sha ?refresh ()] 515 + applies a specific commit from a verse member's fork to your local checkout. 516 + 517 + Finds the commit in the verse diff output and cherry-picks it into the 518 + appropriate local checkout. The changes are then ready to be synced to 519 + the monorepo via [sync]. 520 + 521 + @param sha Commit SHA prefix (7+ characters) to cherry-pick 522 + @param refresh If true, force fresh fetches ignoring cache (default: false) *)