Monorepo management for opam overlays

Add #branch suffix support and fetch+reset for verse pull

Registry member URLs can now include a #branch suffix to specify a
non-default branch (e.g., "https://github.com/user/repo#develop").

Also changes verse pull to use fetch+reset instead of pull, since verse
repos should not have local modifications.

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

+80 -17
+8
lib/git.ml
··· 115 115 in 116 116 run_git_ok ~proc ~cwd args |> Result.map ignore 117 117 118 + let fetch_and_reset ~proc ~fs ?(remote = "origin") ~branch path = 119 + let cwd = path_to_eio ~fs path in 120 + match run_git_ok ~proc ~cwd [ "fetch"; remote ] with 121 + | Error e -> Error e 122 + | Ok _ -> 123 + let upstream = remote ^ "/" ^ branch in 124 + run_git_ok ~proc ~cwd [ "reset"; "--hard"; upstream ] |> Result.map ignore 125 + 118 126 let checkout ~proc ~fs ~branch path = 119 127 let cwd = path_to_eio ~fs path in 120 128 run_git_ok ~proc ~cwd [ "checkout"; branch ] |> Result.map ignore
+17
lib/git.mli
··· 121 121 @param remote Remote name (default: "origin") 122 122 @param branch Branch to pull (default: current branch) *) 123 123 124 + val fetch_and_reset : 125 + proc:_ Eio.Process.mgr -> 126 + fs:Eio.Fs.dir_ty Eio.Path.t -> 127 + ?remote:string -> 128 + branch:string -> 129 + Fpath.t -> 130 + (unit, error) result 131 + (** [fetch_and_reset ~proc ~fs ?remote ~branch path] fetches from the remote 132 + and resets the local branch to match the remote. 133 + 134 + This is useful for repositories that should not have local changes, as it 135 + discards any local modifications and sets the working tree to exactly match 136 + the remote branch. 137 + 138 + @param remote Remote name (default: "origin") 139 + @param branch Branch to reset to *) 140 + 124 141 val checkout : 125 142 proc:_ Eio.Process.mgr -> 126 143 fs:Eio.Fs.dir_ty Eio.Path.t ->
+18 -11
lib/verse.ml
··· 258 258 | Ok registry -> Ok registry.members 259 259 260 260 261 - (** Clone or pull a single git repo. Returns Ok true if cloned, Ok false if pulled. *) 262 - let clone_or_pull_repo ~proc ~fs ~url ~branch path = 261 + (** Clone or fetch+reset a single git repo. Returns Ok true if cloned, Ok false if reset. 262 + Uses fetch+reset instead of pull since verse repos should not have local changes. *) 263 + let clone_or_reset_repo ~proc ~fs ~url ~branch path = 263 264 if Git.is_repo ~proc ~fs path then begin 264 - match Git.pull ~proc ~fs path with 265 + match Git.fetch_and_reset ~proc ~fs ~branch path with 265 266 | Error e -> Error e 266 267 | Ok () -> Ok false 267 268 end ··· 296 297 let h = member.handle in 297 298 let mono_path = Fpath.(verse_dir / h) in 298 299 let opam_path = Fpath.(verse_dir / (h ^ "-opam")) in 299 - (* Clone or pull monorepo *) 300 + (* Clone or fetch+reset monorepo *) 300 301 Logs.info (fun m -> m "Syncing %s monorepo" h); 302 + let mono_branch = 303 + Option.value ~default:Verse_config.default_branch member.monorepo_branch 304 + in 301 305 let mono_result = 302 - clone_or_pull_repo ~proc ~fs ~url:member.monorepo 303 - ~branch:Verse_config.default_branch mono_path 306 + clone_or_reset_repo ~proc ~fs ~url:member.monorepo 307 + ~branch:mono_branch mono_path 304 308 in 305 309 let mono_err = match mono_result with 306 310 | Ok true -> Logs.info (fun m -> m " Cloned %s monorepo" h); None 307 - | Ok false -> Logs.info (fun m -> m " Pulled %s monorepo" h); None 311 + | Ok false -> Logs.info (fun m -> m " Reset %s monorepo" h); None 308 312 | Error e -> 309 313 Logs.warn (fun m -> m " Failed %s monorepo: %a" h Git.pp_error e); 310 314 Some (Fmt.str "%s monorepo: %a" h Git.pp_error e) 311 315 in 312 - (* Clone or pull opam repo *) 316 + (* Clone or fetch+reset opam repo *) 313 317 Logs.info (fun m -> m "Syncing %s opam repo" h); 318 + let opam_branch = 319 + Option.value ~default:Verse_config.default_branch member.opamrepo_branch 320 + in 314 321 let opam_result = 315 - clone_or_pull_repo ~proc ~fs ~url:member.opamrepo 316 - ~branch:Verse_config.default_branch opam_path 322 + clone_or_reset_repo ~proc ~fs ~url:member.opamrepo 323 + ~branch:opam_branch opam_path 317 324 in 318 325 let opam_err = match opam_result with 319 326 | Ok true -> Logs.info (fun m -> m " Cloned %s opam repo" h); None 320 - | Ok false -> Logs.info (fun m -> m " Pulled %s opam repo" h); None 327 + | Ok false -> Logs.info (fun m -> m " Reset %s opam repo" h); None 321 328 | Error e -> 322 329 Logs.warn (fun m -> m " Failed %s opam repo: %a" h Git.pp_error e); 323 330 Some (Fmt.str "%s opam: %a" h Git.pp_error e)
+31 -5
lib/verse_registry.ml
··· 1 - type member = { handle : string; monorepo : string; opamrepo : string } 1 + type member = { 2 + handle : string; 3 + monorepo : string; 4 + monorepo_branch : string option; 5 + opamrepo : string; 6 + opamrepo_branch : string option; 7 + } 2 8 type t = { name : string; members : member list } 3 9 4 10 let default_url = "https://tangled.org/eeg.cl.cam.ac.uk/opamverse" 5 11 12 + (** Parse a URL that may have a #branch suffix. Returns (url, branch option). *) 13 + let parse_url_with_branch s = 14 + match String.rindex_opt s '#' with 15 + | None -> (s, None) 16 + | Some i -> 17 + let url = String.sub s 0 i in 18 + let branch = String.sub s (i + 1) (String.length s - i - 1) in 19 + if branch = "" then (s, None) else (url, Some branch) 20 + 21 + (** Encode a URL with optional branch suffix. *) 22 + let encode_url_with_branch url branch = 23 + match branch with 24 + | None -> url 25 + | Some b -> url ^ "#" ^ b 26 + 6 27 let pp_member ppf m = 7 - Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle m.monorepo m.opamrepo 28 + let mono_str = encode_url_with_branch m.monorepo m.monorepo_branch in 29 + let opam_str = encode_url_with_branch m.opamrepo m.opamrepo_branch in 30 + Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle mono_str opam_str 8 31 9 32 let pp ppf t = 10 33 Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]" ··· 23 46 let member_codec : member Tomlt.t = 24 47 Tomlt.( 25 48 Table.( 26 - obj (fun handle monorepo opamrepo -> { handle; monorepo; opamrepo }) 49 + obj (fun handle monorepo_raw opamrepo_raw -> 50 + let monorepo, monorepo_branch = parse_url_with_branch monorepo_raw in 51 + let opamrepo, opamrepo_branch = parse_url_with_branch opamrepo_raw in 52 + { handle; monorepo; monorepo_branch; opamrepo; opamrepo_branch }) 27 53 |> mem "handle" string ~enc:(fun m -> m.handle) 28 - |> mem "monorepo" string ~enc:(fun m -> m.monorepo) 29 - |> mem "opamrepo" string ~enc:(fun m -> m.opamrepo) 54 + |> mem "monorepo" string ~enc:(fun m -> encode_url_with_branch m.monorepo m.monorepo_branch) 55 + |> mem "opamrepo" string ~enc:(fun m -> encode_url_with_branch m.opamrepo m.opamrepo_branch) 30 56 |> finish)) 31 57 32 58 type registry_info = { r_name : string }
+6 -1
lib/verse_registry.mli
··· 8 8 type member = { 9 9 handle : string; (** Tangled handle (e.g., "alice.bsky.social") *) 10 10 monorepo : string; (** Git URL of the member's monorepo *) 11 + monorepo_branch : string option; (** Optional branch for monorepo (from URL#branch) *) 11 12 opamrepo : string; (** Git URL of the member's opam overlay repository *) 13 + opamrepo_branch : string option; (** Optional branch for opam repo (from URL#branch) *) 12 14 } 13 - (** A registry member entry. *) 15 + (** A registry member entry. 16 + 17 + URLs may include a [#branch] suffix to specify a non-default branch. 18 + For example, ["https://github.com/user/repo#develop"]. *) 14 19 15 20 type t = { 16 21 name : string; (** Registry name *)